Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"bun": "1.3.14"
},
"dependencies": {
"@agentclientprotocol/sdk": "^2.2.0",
"@codebuff/sdk": "workspace:*",
"@opentui/core": "0.3.4",
"@opentui/react": "0.3.4",
Expand All @@ -41,6 +42,7 @@
"jimp": "^1.6.0",
"node-machine-id": "^1.1.12",
"open": "^10.1.0",
"picocolors": "^1.1.1",
"pino": "9.4.0",
"react": "^19.0.0",
"remark-breaks": "^4.0.0",
Expand Down
165 changes: 165 additions & 0 deletions cli/src/acp/__tests__/acp-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, expect, test } from 'bun:test'

import { FreebuffAcpAgent } from '../acp-agent'

import type { PrintModeEvent } from '@codebuff/common/types/print-mode'

function agent(runTurn?: unknown) {
const backend = new FreebuffAcpAgent({
runTurn: runTurn as never,
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
child: () => ({}) as never,
} as never)
const sessionId = backend.newSession('/tmp/project')
return { backend, sessionId }
}

describe('ACP event mapping', () => {
test('text becomes an agent message chunk', () => {
const { backend, sessionId } = agent()
const mapped = backend.eventToUpdate(sessionId, {
type: 'text',
text: 'hello',
} as PrintModeEvent)
expect(mapped).toEqual({
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hello' },
},
})
})

test('empty text is not reported', () => {
const { backend, sessionId } = agent()
expect(
backend.eventToUpdate(sessionId, { type: 'text', text: '' } as PrintModeEvent),
).toBeNull()
})

test('reasoning becomes a thought chunk', () => {
const { backend, sessionId } = agent()
const mapped = backend.eventToUpdate(sessionId, {
type: 'reasoning_delta',
text: 'thinking',
ancestorRunIds: [],
runId: 'r1',
agentId: 'a1',
} as PrintModeEvent)
expect(mapped).toEqual({
update: {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'thinking' },
},
})
})

test('a tool call becomes a pending tool_call with a kind', () => {
const { backend, sessionId } = agent()
const mapped = backend.eventToUpdate(sessionId, {
type: 'tool_call',
toolCallId: 'call_1',
toolName: 'read_files',
input: { paths: ['a.ts'] },
} as PrintModeEvent)
expect(mapped).toEqual({
update: {
sessionUpdate: 'tool_call',
toolCallId: 'call_1',
title: 'read_files',
name: 'read_files',
kind: 'read',
status: 'pending',
rawInput: { paths: ['a.ts'] },
},
})
})

test('an unknown tool is reported as kind other', () => {
const { backend, sessionId } = agent()
const mapped = backend.eventToUpdate(sessionId, {
type: 'tool_call',
toolCallId: 'call_2',
toolName: 'some_new_tool',
input: {},
} as PrintModeEvent)
expect((mapped as { update: { kind: string } }).update.kind).toBe('other')
})

test('a tool result completes the tool call with its output', () => {
const { backend, sessionId } = agent()
backend.eventToUpdate(sessionId, {
type: 'tool_call',
toolCallId: 'call_1',
toolName: 'read_files',
input: {},
} as PrintModeEvent)
const mapped = backend.eventToUpdate(sessionId, {
type: 'tool_result',
toolCallId: 'call_1',
toolName: 'read_files',
output: [{ type: 'json', value: 'file contents' }],
} as PrintModeEvent)
expect(mapped).toEqual({
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call_1',
status: 'completed',
content: [
{ type: 'content', content: { type: 'text', text: 'file contents' } },
],
},
})
})

test('a media tool result is described rather than dropped', () => {
const { backend, sessionId } = agent()
const mapped = backend.eventToUpdate(sessionId, {
type: 'tool_result',
toolCallId: 'call_3',
toolName: 'screenshot',
output: [{ type: 'media', data: 'AAAA', mediaType: 'image/png' }],
} as PrintModeEvent)
const content = (mapped as { update: { content: Array<{ content: { text: string } }> } })
.update.content
expect(content[0]!.content.text).toContain('image/png')
})

test('an error event becomes an error, not an update', () => {
const { backend, sessionId } = agent()
const mapped = backend.eventToUpdate(sessionId, {
type: 'error',
message: 'boom',
} as PrintModeEvent)
expect(mapped).toEqual({ error: 'boom' })
})

test('lifecycle events carry nothing a client renders', () => {
const { backend, sessionId } = agent()
expect(
backend.eventToUpdate(sessionId, {
type: 'start',
messageHistoryLength: 0,
} as PrintModeEvent),
).toBeNull()
expect(
backend.eventToUpdate(sessionId, {
type: 'finish',
totalCost: 0,
} as PrintModeEvent),
).toBeNull()
})
})

describe('ACP sessions', () => {
test('a new session has a unique id and is known', () => {
const backend = new FreebuffAcpAgent()
const first = backend.newSession('/tmp/a')
const second = backend.newSession('/tmp/b')
expect(first).not.toBe(second)
expect(backend.hasSession(first)).toBe(true)
expect(backend.hasSession('nope')).toBe(false)
})
})
91 changes: 91 additions & 0 deletions cli/src/acp/__tests__/acp-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from 'bun:test'
import * as acp from '@agentclientprotocol/sdk'

import { FreebuffAcpAgent } from '../acp-agent'

import type { PrintModeEvent } from '@codebuff/common/types/print-mode'

/**
* Drives the ACP server the way a real client (Zed, and any other ACP
* implementation) does, over the protocol itself rather than by calling the
* agent's methods: initialize -> session/new -> session/prompt, and checks
* that the agent's `session/update` notifications carry the turn's content.
*/
function buildAgentApp(backend: FreebuffAcpAgent) {
return acp
.agent({ name: 'freebuff-test' })
.onRequest(acp.methods.agent.initialize, () => ({
protocolVersion: acp.PROTOCOL_VERSION,
agentCapabilities: { loadSession: false },
}))
.onRequest(acp.methods.agent.session.new, ({ params }) => ({
sessionId: backend.newSession(params.cwd),
}))
.onRequest(acp.methods.agent.session.prompt, async ({ params, client }) => {
const stop = await backend.prompt(params.sessionId, params.prompt, client)
return { stopReason: stop }
})
.onNotification(acp.methods.agent.session.cancel, ({ params }) => {
backend.cancel(params.sessionId)
})
}

describe('ACP protocol round trip', () => {
test('a prompt turn streams agent text to the client as session updates', async () => {
// A stub turn: the SDK run path itself is covered by the CLI's own
// integration tests; here we verify the protocol surface.
const backend = new FreebuffAcpAgent({
runTurn: async (request) => {
await request.onEvent({ type: 'text', text: 'hello ' })
await request.onEvent({ type: 'text', text: 'world' })
return { previousRun: null }
},
})
const agentApp = buildAgentApp(backend)

const updates: Array<Record<string, unknown>> = []

const clientApp = acp
.client({ name: 'test-client' })
.onNotification(acp.methods.client.session.update, ({ params }) => {
updates.push(params.update as Record<string, unknown>)
})

await clientApp.connectWith(agentApp, async (agent) => {
const init = await agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
})
expect(init.protocolVersion).toBe(acp.PROTOCOL_VERSION)

const session = await agent.request(acp.methods.agent.session.new, {
cwd: '/tmp/project',
mcpServers: [],
})
expect(session.sessionId).toBeTruthy()

const response = await agent.request(acp.methods.agent.session.prompt, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'say hi' }],
})
expect(response.stopReason).toBe('end_turn')
})

const kinds = updates.map((u) => u.sessionUpdate)
expect(kinds).toEqual(['agent_message_chunk', 'agent_message_chunk'])
expect((updates[0]!.content as { text: string }).text).toBe('hello ')
expect((updates[1]!.content as { text: string }).text).toBe('world')
})

test('the event mapping is what the server sends for a text event', () => {
const backend = new FreebuffAcpAgent()
const sessionId = backend.newSession('/tmp/project')
const event: PrintModeEvent = { type: 'text', text: 'hi' }
const mapped = backend.eventToUpdate(sessionId, event)
expect(mapped).toEqual({
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hi' },
},
})
})
})
Loading
Loading