diff --git a/README.md b/README.md index 83703c018..6a9ba02ee 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ | 特性 | 说明 | 文档 | |------|------|------| | **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) | +| ACP 协议一等一支持 | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) | | Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) | | /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) | | Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) | diff --git a/build.ts b/build.ts index 11b859330..9fe50b3d7 100644 --- a/build.ts +++ b/build.ts @@ -30,6 +30,8 @@ const DEFAULT_BUILD_FEATURES = [ 'ULTRAPLAN', // P2: daemon + remote control server 'DAEMON', + // ACP (Agent Client Protocol) agent mode + 'ACP', // PR-package restored features 'WORKFLOW_SCRIPTS', 'HISTORY_SNIP', diff --git a/bun.lock b/bun.lock index 38d490689..b7965930b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "claude-code-best", "dependencies": { + "@agentclientprotocol/sdk": "^0.19.0", "@claude-code-best/mcp-chrome-bridge": "^2.0.7", "ws": "^8.20.0", }, @@ -256,6 +257,8 @@ }, }, "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.19.0", "https://registry.npmmirror.com/@agentclientprotocol/sdk/-/sdk-0.19.0.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], "@ant/claude-for-chrome-mcp": ["@ant/claude-for-chrome-mcp@workspace:packages/@ant/claude-for-chrome-mcp"], diff --git a/docs/features/acp-zed.md b/docs/features/acp-zed.md new file mode 100644 index 000000000..d83e28be2 --- /dev/null +++ b/docs/features/acp-zed.md @@ -0,0 +1,189 @@ +# ACP (Agent Client Protocol) — Zed / IDE 集成 + +> Feature Flag: `FEATURE_ACP=1`(build 和 dev 模式默认启用) +> 实现状态:可用(支持 Zed、Cursor 等 ACP 客户端) +> 源码目录:`src/services/acp/` + +## 一、功能概述 + +ACP (Agent Client Protocol) 是一种标准化的 stdio 协议,允许 IDE 和编辑器通过 stdin/stdout 的 NDJSON 流驱动 AI Agent。CCB 实现了完整的 ACP agent 端,可以被 Zed、Cursor 等支持 ACP 的客户端直接调用。 + +### 核心特性 + +- **会话管理**:新建 / 恢复 / 加载 / 分叉 / 关闭会话 +- **历史回放**:恢复会话时自动加载并回放对话历史 +- **权限桥接**:ACP 客户端的权限决策映射到 CCB 的工具权限系统 +- **斜杠命令 & Skills**:加载真实命令列表,支持 `/commit`、`/review` 等 prompt 型 skill +- **Context Window 跟踪**:精确的 usage_update,含 model prefix matching +- **Prompt 排队**:支持连续发送多条 prompt,自动排队处理 +- **模式切换**:auto / default / acceptEdits / plan / dontAsk / bypassPermissions +- **模型切换**:运行时切换 AI 模型 + +## 二、架构 + +``` +┌──────────────┐ NDJSON/stdio ┌──────────────────┐ +│ Zed / IDE │ ◄────────────────► │ CCB ACP Agent │ +│ (Client) │ stdin / stdout │ (Agent) │ +└──────────────┘ │ │ + │ entry.ts │ ← stdio → NDJSON stream + │ agent.ts │ ← ACP protocol handler + │ bridge.ts │ ← SDKMessage → ACP SessionUpdate + │ permissions.ts │ ← 权限桥接 + │ utils.ts │ ← 通用工具 + │ │ + │ QueryEngine │ ← 内部查询引擎 + └──────────────────┘ +``` + +### 文件职责 + +| 文件 | 职责 | +|------|------| +| `entry.ts` | 入口,创建 stdio → NDJSON stream,启动 `AgentSideConnection` | +| `agent.ts` | 实现 ACP `Agent` 接口:会话 CRUD、prompt、cancel、模式/模型切换 | +| `bridge.ts` | `SDKMessage` → ACP `SessionUpdate` 转换:文本/思考/工具/用量/编辑 diff | +| `permissions.ts` | ACP `requestPermission()` → CCB `CanUseToolFn` 桥接 | +| `utils.ts` | Pushable、流转换、权限模式解析、session fingerprint、路径显示 | + +## 三、配置 Zed 编辑器 + +### 3.1 Zed settings.json 配置 + +打开 Zed 的 `settings.json`(`Cmd+,` → Open Settings),添加 `agent_servers` 配置: + +```json +{ + "agent_servers": { + "ccb": { + "type": "custom", + "command": "ccb", + "args": ["--acp"] + } + } +} +``` + +### 3.3 API 认证配置 + +CCB 的 ACP agent 在启动时会自动加载 `settings.json` 中的环境变量(`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN` 等)。确保已通过 `/login` 配置好 API 供应商。 + +也可通过环境变量传入: + +```json +{ + "agent_servers": { + "claude-code": { + "command": "ccb", + "args": ["--acp"], + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-xxx" + } + } + } +} +``` + +### 3.4 在 Zed 中使用 + +1. 配置完成后重启 Zed +2. 打开任意项目目录 +3. 按 `Cmd+'`(macOS)或 `Ctrl+'`(Linux)打开 Agent Panel +4. 在 Agent Panel 顶部的下拉菜单中选择 **claude-code** +5. 开始对话 + +### 3.5 功能说明 + +| 功能 | 操作 | +|------|------| +| 对话 | 在 Agent Panel 中直接输入消息 | +| 斜杠命令 | 输入 `/` 查看可用 skills 列表(如 `/commit`、`/review`) | +| 工具权限 | 弹出权限请求时选择 Allow / Reject / Always Allow | +| 模式切换 | 通过 Agent Panel 的设置菜单切换 auto/default/plan 等模式 | +| 模型切换 | 通过 Agent Panel 的设置菜单切换 AI 模型 | +| 会话恢复 | 关闭重开 Zed 后,之前的会话可自动恢复(含历史消息) | + +## 四、配置其他 ACP 客户端 + +ACP 是开放协议,任何支持 ACP 的客户端都可以连接 CCB。通用配置模式: + +``` +命令: ccb --acp +参数: ["--acp"] +通信: stdin/stdout NDJSON +协议版本: ACP v1 +``` + +### 4.1 Cursor + +在 Cursor 的设置中配置 MCP / Agent Server,使用同样的 `ccb --acp` 命令。 + +### 4.2 自定义客户端 + +使用 `@agentclientprotocol/sdk` 可以快速构建 ACP 客户端: + +```typescript +import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk' + +// 创建连接(将 ccb --acp 作为子进程启动) +const child = spawn('ccb', ['--acp']) +const stream = ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout), +) + +const client = new ClientSideConnection(stream) + +// 初始化 +await client.initialize({ clientCapabilities: {} }) + +// 创建会话 +const { sessionId } = await client.newSession({ + cwd: '/path/to/project', +}) + +// 发送 prompt +const response = await client.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'Hello, explain this project' }], +}) + +// 监听 session 更新 +client.on('sessionUpdate', (update) => { + console.log('Update:', update) +}) +``` + +## 五、ACP 协议支持矩阵 + +| 方法 | 状态 | 说明 | +|------|------|------| +| `initialize` | ✅ | 返回 agent 信息和能力 | +| `authenticate` | ✅ | 无需认证(自托管) | +| `newSession` | ✅ | 创建新会话 | +| `resumeSession` | ✅ | 恢复已有会话(含历史回放) | +| `loadSession` | ✅ | 加载指定会话(含历史回放) | +| `listSessions` | ✅ | 列出可用会话 | +| `forkSession` | ✅ | 分叉会话 | +| `closeSession` | ✅ | 关闭会话 | +| `prompt` | ✅ | 发送消息,支持排队 | +| `cancel` | ✅ | 取消当前/排队的 prompt | +| `setSessionMode` | ✅ | 切换权限模式 | +| `setSessionModel` | ✅ | 切换 AI 模型 | +| `setSessionConfigOption` | ✅ | 动态修改配置 | + +### SessionUpdate 类型 + +| 类型 | 状态 | 说明 | +|------|------|------| +| `agent_message_chunk` | ✅ | 助手文本消息 | +| `agent_thought_chunk` | ✅ | 思考/推理内容 | +| `user_message_chunk` | ✅ | 用户消息(历史回放) | +| `tool_call` | ✅ | 工具调用开始 | +| `tool_call_update` | ✅ | 工具调用结果/状态更新 | +| `usage_update` | ✅ | token 用量 + context window | +| `plan` | ✅ | TodoWrite → plan entries | +| `available_commands_update` | ✅ | 斜杠命令 & skills 列表 | +| `current_mode_update` | ✅ | 模式切换通知 | +| `config_option_update` | ✅ | 配置更新通知 | diff --git a/package.json b/package.json index fb5d641cd..47dff2367 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,9 @@ "rcs": "bun run scripts/rcs.ts" }, "dependencies": { - "ws": "^8.20.0", - "@claude-code-best/mcp-chrome-bridge": "^2.0.7" + "@agentclientprotocol/sdk": "^0.19.0", + "@claude-code-best/mcp-chrome-bridge": "^2.0.7", + "ws": "^8.20.0" }, "devDependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", diff --git a/scripts/dev.ts b/scripts/dev.ts index ca693ab68..5e47266c3 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -37,6 +37,8 @@ const DEFAULT_FEATURES = [ "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", // P2: daemon + remote control server "DAEMON", + // ACP (Agent Client Protocol) agent mode + "ACP", // PR-package restored features "WORKFLOW_SCRIPTS", "HISTORY_SNIP", diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index c9d67d382..de4269faf 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -1184,6 +1184,17 @@ export class QueryEngine { this.abortController.abort() } + /** Reset the abort controller so the next submitMessage() call can start + * with a fresh, non-aborted signal. Must be called after interrupt(). */ + resetAbortController(): void { + this.abortController = createAbortController() + } + + /** Expose the current abort signal for external consumers (e.g. ACP bridge). */ + getAbortSignal(): AbortSignal { + return this.abortController.signal + } + getMessages(): readonly Message[] { return this.mutableMessages } diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 8b11a3d23..c9261718d 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -132,6 +132,14 @@ async function main(): Promise { return } + // Fast-path for `--acp` — ACP (Agent Client Protocol) agent mode over stdio. + if (feature('ACP') && process.argv[2] === '--acp') { + profileCheckpoint('cli_acp_path') + const { runAcpAgent } = await import('../services/acp/entry.js') + await runAcpAgent() + return + } + // Fast-path for `--daemon-worker=` (internal — supervisor spawns this). // Must come before the daemon subcommand check: spawned per-worker, so // perf-sensitive. No enableConfigs(), no analytics sinks at this layer — diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts new file mode 100644 index 000000000..8dcf3ab51 --- /dev/null +++ b/src/services/acp/__tests__/agent.test.ts @@ -0,0 +1,735 @@ +import { describe, expect, test, mock, beforeEach } from 'bun:test' + +// ── Heavy module mocks (must be before any import of the module under test) ── + +const mockSetModel = mock(() => {}) + +mock.module('../../../QueryEngine.js', () => ({ + QueryEngine: class MockQueryEngine { + submitMessage = mock(async function* () {}) + interrupt = mock(() => {}) + resetAbortController = mock(() => {}) + getAbortSignal = mock(() => new AbortController().signal) + setModel = mockSetModel + }, +})) + +mock.module('../../../tools.js', () => ({ + getTools: mock(() => []), +})) + +mock.module('../../../Tool.js', () => ({ + getEmptyToolPermissionContext: mock(() => ({})), +})) + +mock.module('../../../utils/config.js', () => ({ + enableConfigs: mock(() => {}), +})) + +mock.module('../../../bootstrap/state.js', () => ({ + setOriginalCwd: mock(() => {}), + addSlowOperation: mock(() => {}), +})) + +const mockGetDefaultAppState = mock(() => ({ + toolPermissionContext: { + mode: 'default', + additionalWorkingDirectories: new Map(), + alwaysAllowRules: { user: [], project: [], local: [] }, + alwaysDenyRules: { user: [], project: [], local: [] }, + alwaysAskRules: { user: [], project: [], local: [] }, + isBypassPermissionsModeAvailable: false, + }, + fastMode: false, + settings: {}, + tasks: {}, + verbose: false, + mainLoopModel: null, + mainLoopModelForSession: null, +})) + +mock.module('../../../state/AppStateStore.js', () => ({ + getDefaultAppState: mockGetDefaultAppState, +})) + +mock.module('../../../utils/fileStateCache.js', () => ({ + FileStateCache: class MockFileStateCache { + constructor() {} + }, +})) + +mock.module('../permissions.js', () => ({ + createAcpCanUseTool: mock(() => mock(async () => ({ behavior: 'allow', updatedInput: {} }))), +})) + +mock.module('../bridge.js', () => ({ + forwardSessionUpdates: mock(async () => ({ stopReason: 'end_turn' as const })), + replayHistoryMessages: mock(async () => {}), + toolInfoFromToolUse: mock(() => ({ title: 'Test', kind: 'other', content: [], locations: [] })), +})) + +mock.module('../utils.js', () => ({ + resolvePermissionMode: mock(() => 'default'), + computeSessionFingerprint: mock(() => '{}'), + sanitizeTitle: mock((s: string) => s), +})) + +mock.module('../../../utils/listSessionsImpl.js', () => ({ + listSessionsImpl: mock(async () => []), +})) + +const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6') + +mock.module('../../../utils/model/model.js', () => ({ + getMainLoopModel: mockGetMainLoopModel, +})) + +mock.module('../../../utils/model/modelOptions.ts', () => ({ + getModelOptions: mock(() => []), +})) + +const mockApplySafeEnvVars = mock(() => {}) +mock.module('../../../utils/managedEnv.js', () => ({ + applySafeConfigEnvironmentVariables: mockApplySafeEnvVars, +})) + +const mockDeserializeMessages = mock((msgs: unknown[]) => msgs) +const mockGetLastSessionLog = mock(async () => null) +const mockSessionIdExists = mock(() => false) + +mock.module('../../../utils/conversationRecovery.js', () => ({ + deserializeMessages: mockDeserializeMessages, +})) + +mock.module('../../../utils/sessionStorage.js', () => ({ + getLastSessionLog: mockGetLastSessionLog, + sessionIdExists: mockSessionIdExists, +})) + +const mockGetCommands = mock(async () => [ + { + name: 'commit', + description: 'Create a git commit', + type: 'prompt', + userInvocable: true, + isHidden: false, + argumentHint: '[message]', + }, + { + name: 'compact', + description: 'Compact conversation', + type: 'local', + userInvocable: true, + isHidden: false, + }, + { + name: 'hidden-skill', + description: 'Hidden skill', + type: 'prompt', + userInvocable: false, + isHidden: true, + }, +]) + +mock.module('../../../commands.js', () => ({ + getCommands: mockGetCommands, +})) + +// ── Import after mocks ──────────────────────────────────────────── + +const { AcpAgent } = await import('../agent.js') +const { forwardSessionUpdates } = await import('../bridge.js') + +// ── Helpers ─────────────────────────────────────────────────────── + +function makeConn() { + return { + sessionUpdate: mock(async () => {}), + requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } })), + } as any +} + +// ── Tests ───────────────────────────────────────────────────────── + +describe('AcpAgent', () => { + beforeEach(() => { + mockSetModel.mockClear() + mockGetMainLoopModel.mockClear() + mockGetDefaultAppState.mockClear() + }) + + describe('initialize', () => { + test('returns protocol version and agent info', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.initialize({} as any) + expect(res.protocolVersion).toBeDefined() + expect(res.agentInfo?.name).toBe('claude-code') + expect(typeof res.agentInfo?.version).toBe('string') + }) + + test('advertises image and embeddedContext capability', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.initialize({} as any) + expect(res.agentCapabilities?.promptCapabilities?.image).toBe(true) + expect(res.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) + }) + + test('loadSession capability is true', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.initialize({} as any) + expect(res.agentCapabilities?.loadSession).toBe(true) + }) + + test('session capabilities include fork, list, resume, close', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.initialize({} as any) + expect(res.agentCapabilities?.sessionCapabilities).toBeDefined() + }) + }) + + describe('authenticate', () => { + test('returns empty object (no auth required)', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.authenticate({} as any) + expect(res).toEqual({}) + }) + }) + + describe('newSession', () => { + test('returns a sessionId string', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + expect(typeof res.sessionId).toBe('string') + expect(res.sessionId.length).toBeGreaterThan(0) + }) + + test('returns modes and models', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + expect(res.modes).toBeDefined() + expect(res.models).toBeDefined() + expect(res.configOptions).toBeDefined() + }) + + test('each call returns a unique sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const r1 = await agent.newSession({ cwd: '/tmp' } as any) + const r2 = await agent.newSession({ cwd: '/tmp' } as any) + expect(r1.sessionId).not.toBe(r2.sessionId) + }) + + test('calls getDefaultAppState to build session appState', async () => { + const agent = new AcpAgent(makeConn()) + await agent.newSession({ cwd: '/tmp' } as any) + expect(mockGetDefaultAppState).toHaveBeenCalled() + }) + + test('calls getMainLoopModel to resolve current model', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + expect(mockGetMainLoopModel).toHaveBeenCalled() + // The model reported to ACP client should match what getMainLoopModel returns + expect(res.models?.currentModelId).toBe('claude-sonnet-4-6') + }) + + test('calls queryEngine.setModel with resolved model', async () => { + const agent = new AcpAgent(makeConn()) + await agent.newSession({ cwd: '/tmp' } as any) + expect(mockSetModel).toHaveBeenCalledWith('claude-sonnet-4-6') + }) + + test('respects model alias resolution via getMainLoopModel', async () => { + // Simulate a mapped model (e.g., "opus" → "glm-5.1" via ANTHROPIC_DEFAULT_OPUS_MODEL) + mockGetMainLoopModel.mockReturnValueOnce('glm-5.1') + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + expect(res.models?.currentModelId).toBe('glm-5.1') + expect(mockSetModel).toHaveBeenCalledWith('glm-5.1') + }) + + test('stores clientCapabilities from initialize', async () => { + const agent = new AcpAgent(makeConn()) + await agent.initialize({ clientCapabilities: { _meta: { terminal_output: true } } } as any) + const res = await agent.newSession({ cwd: '/tmp' } as any) + // Should not throw — clientCapabilities stored internally + expect(res.sessionId).toBeDefined() + }) + }) + + describe('prompt', () => { + test('throws when session not found', async () => { + const agent = new AcpAgent(makeConn()) + await expect( + agent.prompt({ sessionId: 'nonexistent', prompt: [] } as any) + ).rejects.toThrow('nonexistent') + }) + + test('returns end_turn for empty prompt text', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const res = await agent.prompt({ sessionId, prompt: [] } as any) + expect(res.stopReason).toBe('end_turn') + }) + + test('returns end_turn for whitespace-only prompt', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: ' ' }], + } as any) + expect(res.stopReason).toBe('end_turn') + }) + + test('calls forwardSessionUpdates for valid prompt', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.stopReason).toBe('end_turn') + }) + + test('cancel before prompt does not block next prompt', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + // Cancel when nothing is running is a no-op + await agent.cancel({ sessionId } as any) + // The next prompt should work normally + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.stopReason).toBe('end_turn') + }) + + test('cancel during prompt returns cancelled', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + // Start a prompt that hangs, then cancel it + let resolveStream!: () => void + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( + () => new Promise<{ stopReason: string }>((resolve) => { + resolveStream = () => resolve({ stopReason: 'cancelled' }) + }), + ) + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + // Cancel the running prompt + await agent.cancel({ sessionId } as any) + resolveStream() + const res = await promptPromise + // After fix, forwardSessionUpdates mock controls the result + expect(res.stopReason).toBe('cancelled') + + // Next prompt should work normally + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + const res2 = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'world' }], + } as any) + expect(res2.stopReason).toBe('end_turn') + }) + + test('returns end_turn on unexpected error', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce(async () => { + throw new Error('unexpected') + }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.stopReason).toBe('end_turn') + }) + + test('returns usage from forwardSessionUpdates', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ + stopReason: 'end_turn', + usage: { + inputTokens: 100, + outputTokens: 50, + cachedReadTokens: 10, + cachedWriteTokens: 5, + }, + }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.usage).toBeDefined() + expect(res.usage!.inputTokens).toBe(100) + expect(res.usage!.outputTokens).toBe(50) + expect(res.usage!.totalTokens).toBe(165) + }) + }) + + describe('cancel', () => { + test('does not throw for unknown session', async () => { + const agent = new AcpAgent(makeConn()) + await expect(agent.cancel({ sessionId: 'ghost' } as any)).resolves.toBeUndefined() + }) + }) + + describe('closeSession', () => { + test('throws for unknown session', async () => { + const agent = new AcpAgent(makeConn()) + await expect(agent.unstable_closeSession({ sessionId: 'ghost' } as any)).rejects.toThrow('Session not found') + }) + + test('removes session after close', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await agent.unstable_closeSession({ sessionId } as any) + expect(agent.sessions.has(sessionId)).toBe(false) + }) + }) + + describe('setSessionModel', () => { + test('updates model on queryEngine', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + mockSetModel.mockClear() + await agent.unstable_setSessionModel({ sessionId, modelId: 'glm-5.1' } as any) + expect(mockSetModel).toHaveBeenCalledWith('glm-5.1') + }) + + test('passes alias modelId to queryEngine as-is for later resolution', async () => { + // "sonnet[1m]" is stored raw — QueryEngine.submitMessage() calls + // parseUserSpecifiedModel() which resolves aliases via env vars + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + mockSetModel.mockClear() + await agent.unstable_setSessionModel({ sessionId, modelId: 'sonnet[1m]' } as any) + expect(mockSetModel).toHaveBeenCalledWith('sonnet[1m]') + }) + }) + + describe('entry.ts initialization contract', () => { + test('entry.ts imports applySafeConfigEnvironmentVariables from managedEnv', async () => { + // Verify the module import exists — this catches if entry.ts forgets + // to import applySafeConfigEnvironmentVariables + const entrySource = await Bun.file( + new URL('../entry.ts', import.meta.url), + ).text() + expect(entrySource).toContain('applySafeConfigEnvironmentVariables') + expect(entrySource).toContain('enableConfigs') + + // Verify applySafe is called after enableConfigs in the source + const enableIdx = entrySource.indexOf('enableConfigs()') + const applyIdx = entrySource.indexOf('applySafeConfigEnvironmentVariables()') + expect(enableIdx).toBeGreaterThan(-1) + expect(applyIdx).toBeGreaterThan(-1) + expect(enableIdx).toBeLessThan(applyIdx) + }) + }) + + describe('prompt usage tracking', () => { + test('returns totalTokens as sum of all token types', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ + stopReason: 'end_turn', + usage: { + inputTokens: 100, + outputTokens: 50, + cachedReadTokens: 10, + cachedWriteTokens: 5, + }, + }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.usage).toBeDefined() + expect(res.usage!.totalTokens).toBe(165) + }) + + test('returns undefined usage when forwardSessionUpdates returns none', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ + stopReason: 'end_turn', + }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.usage).toBeUndefined() + }) + }) + + describe('prompt error handling', () => { + test('returns cancelled when session was cancelled during prompt', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce(async () => { + // Simulate cancel happening during forward + const session = agent.sessions.get(sessionId) + if (session) session.cancelled = true + return { stopReason: 'end_turn' } + }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.stopReason).toBe('cancelled') + }) + + test('returns cancelled on cancel after error', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce(async () => { + const session = agent.sessions.get(sessionId) + if (session) session.cancelled = true + throw new Error('unexpected') + }) + const res = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(res.stopReason).toBe('cancelled') + }) + }) + + describe('resumeSession', () => { + test('creates new session with the requested sessionId when not in memory', async () => { + const agent = new AcpAgent(makeConn()) + const requestedId = 'e73e9b66-9637-4477-b512-af45357b1dcb' + const res = await agent.unstable_resumeSession({ + sessionId: requestedId, + cwd: '/tmp', + mcpServers: [], + } as any) + // The session must be stored under the requested ID + expect(agent.sessions.has(requestedId)).toBe(true) + // Response should have modes/models/configOptions + expect(res.modes).toBeDefined() + expect(res.models).toBeDefined() + }) + + test('reuses existing session when sessionId matches and fingerprint unchanged', async () => { + const agent = new AcpAgent(makeConn()) + const res1 = await agent.newSession({ cwd: '/tmp' } as any) + const sid = res1.sessionId + const originalSession = agent.sessions.get(sid) + // Resume with same params + const res2 = await agent.unstable_resumeSession({ + sessionId: sid, + cwd: '/tmp', + mcpServers: [], + } as any) + // Same session object — not recreated + expect(agent.sessions.get(sid)).toBe(originalSession) + }) + + test('can prompt after resumeSession with previously unknown sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const sid = 'restored-session-id-1234' + await agent.unstable_resumeSession({ + sessionId: sid, + cwd: '/tmp', + mcpServers: [], + } as any) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + const res = await agent.prompt({ + sessionId: sid, + prompt: [{ type: 'text', text: 'hello after restore' }], + } as any) + expect(res.stopReason).toBe('end_turn') + }) + }) + + describe('loadSession', () => { + test('creates new session with the requested sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const requestedId = 'aaaa-bbbb-cccc' + await agent.loadSession({ + sessionId: requestedId, + cwd: '/tmp', + mcpServers: [], + } as any) + expect(agent.sessions.has(requestedId)).toBe(true) + }) + + test('can prompt after loadSession', async () => { + const agent = new AcpAgent(makeConn()) + const sid = 'loaded-session-id' + await agent.loadSession({ + sessionId: sid, + cwd: '/tmp', + mcpServers: [], + } as any) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + const res = await agent.prompt({ + sessionId: sid, + prompt: [{ type: 'text', text: 'hello after load' }], + } as any) + expect(res.stopReason).toBe('end_turn') + }) + }) + + describe('forkSession', () => { + test('returns a different sessionId from any existing', async () => { + const agent = new AcpAgent(makeConn()) + const original = await agent.newSession({ cwd: '/tmp' } as any) + const forked = await agent.unstable_forkSession({ + cwd: '/tmp', + mcpServers: [], + } as any) + expect(forked.sessionId).not.toBe(original.sessionId) + expect(agent.sessions.has(forked.sessionId)).toBe(true) + }) + }) + + describe('setSessionMode', () => { + test('updates current mode on the session', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await agent.setSessionMode({ sessionId, modeId: 'auto' } as any) + const session = agent.sessions.get(sessionId) + expect(session?.modes.currentModeId).toBe('auto') + }) + + test('throws for invalid mode', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await expect( + agent.setSessionMode({ sessionId, modeId: 'invalid_mode' } as any), + ).rejects.toThrow('Invalid mode') + }) + + test('throws for unknown session', async () => { + const agent = new AcpAgent(makeConn()) + await expect( + agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any), + ).rejects.toThrow('Session not found') + }) + }) + + describe('setSessionConfigOption', () => { + test('throws for unknown config option', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'nonexistent', + value: 'x', + } as any), + ).rejects.toThrow('Unknown config option') + }) + + test('throws for non-string value', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'mode', + value: 42, + } as any), + ).rejects.toThrow('Invalid value') + }) + }) + + describe('prompt queueing', () => { + test('queued prompts execute in order after current prompt finishes', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + // First prompt hangs + let resolveFirst!: () => void + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( + () => new Promise<{ stopReason: string }>((resolve) => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + // Second prompt resolves normally + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + + const p1 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'first' }] } as any) + const p2 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] } as any) + + // Resolve the first prompt to unblock the second + resolveFirst() + const [r1, r2] = await Promise.all([p1, p2]) + expect(r1.stopReason).toBe('end_turn') + expect(r2.stopReason).toBe('end_turn') + }) + + test('queued prompts return cancelled when session is cancelled', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + // First prompt hangs + let resolveFirst!: () => void + ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( + () => new Promise<{ stopReason: string }>((resolve) => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + + const p1 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'first' }] } as any) + const p2 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] } as any) + + // Cancel while first is running — both should be cancelled + await agent.cancel({ sessionId } as any) + resolveFirst() + const [r1, r2] = await Promise.all([p1, p2]) + expect(r1.stopReason).toBe('cancelled') + expect(r2.stopReason).toBe('cancelled') + }) + }) + + describe('commands', () => { + test('sends filtered prompt-type commands to client', async () => { + const conn = makeConn() + const agent = new AcpAgent(conn) + await agent.newSession({ cwd: '/tmp' } as any) + + // Wait for setTimeout-based sendAvailableCommandsUpdate + await new Promise(r => setTimeout(r, 10)) + + const calls = (conn.sessionUpdate as ReturnType).mock.calls + const cmdUpdate = calls.find((c: any[]) => { + const update = c[0]?.update + return update?.sessionUpdate === 'available_commands_update' + }) + expect(cmdUpdate).toBeDefined() + + const cmds = (cmdUpdate as any[])[0].update.availableCommands + // Only prompt-type, non-hidden, userInvocable commands + const names = cmds.map((c: any) => c.name) + expect(names).toContain('commit') + expect(names).not.toContain('compact') // type: 'local' + expect(names).not.toContain('hidden-skill') // isHidden: true, userInvocable: false + }) + + test('maps argumentHint to input.hint', async () => { + const conn = makeConn() + const agent = new AcpAgent(conn) + await agent.newSession({ cwd: '/tmp' } as any) + + await new Promise(r => setTimeout(r, 10)) + + const calls = (conn.sessionUpdate as ReturnType).mock.calls + const cmdUpdate = calls.find((c: any[]) => { + const update = c[0]?.update + return update?.sessionUpdate === 'available_commands_update' + }) + const commit = (cmdUpdate as any[])[0].update.availableCommands.find( + (c: any) => c.name === 'commit', + ) + expect(commit.input).toEqual({ hint: '[message]' }) + }) + }) +}) diff --git a/src/services/acp/__tests__/bridge.test.ts b/src/services/acp/__tests__/bridge.test.ts new file mode 100644 index 000000000..5e885d95d --- /dev/null +++ b/src/services/acp/__tests__/bridge.test.ts @@ -0,0 +1,677 @@ +import { describe, expect, test, mock } from 'bun:test' +import { + toolInfoFromToolUse, + toolUpdateFromToolResult, + toolUpdateFromEditToolResponse, + forwardSessionUpdates, +} from '../bridge.js' +import { markdownEscape, toDisplayPath } from '../utils.js' +import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk' +import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js' + +// ── Helpers ──────────────────────────────────────────────────────── + +function makeConn(overrides: Partial = {}): AgentSideConnection { + return { + sessionUpdate: mock(async () => {}), + requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } }) as any), + ...overrides, + } as unknown as AgentSideConnection +} + +async function* makeStream(msgs: SDKMessage[]): AsyncGenerator { + for (const m of msgs) yield m +} + +// ── toolInfoFromToolUse ──────────────────────────────────────────── + +describe('toolInfoFromToolUse', () => { + const kindCases: Array<[string, ToolKind]> = [ + ['Read', 'read'], + ['Edit', 'edit'], + ['Write', 'edit'], + ['Bash', 'execute'], + ['Glob', 'search'], + ['Grep', 'search'], + ['WebFetch', 'fetch'], + ['WebSearch', 'fetch'], + ['Agent', 'think'], + ['Task', 'think'], + ['TodoWrite', 'think'], + ['ExitPlanMode', 'switch_mode'], + ] + + for (const [name, expected] of kindCases) { + test(`${name} → ${expected}`, () => { + const info = toolInfoFromToolUse({ name, id: 'test', input: {} }) + expect(info.kind).toBe(expected) + }) + } + + test('unknown tool name → other', () => { + expect(toolInfoFromToolUse({ name: 'SomeFancyTool', id: 'x', input: {} }).kind).toBe('other' as ToolKind) + expect(toolInfoFromToolUse({ name: '', id: 'x', input: {} }).kind).toBe('other' as ToolKind) + }) + + // ── Bash ────────────────────────────────────────────────────── + + test('Bash with command → title shows command', () => { + const info = toolInfoFromToolUse({ name: 'Bash', id: 'x', input: { command: 'ls -la', description: 'List files' } }) + expect(info.title).toBe('ls -la') + expect(info.content).toEqual([ + { type: 'content', content: { type: 'text', text: 'List files' } }, + ]) + }) + + test('Bash with terminalOutput → returns terminalId content', () => { + const info = toolInfoFromToolUse( + { name: 'Bash', id: 'tu_123', input: { command: 'ls' } }, + true, + ) + expect(info.kind).toBe('execute') + expect(info.content).toEqual([{ type: 'terminal', terminalId: 'tu_123' }]) + }) + + test('Bash without description → empty content', () => { + const info = toolInfoFromToolUse({ name: 'Bash', id: 'x', input: { command: 'ls' } }) + expect(info.content).toEqual([]) + }) + + // ── Glob ────────────────────────────────────────────────────── + + test('Glob with pattern → title shows Find', () => { + const info = toolInfoFromToolUse({ name: 'Glob', id: 'x', input: { pattern: '*/**.ts' } }) + expect(info.title).toBe('Find `*/**.ts`') + expect(info.locations).toEqual([]) + }) + + test('Glob with path → locations include path', () => { + const info = toolInfoFromToolUse({ name: 'Glob', id: 'x', input: { pattern: '*.ts', path: '/src' } }) + expect(info.title).toBe('Find `/src` `*.ts`') + expect(info.locations).toEqual([{ path: '/src' }]) + }) + + // ── Task/Agent ──────────────────────────────────────────────── + + test('Task with description and prompt → content has prompt text', () => { + const info = toolInfoFromToolUse({ + name: 'Task', + id: 'x', + input: { description: 'Handle task', prompt: 'Do the work' }, + }) + expect(info.title).toBe('Handle task') + expect(info.content).toEqual([ + { type: 'content', content: { type: 'text', text: 'Do the work' } }, + ]) + }) + + // ── Grep ────────────────────────────────────────────────────── + + test('Grep with full flags', () => { + const info = toolInfoFromToolUse({ + name: 'Grep', + id: 'x', + input: { + pattern: 'todo', + path: '/src', + '-i': true, + '-n': true, + '-A': 3, + '-B': 2, + '-C': 5, + head_limit: 10, + glob: '*.ts', + type: 'js', + multiline: true, + }, + }) + expect(info.title).toContain('-i') + expect(info.title).toContain('-n') + expect(info.title).toContain('-A 3') + expect(info.title).toContain('-B 2') + expect(info.title).toContain('-C 5') + expect(info.title).toContain('| head -10') + expect(info.title).toContain('--include="*.ts"') + expect(info.title).toContain('--type=js') + expect(info.title).toContain('-P') + expect(info.title).toContain('"todo"') + expect(info.title).toContain('/src') + }) + + test('Grep with files_with_matches → -l', () => { + const info = toolInfoFromToolUse({ + name: 'Grep', + id: 'x', + input: { pattern: 'foo', output_mode: 'files_with_matches' }, + }) + expect(info.title).toContain('-l') + }) + + test('Grep with count → -c', () => { + const info = toolInfoFromToolUse({ + name: 'Grep', + id: 'x', + input: { pattern: 'foo', output_mode: 'count' }, + }) + expect(info.title).toContain('-c') + }) + + // ── Write ───────────────────────────────────────────────────── + + test('Write with file_path and content → diff content', () => { + const info = toolInfoFromToolUse({ + name: 'Write', + id: 'x', + input: { file_path: '/Users/test/project/example.txt', content: 'Hello, World!\nThis is test content.' }, + }) + expect(info.kind).toBe('edit') + expect(info.title).toBe('Write /Users/test/project/example.txt') + expect(info.content).toEqual([ + { + type: 'diff', + path: '/Users/test/project/example.txt', + oldText: null, + newText: 'Hello, World!\nThis is test content.', + }, + ]) + expect(info.locations).toEqual([{ path: '/Users/test/project/example.txt' }]) + }) + + // ── Edit ────────────────────────────────────────────────────── + + test('Edit with file_path → diff content', () => { + const info = toolInfoFromToolUse({ + name: 'Edit', + id: 'x', + input: { file_path: '/Users/test/project/test.txt', old_string: 'old text', new_string: 'new text' }, + }) + expect(info.kind).toBe('edit') + expect(info.title).toBe('Edit /Users/test/project/test.txt') + expect(info.content).toEqual([ + { + type: 'diff', + path: '/Users/test/project/test.txt', + oldText: 'old text', + newText: 'new text', + }, + ]) + }) + + test('Edit without file_path → empty content', () => { + const info = toolInfoFromToolUse({ name: 'Edit', id: 'x', input: {} }) + expect(info.title).toBe('Edit') + expect(info.content).toEqual([]) + }) + + // ── Read ────────────────────────────────────────────────────── + + test('Read with file_path → locations include path and line 1', () => { + const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/src/foo.ts' } }) + expect(info.locations).toEqual([{ path: '/src/foo.ts', line: 1 }]) + }) + + test('Read with limit', () => { + const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/large.txt', limit: 100 } }) + expect(info.title).toContain('(1 - 100)') + }) + + test('Read with offset and limit', () => { + const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/large.txt', offset: 50, limit: 100 } }) + expect(info.title).toContain('(50 - 149)') + expect(info.locations).toEqual([{ path: '/large.txt', line: 50 }]) + }) + + test('Read with only offset', () => { + const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/large.txt', offset: 200 } }) + expect(info.title).toContain('(from line 200)') + }) + + test('Read with cwd → relative path in title, absolute in locations', () => { + const info = toolInfoFromToolUse( + { name: 'Read', id: 'x', input: { file_path: '/Users/test/project/src/main.ts' } }, + false, + '/Users/test/project', + ) + expect(info.title).toBe('Read src/main.ts') + expect(info.locations).toEqual([{ path: '/Users/test/project/src/main.ts', line: 1 }]) + }) + + // ── WebSearch ───────────────────────────────────────────────── + + test('WebSearch with allowed/blocked domains', () => { + const info = toolInfoFromToolUse({ + name: 'WebSearch', + id: 'x', + input: { query: 'test', allowed_domains: ['a.com'], blocked_domains: ['b.com'] }, + }) + expect(info.title).toContain('allowed: a.com') + expect(info.title).toContain('blocked: b.com') + }) + + // ── TodoWrite ───────────────────────────────────────────────── + + test('TodoWrite with todos array → title shows content', () => { + const info = toolInfoFromToolUse({ + name: 'TodoWrite', + id: 'x', + input: { todos: [{ content: 'Task 1' }, { content: 'Task 2' }] }, + }) + expect(info.title).toContain('Task 1') + expect(info.title).toContain('Task 2') + }) + + // ── ExitPlanMode ────────────────────────────────────────────── + + test('ExitPlanMode with plan → content has plan text', () => { + const info = toolInfoFromToolUse({ + name: 'ExitPlanMode', + id: 'x', + input: { plan: 'Do the thing' }, + }) + expect(info.title).toBe('Ready to code?') + expect(info.content).toEqual([ + { type: 'content', content: { type: 'text', text: 'Do the thing' } }, + ]) + }) +}) + +// ── toolUpdateFromToolResult ─────────────────────────────────────── + +describe('toolUpdateFromToolResult', () => { + test('returns empty for Edit success', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'text', text: 'The file has been edited' }], is_error: false, tool_use_id: 't1' }, + { name: 'Edit', id: 't1' }, + ) + expect(result).toEqual({}) + }) + + test('returns error content for Edit failure', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'text', text: 'Failed to find `old_string`' }], is_error: true, tool_use_id: 't1' }, + { name: 'Edit', id: 't1' }, + ) + expect(result.content).toEqual([ + { type: 'content', content: { type: 'text', text: '```\nFailed to find `old_string`\n```' } }, + ]) + }) + + test('returns markdown-escaped content for Read', () => { + const result = toolUpdateFromToolResult( + { content: 'let x = 1', is_error: false, tool_use_id: 't1' }, + { name: 'Read', id: 't1' }, + ) + expect(result.content).toBeDefined() + expect(result.content![0].type).toBe('content') + // Should be wrapped in markdown code fence + const text = (result.content![0] as { type: string; content: { type: string; text: string } }).content.text + expect(text).toContain('```') + expect(text).toContain('let x = 1') + }) + + test('returns console block for Bash output', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'text', text: 'hello world' }], is_error: false, tool_use_id: 't1' }, + { name: 'Bash', id: 't1' }, + ) + expect(result.content).toEqual([ + { type: 'content', content: { type: 'text', text: '```console\nhello world\n```' } }, + ]) + }) + + test('returns terminal metadata for Bash with terminalOutput', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'text', text: 'output' }], is_error: false, tool_use_id: 't1' }, + { name: 'Bash', id: 't1' }, + true, + ) + expect(result.content).toEqual([{ type: 'terminal', terminalId: 't1' }]) + expect(result._meta).toBeDefined() + expect((result._meta as Record).terminal_info).toEqual({ terminal_id: 't1' }) + expect((result._meta as Record).terminal_output).toEqual({ terminal_id: 't1', data: 'output' }) + expect((result._meta as Record).terminal_exit).toEqual({ terminal_id: 't1', exit_code: 0, signal: null }) + }) + + test('handles bash_code_execution_result format', () => { + const result = toolUpdateFromToolResult( + { content: { type: 'bash_code_execution_result', stdout: 'out', stderr: 'err', return_code: 0 }, is_error: false, tool_use_id: 't1' }, + { name: 'Bash', id: 't1' }, + true, + ) + const meta = result._meta as Record + const termOutput = meta.terminal_output as { data: string } + expect(termOutput.data).toBe('out\nerr') + }) + + test('returns empty when no toolUse', () => { + const result = toolUpdateFromToolResult( + { content: 'text', is_error: false }, + undefined, + ) + expect(result).toEqual({}) + }) + + test('transforms tool_reference content', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'tool_reference', tool_name: 'some_tool' }], is_error: false, tool_use_id: 't1' }, + { name: 'ToolSearch', id: 't1' }, + ) + expect(result.content).toEqual([ + { type: 'content', content: { type: 'text', text: 'Tool: some_tool' } }, + ]) + }) + + test('transforms web_search_result content', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'web_search_result', title: 'Test Result', url: 'https://example.com' }], is_error: false, tool_use_id: 't1' }, + { name: 'WebSearch', id: 't1' }, + ) + expect(result.content).toEqual([ + { type: 'content', content: { type: 'text', text: 'Test Result (https://example.com)' } }, + ]) + }) + + test('transforms code_execution_result content', () => { + const result = toolUpdateFromToolResult( + { content: [{ type: 'code_execution_result', stdout: 'Hello World', stderr: '' }], is_error: false, tool_use_id: 't1' }, + { name: 'CodeExecution', id: 't1' }, + ) + expect(result.content).toEqual([ + { type: 'content', content: { type: 'text', text: 'Output: Hello World' } }, + ]) + }) + + test('returns title for ExitPlanMode', () => { + const result = toolUpdateFromToolResult( + { content: 'ok', is_error: false, tool_use_id: 't1' }, + { name: 'ExitPlanMode', id: 't1' }, + ) + expect(result.title).toBe('Exited Plan Mode') + }) +}) + +// ── toolUpdateFromEditToolResponse ───────────────────────────────── + +describe('toolUpdateFromEditToolResponse', () => { + test('returns empty for null/undefined/string', () => { + expect(toolUpdateFromEditToolResponse(null)).toEqual({}) + expect(toolUpdateFromEditToolResponse(undefined)).toEqual({}) + expect(toolUpdateFromEditToolResponse('string')).toEqual({}) + }) + + test('returns empty when filePath or structuredPatch missing', () => { + expect(toolUpdateFromEditToolResponse({})).toEqual({}) + expect(toolUpdateFromEditToolResponse({ filePath: '/foo.ts' })).toEqual({}) + expect(toolUpdateFromEditToolResponse({ structuredPatch: [] })).toEqual({}) + }) + + test('builds diff content from single hunk', () => { + const result = toolUpdateFromEditToolResponse({ + filePath: '/Users/test/project/test.txt', + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [' context before', '-old line', '+new line', ' context after'], + }, + ], + }) + expect(result).toEqual({ + content: [ + { + type: 'diff', + path: '/Users/test/project/test.txt', + oldText: 'context before\nold line\ncontext after', + newText: 'context before\nnew line\ncontext after', + }, + ], + locations: [{ path: '/Users/test/project/test.txt', line: 1 }], + }) + }) + + test('builds multiple diff blocks for replaceAll with multiple hunks', () => { + const result = toolUpdateFromEditToolResponse({ + filePath: '/Users/test/project/file.ts', + structuredPatch: [ + { oldStart: 5, oldLines: 1, newStart: 5, newLines: 1, lines: ['-oldValue', '+newValue'] }, + { oldStart: 20, oldLines: 1, newStart: 20, newLines: 1, lines: ['-oldValue', '+newValue'] }, + ], + }) + expect(result.content).toHaveLength(2) + expect(result.locations).toHaveLength(2) + expect(result.locations).toEqual([ + { path: '/Users/test/project/file.ts', line: 5 }, + { path: '/Users/test/project/file.ts', line: 20 }, + ]) + }) + + test('handles deletion (newText becomes empty string)', () => { + const result = toolUpdateFromEditToolResponse({ + filePath: '/Users/test/project/file.ts', + structuredPatch: [ + { oldStart: 10, oldLines: 2, newStart: 10, newLines: 1, lines: [' context', '-removed line'] }, + ], + }) + expect(result.content).toEqual([ + { + type: 'diff', + path: '/Users/test/project/file.ts', + oldText: 'context\nremoved line', + newText: 'context', + }, + ]) + }) + + test('returns empty for empty structuredPatch array', () => { + expect( + toolUpdateFromEditToolResponse({ filePath: '/foo.ts', structuredPatch: [] }), + ).toEqual({}) + }) +}) + +// ── markdownEscape ───────────────────────────────────────────────── + +describe('markdownEscape', () => { + test('wraps basic text in code fence', () => { + expect(markdownEscape('Hello *world*!')).toBe('```\nHello *world*!\n```') + }) + + test('extends fence for text containing backtick fences', () => { + const text = 'for example:\n```markdown\nHello *world*!\n```\n' + expect(markdownEscape(text)).toBe('````\nfor example:\n```markdown\nHello *world*!\n```\n````') + }) +}) + +// ── toDisplayPath ────────────────────────────────────────────────── + +describe('toDisplayPath', () => { + test('relativizes paths inside cwd', () => { + expect(toDisplayPath('/Users/test/project/src/main.ts', '/Users/test/project')).toBe('src/main.ts') + }) + + test('keeps absolute paths outside cwd', () => { + expect(toDisplayPath('/etc/hosts', '/Users/test/project')).toBe('/etc/hosts') + }) + + test('returns original when no cwd', () => { + expect(toDisplayPath('/Users/test/project/src/main.ts')).toBe('/Users/test/project/src/main.ts') + }) + + test('partial directory name match does not relativize', () => { + expect(toDisplayPath('/Users/test/project-other/file.ts', '/Users/test/project')).toBe('/Users/test/project-other/file.ts') + }) +}) + +// ── forwardSessionUpdates ───────────────────────────────────────── + +describe('forwardSessionUpdates', () => { + test('returns end_turn when stream is empty', async () => { + const conn = makeConn() + const result = await forwardSessionUpdates('s1', makeStream([]), conn, new AbortController().signal, {}) + expect(result.stopReason).toBe('end_turn') + }) + + test('returns cancelled when aborted before iteration', async () => { + const ac = new AbortController() + ac.abort() + const conn = makeConn() + const result = await forwardSessionUpdates('s1', makeStream([ + { type: 'assistant', message: { content: [{ type: 'text', text: 'hi' }] } } as unknown as SDKMessage, + ]), conn, ac.signal, {}) + expect(result.stopReason).toBe('cancelled') + }) + + test('forwards assistant text message as agent_message_chunk', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }], role: 'assistant' } } as unknown as SDKMessage, + ] + const result = await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + const calls = (conn.sessionUpdate as ReturnType).mock.calls + expect(calls.length).toBeGreaterThanOrEqual(1) + expect(calls[0][0]).toMatchObject({ + sessionId: 's1', + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello!' } }, + }) + expect(result.stopReason).toBe('end_turn') + }) + + test('forwards thinking block as agent_thought_chunk', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'reasoning...' }], role: 'assistant' } } as unknown as SDKMessage, + ] + await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + const calls = (conn.sessionUpdate as ReturnType).mock.calls + expect(calls[0][0].update).toMatchObject({ sessionUpdate: 'agent_thought_chunk' }) + }) + + test('forwards tool_use block as tool_call', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'tu_1', + name: 'Bash', + input: { command: 'ls' }, + }], + role: 'assistant', + }, + } as unknown as SDKMessage, + ] + await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + const update = (conn.sessionUpdate as ReturnType).mock.calls[0][0].update as Record + expect(update.sessionUpdate).toBe('tool_call') + expect(update.toolCallId).toBe('tu_1') + expect(update.kind).toBe('execute' as ToolKind) + expect(update.status).toBe('pending') + }) + + test('sends usage_update on result message with correct tokens', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 }, + total_cost_usd: 0.01, + } as unknown as SDKMessage, + ] + const result = await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + expect(result.stopReason).toBe('end_turn') + expect(result.usage).toBeDefined() + expect(result.usage!.inputTokens).toBe(100) + expect(result.usage!.outputTokens).toBe(50) + }) + + test('sends usage_update with context window from modelUsage', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'hi' }], + role: 'assistant', + model: 'claude-opus-4-20250514', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 }, + }, + parent_tool_use_id: null, + } as unknown as SDKMessage, + { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + modelUsage: { + 'claude-opus-4-20250514': { contextWindow: 1000000 }, + }, + } as unknown as SDKMessage, + ] + await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + const calls = (conn.sessionUpdate as ReturnType).mock.calls + const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record>).update ?? {})['sessionUpdate'] === 'usage_update') + expect(usageUpdate).toBeDefined() + expect(((usageUpdate![0] as Record).update as Record).size).toBe(1000000) + }) + + test('sends usage_update with prefix-matched modelUsage', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'hi' }], + role: 'assistant', + model: 'claude-opus-4-6-20250514', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + parent_tool_use_id: null, + } as unknown as SDKMessage, + { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + modelUsage: { + 'claude-opus-4-6': { contextWindow: 2000000 }, + }, + } as unknown as SDKMessage, + ] + await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + const calls = (conn.sessionUpdate as ReturnType).mock.calls + const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record>).update ?? {})['sessionUpdate'] === 'usage_update') + expect(usageUpdate).toBeDefined() + expect(((usageUpdate![0] as Record).update as Record).size).toBe(2000000) + }) + + test('resets usage on compact_boundary', async () => { + const conn = makeConn() + const msgs: SDKMessage[] = [ + { type: 'system', subtype: 'compact_boundary' } as unknown as SDKMessage, + ] + await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + const calls = (conn.sessionUpdate as ReturnType).mock.calls + const usageCall = calls.find((c: unknown[]) => ((c[0] as Record>).update ?? {})['sessionUpdate'] === 'usage_update') + expect(usageCall).toBeDefined() + expect(((usageCall![0] as Record).update as Record).used).toBe(0) + }) + + test('re-throws unexpected errors from stream', async () => { + const conn = makeConn() + async function* errorStream(): AsyncGenerator { + throw new Error('stream exploded') + } + await expect( + forwardSessionUpdates('s1', errorStream(), conn, new AbortController().signal, {}), + ).rejects.toThrow('stream exploded') + }) +}) diff --git a/src/services/acp/__tests__/permissions.test.ts b/src/services/acp/__tests__/permissions.test.ts new file mode 100644 index 000000000..451caf1b6 --- /dev/null +++ b/src/services/acp/__tests__/permissions.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test, mock } from 'bun:test' +import type { AgentSideConnection } from '@agentclientprotocol/sdk' +import type { Tool as ToolType } from '../../../Tool.js' + +// ── Inline re-implementation of createAcpCanUseTool for isolated testing ── +// We cannot import the real permissions.js because agent.test.ts mocks it globally. +// Instead we re-implement the core logic here, using our own mocked bridge.js. + +function createAcpCanUseTool( + conn: AgentSideConnection, + sessionId: string, + getCurrentMode: () => string, +): any { + return async ( + tool: { name: string }, + input: Record, + _context: any, + _assistantMessage: any, + toolUseID: string, + ): Promise<{ behavior: string; message?: string; updatedInput?: Record }> => { + if (getCurrentMode() === 'bypassPermissions') { + return { behavior: 'allow', updatedInput: input } + } + + const TOOL_KIND_MAP: Record = { + Read: 'read', Edit: 'edit', Write: 'edit', + Bash: 'execute', Glob: 'search', Grep: 'search', + WebFetch: 'fetch', WebSearch: 'fetch', + } + + const toolCall = { + toolCallId: toolUseID, + title: tool.name, + kind: TOOL_KIND_MAP[tool.name] ?? 'other', + status: 'pending', + rawInput: input, + } + + const options = [ + { kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' }, + { kind: 'allow_once', name: 'Allow', optionId: 'allow' }, + { kind: 'reject_once', name: 'Reject', optionId: 'reject' }, + ] + + try { + const response = await (conn as any).requestPermission({ sessionId, toolCall, options }) + + if (response.outcome.outcome === 'cancelled') { + return { behavior: 'deny', message: 'Permission request cancelled by client' } + } + + if (response.outcome.outcome === 'selected' && response.outcome.optionId !== undefined) { + const optionId = response.outcome.optionId + if (optionId === 'allow' || optionId === 'allow_always') { + return { behavior: 'allow', updatedInput: input } + } + } + + return { behavior: 'deny', message: 'Permission denied by client' } + } catch { + return { behavior: 'deny', message: 'Permission request failed' } + } + } +} + +function makeConn(permissionResponse: Record) { + return { + requestPermission: mock(async () => permissionResponse), + sessionUpdate: mock(async () => {}), + } as unknown as AgentSideConnection +} + +function makeTool(name: string) { + return { name } as unknown as ToolType +} + +const dummyContext = {} as Record +const dummyMsg = {} as Record + +describe('createAcpCanUseTool', () => { + test('returns allow when client selects allow option', async () => { + const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } }) + const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default') + const result = await canUseTool(makeTool('Bash'), { command: 'ls' }, dummyContext as any, dummyMsg as any, 'tu_1') + expect(result.behavior).toBe('allow') + }) + + test('returns deny when client selects reject option', async () => { + const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } }) + const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default') + const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2') + expect(result.behavior).toBe('deny') + }) + + test('returns deny when client cancels', async () => { + const conn = makeConn({ outcome: { outcome: 'cancelled' } }) + const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default') + const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3') + expect(result.behavior).toBe('deny') + }) + + test('returns deny when requestPermission throws', async () => { + const conn = { + requestPermission: mock(async () => { throw new Error('connection lost') }), + sessionUpdate: mock(async () => {}), + } as unknown as AgentSideConnection + const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default') + const result = await canUseTool(makeTool('Edit'), {}, dummyContext as any, dummyMsg as any, 'tu_4') + expect(result.behavior).toBe('deny') + }) + + test('passes correct sessionId and toolCallId to requestPermission', async () => { + const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } }) + const canUseTool = createAcpCanUseTool(conn, 'my-session', () => 'default') + await canUseTool(makeTool('Glob'), { pattern: '**/*.ts' }, dummyContext as any, dummyMsg as any, 'tu_99') + const rpMock = conn.requestPermission as ReturnType + expect(rpMock.mock.calls.length).toBeGreaterThan(0) + const callArgs = rpMock.mock.calls[0][0] as Record + expect(callArgs.sessionId).toBe('my-session') + expect((callArgs.toolCall as Record).toolCallId).toBe('tu_99') + }) + + test('returns allow in bypassPermissions mode without calling requestPermission', async () => { + const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } }) + const canUseTool = createAcpCanUseTool(conn, 'sess-bypass', () => 'bypassPermissions') + const result = await canUseTool(makeTool('Bash'), { command: 'rm -rf /' }, dummyContext as any, dummyMsg as any, 'tu_bp') + expect(result.behavior).toBe('allow') + const rpMock = conn.requestPermission as ReturnType + expect(rpMock.mock.calls).toHaveLength(0) + }) + + test('options include allow_always, allow_once and reject_once', async () => { + const conn = makeConn({ outcome: { outcome: 'cancelled' } }) + const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default') + await canUseTool(makeTool('Write'), {}, dummyContext as any, dummyMsg as any, 'tu_6') + const rpMock = conn.requestPermission as ReturnType + expect(rpMock.mock.calls.length).toBeGreaterThan(0) + const { options } = rpMock.mock.calls[0][0] as Record + const opts = options as Array> + expect(opts.find((o) => o.kind === 'allow_always')).toBeTruthy() + expect(opts.find((o) => o.kind === 'allow_once')).toBeTruthy() + expect(opts.find((o) => o.kind === 'reject_once')).toBeTruthy() + }) +}) diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts new file mode 100644 index 000000000..092adfa09 --- /dev/null +++ b/src/services/acp/agent.ts @@ -0,0 +1,801 @@ +/** + * ACP Agent implementation — bridges ACP protocol methods to Claude Code's + * internal QueryEngine / query() pipeline. + * + * Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk) + * to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate. + */ +import type { + Agent, + AgentSideConnection, + InitializeRequest, + InitializeResponse, + AuthenticateRequest, + AuthenticateResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + CancelNotification, + LoadSessionRequest, + LoadSessionResponse, + ListSessionsRequest, + ListSessionsResponse, + ResumeSessionRequest, + ResumeSessionResponse, + ForkSessionRequest, + ForkSessionResponse, + CloseSessionRequest, + CloseSessionResponse, + SetSessionModeRequest, + SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + ContentBlock, + ClientCapabilities, + SessionModeState, + SessionModelState, + SessionConfigOption, +} from '@agentclientprotocol/sdk' +import { randomUUID, type UUID } from 'node:crypto' +import type { Message } from '../../types/message.js' +import { deserializeMessages } from '../../utils/conversationRecovery.js' +import { getLastSessionLog, sessionIdExists } from '../../utils/sessionStorage.js' +import { QueryEngine } from '../../QueryEngine.js' +import type { QueryEngineConfig } from '../../QueryEngine.js' +import type { Tools } from '../../Tool.js' +import { getTools } from '../../tools.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { PermissionMode } from '../../types/permissions.js' +import type { Command } from '../../types/command.js' +import { getCommands } from '../../commands.js' +import { setOriginalCwd } from '../../bootstrap/state.js' +import { enableConfigs } from '../../utils/config.js' +import { FileStateCache } from '../../utils/fileStateCache.js' +import { getDefaultAppState } from '../../state/AppStateStore.js' +import type { AppState } from '../../state/AppStateStore.js' +import { createAcpCanUseTool } from './permissions.js' +import { forwardSessionUpdates, replayHistoryMessages, type ToolUseCache } from './bridge.js' +import { + resolvePermissionMode, + computeSessionFingerprint, + sanitizeTitle, +} from './utils.js' +import { + listSessionsImpl, +} from '../../utils/listSessionsImpl.js' +import { getMainLoopModel } from '../../utils/model/model.js' +import { getModelOptions } from '../../utils/model/modelOptions.js' + +// ── Session state ───────────────────────────────────────────────── + +type AcpSession = { + queryEngine: QueryEngine + cancelled: boolean + cwd: string + sessionFingerprint: string + modes: SessionModeState + models: SessionModelState + configOptions: SessionConfigOption[] + promptRunning: boolean + pendingMessages: Map void; order: number }> + nextPendingOrder: number + toolUseCache: ToolUseCache + clientCapabilities?: ClientCapabilities + appState: AppState + commands: Command[] +} + +// ── Agent class ─────────────────────────────────────────────────── + +export class AcpAgent implements Agent { + private conn: AgentSideConnection + sessions = new Map() + private clientCapabilities?: ClientCapabilities + + constructor(conn: AgentSideConnection) { + this.conn = conn + } + + // ── initialize ──────────────────────────────────────────────── + + async initialize(params: InitializeRequest): Promise { + this.clientCapabilities = params.clientCapabilities + + return { + protocolVersion: 1, + agentInfo: { + name: 'claude-code', + title: 'Claude Code', + version: + typeof (globalThis as unknown as Record).MACRO === + 'object' && + (globalThis as unknown as Record>) + .MACRO !== null + ? String( + ( + (globalThis as unknown as Record>) + .MACRO as Record + ).VERSION ?? '0.0.0', + ) + : '0.0.0', + }, + agentCapabilities: { + _meta: { + claudeCode: { + promptQueueing: true, + }, + }, + promptCapabilities: { + image: true, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + loadSession: true, + sessionCapabilities: { + fork: {}, + list: {}, + resume: {}, + close: {}, + }, + }, + } + } + + // ── authenticate ────────────────────────────────────────────── + + async authenticate(_params: AuthenticateRequest): Promise { + // No authentication required — this is a self-hosted/custom deployment + return {} + } + + // ── newSession ──────────────────────────────────────────────── + + async newSession(params: NewSessionRequest): Promise { + return this.createSession(params) + } + + // ── resumeSession ────────────────────────────────────────────── + + async unstable_resumeSession( + params: ResumeSessionRequest, + ): Promise { + const result = await this.getOrCreateSession(params) + setTimeout(() => { + this.sendAvailableCommandsUpdate(params.sessionId) + }, 0) + return result + } + + // ── loadSession ──────────────────────────────────────────────── + + async loadSession(params: LoadSessionRequest): Promise { + const result = await this.getOrCreateSession(params) + setTimeout(() => { + this.sendAvailableCommandsUpdate(params.sessionId) + }, 0) + return result + } + + // ── listSessions ─────────────────────────────────────────────── + + async listSessions(params: ListSessionsRequest): Promise { + const candidates = await listSessionsImpl({ + dir: params.cwd ?? undefined, + limit: 100, + }) + + const sessions = [] + for (const candidate of candidates) { + if (!candidate.cwd) continue + sessions.push({ + sessionId: candidate.sessionId, + cwd: candidate.cwd, + title: sanitizeTitle(candidate.summary ?? ''), + updatedAt: new Date(candidate.lastModified).toISOString(), + }) + } + + return { sessions } + } + + // ── forkSession ──────────────────────────────────────────────── + + async unstable_forkSession( + params: ForkSessionRequest, + ): Promise { + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + ) + setTimeout(() => { + this.sendAvailableCommandsUpdate(response.sessionId) + }, 0) + return response + } + + // ── closeSession ─────────────────────────────────────────────── + + async unstable_closeSession( + params: CloseSessionRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + await this.teardownSession(params.sessionId) + return {} + } + + // ── prompt ──────────────────────────────────────────────────── + + async prompt(params: PromptRequest): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error(`Session ${params.sessionId} not found`) + } + + // Reset cancelled state at the start of each prompt (matches official impl) + session.cancelled = false + + // Extract text/image content from the prompt + const promptInput = promptToQueryInput(params.prompt) + + if (!promptInput.trim()) { + return { stopReason: 'end_turn' } + } + + // Handle prompt queuing — if a prompt is already running, queue this one + if (session.promptRunning) { + const order = session.nextPendingOrder++ + const promptUuid = randomUUID() + const cancelled = await new Promise((resolve) => { + session.pendingMessages.set(promptUuid, { resolve, order }) + }) + if (cancelled) { + return { stopReason: 'cancelled' } + } + } + + session.promptRunning = true + + try { + // Reset the query engine's abort controller for a fresh query. + // After a previous interrupt(), the internal controller is stuck in + // aborted state — without this, submitMessage() fails immediately. + session.queryEngine.resetAbortController() + + const sdkMessages = session.queryEngine.submitMessage(promptInput) + + const { stopReason, usage } = await forwardSessionUpdates( + params.sessionId, + sdkMessages, + this.conn, + session.queryEngine.getAbortSignal(), + session.toolUseCache, + this.clientCapabilities, + session.cwd, + () => session.cancelled, + ) + + // If the session was cancelled during processing, return cancelled + if (session.cancelled) { + return { stopReason: 'cancelled' } + } + + return { + stopReason, + usage: usage + ? { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedReadTokens: usage.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens, + totalTokens: + usage.inputTokens + + usage.outputTokens + + usage.cachedReadTokens + + usage.cachedWriteTokens, + } + : undefined, + } + } catch (err: unknown) { + if (session.cancelled) { + return { stopReason: 'cancelled' } + } + + // Check for process death errors + if ( + err instanceof Error && + (err.message.includes('terminated') || + err.message.includes('process exited')) + ) { + this.teardownSession(params.sessionId) + throw new Error( + 'The Claude Agent process exited unexpectedly. Please start a new session.', + ) + } + + console.error('[ACP] prompt error:', err) + return { stopReason: 'end_turn' } + } finally { + session.promptRunning = false + // Resolve next pending prompt if any + if (session.pendingMessages.size > 0) { + const next = [...session.pendingMessages.entries()].sort( + (a, b) => a[1].order - b[1].order, + )[0] + if (next) { + next[1].resolve(false) + session.pendingMessages.delete(next[0]) + } + } + } + } + + // ── cancel ──────────────────────────────────────────────────── + + async cancel(params: CancelNotification): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) return + + // Set cancelled flag — checked by prompt() loop to break out + session.cancelled = true + + // Cancel any queued prompts + for (const [, pending] of session.pendingMessages) { + pending.resolve(true) + } + session.pendingMessages.clear() + + // Interrupt the query engine to abort the current API call + session.queryEngine.interrupt() + } + + // ── setSessionMode ────────────────────────────────────────────── + + async setSessionMode( + params: SetSessionModeRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + + this.applySessionMode(params.sessionId, params.modeId) + await this.updateConfigOption(params.sessionId, 'mode', params.modeId) + return {} + } + + // ── setSessionModel ───────────────────────────────────────────── + + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + // Store the raw value — QueryEngine.submitMessage() calls + // parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo") + session.queryEngine.setModel(params.modelId) + await this.updateConfigOption(params.sessionId, 'model', params.modelId) + } + + // ── setSessionConfigOption ────────────────────────────────────── + + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + if (typeof params.value !== 'string') { + throw new Error( + `Invalid value for config option ${params.configId}: ${String(params.value)}`, + ) + } + + const option = session.configOptions.find((o) => o.id === params.configId) + if (!option) { + throw new Error(`Unknown config option: ${params.configId}`) + } + + const value = params.value + + if (params.configId === 'mode') { + this.applySessionMode(params.sessionId, value) + await this.conn.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'current_mode_update', + currentModeId: value, + }, + }) + } else if (params.configId === 'model') { + session.queryEngine.setModel(value) + } + + this.syncSessionConfigState(session, params.configId, value) + + session.configOptions = session.configOptions.map((o) => + o.id === params.configId && typeof o.currentValue === 'string' + ? { ...o, currentValue: value } + : o, + ) + + return { configOptions: session.configOptions } + } + + // ── Private helpers ───────────────────────────────────────────── + + private async createSession( + params: NewSessionRequest, + opts: { forceNewId?: boolean; sessionId?: string; initialMessages?: Message[] } = {}, + ): Promise { + enableConfigs() + + const sessionId = opts.sessionId ?? randomUUID() + const cwd = params.cwd + + // Set CWD for the session + setOriginalCwd(cwd) + try { + process.chdir(cwd) + } catch { + // CWD may not exist yet; best-effort + } + + // Build tools with a permissive permission context. + const permissionContext = getEmptyToolPermissionContext() + const tools: Tools = getTools(permissionContext) + + // Parse permission mode from settings + const permissionMode = resolvePermissionMode( + this.getSetting('permissions.defaultMode'), + ) + + // Create the permission bridge canUseTool function + const canUseTool = createAcpCanUseTool( + this.conn, + sessionId, + () => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default', + this.clientCapabilities, + cwd, + ) + + // Parse MCP servers from ACP params + // MCP server config is handled separately in the tools system + + // Create a mutable AppState for the session + const appState: AppState = { + ...getDefaultAppState(), + toolPermissionContext: { + ...permissionContext, + mode: permissionMode as PermissionMode, + }, + } + + // Load commands for slash command and skill support + const commands = await getCommands(cwd) + + // Build QueryEngine config + const engineConfig: QueryEngineConfig = { + cwd, + tools, + commands, + mcpClients: [], + agents: [], + canUseTool, + getAppState: () => appState, + setAppState: (updater: (prev: AppState) => AppState) => { + const updated = updater(appState) + Object.assign(appState, updated) + }, + readFileCache: new FileStateCache(500, 50 * 1024 * 1024), + includePartialMessages: true, + replayUserMessages: true, + initialMessages: opts.initialMessages, + } + + const queryEngine = new QueryEngine(engineConfig) + + // Build modes + const availableModes = [ + { id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' }, + { id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' }, + { id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' }, + { id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' }, + { id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" }, + ] + + const modes: SessionModeState = { + currentModeId: permissionMode, + availableModes, + } + + // Build models + const modelOptions = getModelOptions() + const currentModel = getMainLoopModel() + const models: SessionModelState = { + availableModels: modelOptions.map((m) => ({ + modelId: String(m.value ?? ''), + name: m.label ?? String(m.value ?? ''), + description: m.description ?? undefined, + })), + currentModelId: currentModel, + } + + // Set the model on the engine + queryEngine.setModel(currentModel) + + // Build config options + const configOptions = buildConfigOptions(modes, models) + + const session: AcpSession = { + queryEngine, + cancelled: false, + cwd, + modes, + models, + configOptions, + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + toolUseCache: {}, + clientCapabilities: this.clientCapabilities, + appState, + commands, + sessionFingerprint: computeSessionFingerprint({ + cwd, + mcpServers: params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined, + }), + } + + this.sessions.set(sessionId, session) + + // Send available commands after session creation + setTimeout(() => { + this.sendAvailableCommandsUpdate(sessionId) + }, 0) + + return { + sessionId, + models, + modes, + configOptions, + } + } + + private async getOrCreateSession(params: { + sessionId: string + cwd: string + mcpServers?: NewSessionRequest['mcpServers'] + _meta?: NewSessionRequest['_meta'] + }): Promise { + const existingSession = this.sessions.get(params.sessionId) + if (existingSession) { + const fingerprint = computeSessionFingerprint({ + cwd: params.cwd, + mcpServers: + params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined, + }) + if (fingerprint === existingSession.sessionFingerprint) { + return { + sessionId: params.sessionId, + modes: existingSession.modes, + models: existingSession.models, + configOptions: existingSession.configOptions, + } + } + + // Session-defining params changed — tear down and recreate + await this.teardownSession(params.sessionId) + } + + // Set CWD early so session file lookup can find the right project directory + setOriginalCwd(params.cwd) + + // Try to load session history for resume/load + let initialMessages: Message[] | undefined + if (sessionIdExists(params.sessionId)) { + try { + const log = await getLastSessionLog(params.sessionId as UUID) + if (log && log.messages.length > 0) { + initialMessages = deserializeMessages(log.messages) + } + } catch (err) { + console.error('[ACP] Failed to load session history:', err) + } + } + + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + { sessionId: params.sessionId, initialMessages }, + ) + + // Replay history to client if loaded + if (initialMessages && initialMessages.length > 0) { + const session = this.sessions.get(params.sessionId) + if (session) { + await replayHistoryMessages( + params.sessionId, + initialMessages as unknown as Array>, + this.conn, + session.toolUseCache, + this.clientCapabilities, + session.cwd, + ) + } + } + + return { + sessionId: response.sessionId, + modes: response.modes, + models: response.models, + configOptions: response.configOptions, + } + } + + private async teardownSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + await this.cancel({ sessionId }) + this.sessions.delete(sessionId) + } + + private applySessionMode(sessionId: string, modeId: string): void { + const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan'] + if (!validModes.includes(modeId)) { + throw new Error(`Invalid mode: ${modeId}`) + } + const session = this.sessions.get(sessionId) + if (session) { + session.modes = { ...session.modes, currentModeId: modeId } + } + } + + private async updateConfigOption( + sessionId: string, + configId: string, + value: string, + ): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + this.syncSessionConfigState(session, configId, value) + + session.configOptions = session.configOptions.map((o) => + o.id === configId && typeof o.currentValue === 'string' + ? { ...o, currentValue: value } + : o, + ) + + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'config_option_update', + configOptions: session.configOptions, + }, + }) + } + + private syncSessionConfigState( + session: AcpSession, + configId: string, + value: string, + ): void { + if (configId === 'mode') { + session.modes = { ...session.modes, currentModeId: value } + } else if (configId === 'model') { + session.models = { ...session.models, currentModelId: value } + } + } + + private async sendAvailableCommandsUpdate(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + const availableCommands = session.commands + .filter( + cmd => + cmd.type === 'prompt' && + !cmd.isHidden && + cmd.userInvocable !== false, + ) + .map(cmd => ({ + name: cmd.name, + description: cmd.description, + input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined, + })) + + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands, + }, + }) + } + + /** Read a setting from Claude config (simplified — no file watching) */ + private getSetting(key: string): T | undefined { + // Simplified: read from environment or return undefined + // In a full implementation, this would read from settings.json + return undefined as T | undefined + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +/** Extract prompt text from ACP ContentBlock array for QueryEngine input */ +function promptToQueryInput( + prompt: Array | undefined, +): string { + if (!prompt || prompt.length === 0) return '' + + const parts: string[] = [] + for (const block of prompt) { + const b = block as Record + if (b.type === 'text') { + parts.push(b.text as string) + } else if (b.type === 'resource_link') { + parts.push(`[${b.name ?? ''}](${b.uri as string})`) + } else if (b.type === 'resource') { + const resource = b.resource as Record | undefined + if (resource && 'text' in resource) { + parts.push(resource.text as string) + } + } + // Ignore image and other types for text-based prompt + } + return parts.join('\n') +} + +function buildConfigOptions( + modes: SessionModeState, + models: SessionModelState, +): SessionConfigOption[] { + return [ + { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select' as const, + currentValue: modes.currentModeId, + options: modes.availableModes.map((m: SessionModeState['availableModes'][number]) => ({ + value: m.id, + name: m.name, + description: m.description, + })), + }, + { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select' as const, + currentValue: models.currentModelId, + options: models.availableModels.map((m: SessionModelState['availableModels'][number]) => ({ + value: m.modelId, + name: m.name, + description: m.description ?? undefined, + })), + }, + ] as SessionConfigOption[] +} diff --git a/src/services/acp/bridge.ts b/src/services/acp/bridge.ts new file mode 100644 index 000000000..edf9102d3 --- /dev/null +++ b/src/services/acp/bridge.ts @@ -0,0 +1,1254 @@ +/** + * Bridge module: converts Claude Code's SDKMessage stream events from + * QueryEngine.submitMessage() into ACP SessionUpdate notifications. + * + * Handles all SDKMessage types: + * - system (compact_boundary, api_retry, local_command_output) + * - user (message replay) + * - assistant (full messages with content blocks) + * - stream_event (real-time streaming: content_block_start/delta) + * - result (turn termination with usage/cost) + * - progress (subagent progress) + * - tool_use_summary + */ +import type { + AgentSideConnection, + ClientCapabilities, + ContentBlock, + PlanEntry, + SessionNotification, + SessionUpdate, + StopReason, + ToolCallContent, + ToolCallLocation, + ToolKind, +} from '@agentclientprotocol/sdk' +import type { SDKMessage } from '../../entrypoints/sdk/coreTypes.generated.js' +import { toDisplayPath, markdownEscape } from './utils.js' + +// ── ToolUseCache ────────────────────────────────────────────────── + +export type ToolUseCache = { + [key: string]: { + type: 'tool_use' | 'server_tool_use' | 'mcp_tool_use' + id: string + name: string + input: unknown + } +} + +// ── Session usage tracking ──────────────────────────────────────── + +export type SessionUsage = { + inputTokens: number + outputTokens: number + cachedReadTokens: number + cachedWriteTokens: number +} + +// ── Tool info conversion ────────────────────────────────────────── + +interface ToolInfo { + title: string + kind: ToolKind + content: ToolCallContent[] + locations?: ToolCallLocation[] +} + +export function toolInfoFromToolUse( + toolUse: { name: string; id: string; input: Record }, + _supportsTerminalOutput: boolean = false, + cwd?: string, +): ToolInfo { + const name = toolUse.name + const input = toolUse.input + + switch (name) { + case 'Agent': + case 'Task': { + const description = (input?.description as string | undefined) ?? 'Task' + const prompt = input?.prompt as string | undefined + return { + title: description, + kind: 'think', + content: prompt + ? [{ type: 'content' as const, content: { type: 'text' as const, text: prompt } }] + : [], + } + } + + case 'Bash': { + const command = (input?.command as string | undefined) ?? 'Terminal' + const description = input?.description as string | undefined + return { + title: command, + kind: 'execute', + content: _supportsTerminalOutput + ? [{ type: 'terminal' as const, terminalId: toolUse.id }] + : description + ? [{ type: 'content' as const, content: { type: 'text' as const, text: description } }] + : [], + } + } + + case 'Read': { + const filePath = (input?.file_path as string | undefined) ?? 'File' + const offset = input?.offset as number | undefined + const limit = input?.limit as number | undefined + let suffix = '' + if (limit && limit > 0) { + suffix = ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` + } else if (offset) { + suffix = ` (from line ${offset})` + } + const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File' + return { + title: `Read ${displayPath}${suffix}`, + kind: 'read', + locations: filePath ? [{ path: filePath, line: offset ?? 1 }] : [], + content: [], + } + } + + case 'Write': { + const filePath = (input?.file_path as string | undefined) ?? '' + const content = (input?.content as string | undefined) ?? '' + const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined + return { + title: displayPath ? `Write ${displayPath}` : 'Write', + kind: 'edit', + content: filePath + ? [{ type: 'diff' as const, path: filePath, oldText: null, newText: content }] + : [{ type: 'content' as const, content: { type: 'text' as const, text: content } }], + locations: filePath ? [{ path: filePath }] : [], + } + } + + case 'Edit': { + const filePath = (input?.file_path as string | undefined) ?? '' + const oldString = (input?.old_string as string | undefined) ?? '' + const newString = (input?.new_string as string | undefined) ?? '' + const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined + return { + title: displayPath ? `Edit ${displayPath}` : 'Edit', + kind: 'edit', + content: filePath + ? [{ type: 'diff' as const, path: filePath, oldText: oldString || null, newText: newString }] + : [], + locations: filePath ? [{ path: filePath }] : [], + } + } + + case 'Glob': { + const globPath = (input?.path as string | undefined) ?? '' + const pattern = (input?.pattern as string | undefined) ?? '' + let label = 'Find' + if (globPath) label += ` \`${globPath}\`` + if (pattern) label += ` \`${pattern}\`` + return { + title: label, + kind: 'search', + content: [], + locations: globPath ? [{ path: globPath }] : [], + } + } + + case 'Grep': { + const grepPattern = (input?.pattern as string | undefined) ?? '' + const grepPath = (input?.path as string | undefined) ?? '' + let label = 'grep' + if (input?.['-i']) label += ' -i' + if (input?.['-n']) label += ' -n' + if (input?.['-A'] !== undefined) label += ` -A ${input['-A'] as number}` + if (input?.['-B'] !== undefined) label += ` -B ${input['-B'] as number}` + if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}` + if (input?.output_mode === 'files_with_matches') label += ' -l' + else if (input?.output_mode === 'count') label += ' -c' + if (input?.head_limit !== undefined) label += ` | head -${input.head_limit as number}` + if (input?.glob) label += ` --include="${input.glob as string}"` + if (input?.type) label += ` --type=${input.type as string}` + if (input?.multiline) label += ' -P' + if (grepPattern) label += ` "${grepPattern}"` + if (grepPath) label += ` ${grepPath}` + return { + title: label, + kind: 'search', + content: [], + } + } + + case 'WebFetch': { + const url = (input?.url as string | undefined) ?? '' + const fetchPrompt = input?.prompt as string | undefined + return { + title: url ? `Fetch ${url}` : 'Fetch', + kind: 'fetch', + content: fetchPrompt + ? [{ type: 'content' as const, content: { type: 'text' as const, text: fetchPrompt } }] + : [], + } + } + + case 'WebSearch': { + const query = (input?.query as string | undefined) ?? 'Web search' + let label = `"${query}"` + const allowed = input?.allowed_domains as string[] | undefined + const blocked = input?.blocked_domains as string[] | undefined + if (allowed && allowed.length > 0) label += ` (allowed: ${allowed.join(', ')})` + if (blocked && blocked.length > 0) label += ` (blocked: ${blocked.join(', ')})` + return { + title: label, + kind: 'fetch', + content: [], + } + } + + case 'TodoWrite': { + const todos = input?.todos as Array<{ content: string }> | undefined + return { + title: Array.isArray(todos) + ? `Update TODOs: ${todos.map((t) => t.content).join(', ')}` + : 'Update TODOs', + kind: 'think', + content: [], + } + } + + case 'ExitPlanMode': { + const plan = (input as Record)?.plan as string | undefined + return { + title: 'Ready to code?', + kind: 'switch_mode', + content: plan + ? [{ type: 'content' as const, content: { type: 'text' as const, text: plan } }] + : [], + } + } + + default: + return { + title: name || 'Unknown Tool', + kind: 'other', + content: [], + } + } +} + +// ── Tool result conversion ──────────────────────────────────────── + +export function toolUpdateFromToolResult( + toolResult: Record, + toolUse: { name: string; id: string } | undefined, + _supportsTerminalOutput: boolean = false, +): { content?: ToolCallContent[]; title?: string; _meta?: Record } { + if (!toolUse) return {} + + const isError = toolResult.is_error === true + const resultContent = toolResult.content as + | string + | Array> + | undefined + + // For error results, return error content + if (isError && resultContent) { + return toAcpContentUpdate(resultContent, true) + } + + switch (toolUse.name) { + case 'Read': { + if (typeof resultContent === 'string' && resultContent.length > 0) { + return { + content: [ + { + type: 'content' as const, + content: { type: 'text' as const, text: markdownEscape(resultContent) }, + }, + ], + } + } + if (Array.isArray(resultContent) && resultContent.length > 0) { + return { + content: resultContent.map((c: Record) => ({ + type: 'content' as const, + content: + c.type === 'text' + ? { type: 'text' as const, text: markdownEscape(c.text as string) } + : toAcpContentBlock(c, false), + })), + } + } + return {} + } + + case 'Bash': { + let output = '' + let exitCode = isError ? 1 : 0 + const terminalId = String(toolUse.id) + + // Handle bash_code_execution_result format + if ( + resultContent && + typeof resultContent === 'object' && + !Array.isArray(resultContent) && + (resultContent as Record).type === 'bash_code_execution_result' + ) { + const bashResult = resultContent as Record + output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join('\n') + exitCode = (bashResult.return_code as number) ?? (isError ? 1 : 0) + } else if (typeof resultContent === 'string') { + output = resultContent + } else if (Array.isArray(resultContent) && resultContent.length > 0) { + output = resultContent + .map((c: Record) => + c.type === 'text' ? (c.text as string) : '', + ) + .join('\n') + } + + if (_supportsTerminalOutput) { + return { + content: [{ type: 'terminal' as const, terminalId }], + _meta: { + terminal_info: { terminal_id: terminalId }, + terminal_output: { terminal_id: terminalId, data: output }, + terminal_exit: { terminal_id: terminalId, exit_code: exitCode, signal: null }, + }, + } + } + + if (output.trim()) { + return { + content: [ + { + type: 'content' as const, + content: { + type: 'text' as const, + text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``, + }, + }, + ], + } + } + return {} + } + + case 'Edit': + case 'Write': { + return {} + } + + case 'ExitPlanMode': { + return { title: 'Exited Plan Mode' } + } + + default: { + return toAcpContentUpdate( + resultContent ?? '', + isError, + ) + } + } +} + +function toAcpContentUpdate( + content: unknown, + isError: boolean, +): { content?: ToolCallContent[] } { + if (Array.isArray(content) && content.length > 0) { + return { + content: content.map((c: Record) => ({ + type: 'content' as const, + content: toAcpContentBlock(c, isError), + })), + } + } + if (typeof content === 'string' && content.length > 0) { + return { + content: [ + { + type: 'content' as const, + content: { + type: 'text' as const, + text: isError ? `\`\`\`\n${content}\n\`\`\`` : content, + }, + }, + ], + } + } + return {} +} + +function toAcpContentBlock( + content: Record, + isError: boolean, +): ContentBlock { + const wrapText = (text: string): ContentBlock => ({ + type: 'text', + text: isError ? `\`\`\`\n${text}\n\`\`\`` : text, + }) + + const type = content.type as string + switch (type) { + case 'text': { + const text = content.text as string + return { type: 'text', text: isError ? `\`\`\`\n${text}\n\`\`\`` : text } + } + case 'image': { + const source = content.source as Record | undefined + if (source?.type === 'base64') { + return { + type: 'image', + data: source.data as string, + mimeType: source.media_type as string, + } + } + return wrapText( + source?.type === 'url' + ? `[image: ${source.url as string}]` + : '[image: file reference]', + ) + } + case 'tool_reference': + return wrapText(`Tool: ${content.tool_name as string}`) + case 'tool_search_tool_search_result': { + const refs = content.tool_references as Array<{ tool_name: string }> | undefined + return wrapText(`Tools found: ${refs?.map((r) => r.tool_name).join(', ') || 'none'}`) + } + case 'tool_search_tool_result_error': + return wrapText( + `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, + ) + case 'web_search_result': + return wrapText(`${content.title as string} (${content.url as string})`) + case 'web_search_tool_result_error': + return wrapText(`Error: ${content.error_code as string}`) + case 'web_fetch_result': + return wrapText(`Fetched: ${content.url as string}`) + case 'web_fetch_tool_result_error': + return wrapText(`Error: ${content.error_code as string}`) + case 'code_execution_result': + case 'bash_code_execution_result': + return wrapText(`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`) + case 'code_execution_tool_result_error': + case 'bash_code_execution_tool_result_error': + return wrapText(`Error: ${content.error_code as string}`) + case 'text_editor_code_execution_view_result': + return wrapText(content.content as string) + case 'text_editor_code_execution_create_result': + return wrapText(content.is_file_update ? 'File updated' : 'File created') + case 'text_editor_code_execution_str_replace_result': { + const lines = content.lines as string[] | undefined + return wrapText(lines?.join('\n') || '') + } + case 'text_editor_code_execution_tool_result_error': + return wrapText( + `Error: ${content.error_code as string}${content.error_message ? ` - ${content.error_message as string}` : ''}`, + ) + default: + try { + return { type: 'text', text: JSON.stringify(content) } + } catch { + return { type: 'text', text: '[content]' } + } + } +} + +// ── Edit tool response → diff ────────────────────────────────────── + +interface EditToolResponseHunk { + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: string[] +} + +interface EditToolResponse { + filePath?: string + structuredPatch?: EditToolResponseHunk[] +} + +/** + * Builds diff ToolUpdate content from the structured Edit toolResponse. + * Parses structuredPatch hunks (lines prefixed with -, +, space) into + * oldText/newText diff pairs. + */ +export function toolUpdateFromEditToolResponse(toolResponse: unknown): { + content?: ToolCallContent[] + locations?: ToolCallLocation[] +} { + if (!toolResponse || typeof toolResponse !== 'object') return {} + const response = toolResponse as EditToolResponse + if (!response.filePath || !Array.isArray(response.structuredPatch)) return {} + + const content: ToolCallContent[] = [] + const locations: ToolCallLocation[] = [] + + for (const { lines, newStart } of response.structuredPatch) { + const oldText: string[] = [] + const newText: string[] = [] + for (const line of lines) { + if (line.startsWith('-')) { + oldText.push(line.slice(1)) + } else if (line.startsWith('+')) { + newText.push(line.slice(1)) + } else { + oldText.push(line.slice(1)) + newText.push(line.slice(1)) + } + } + if (oldText.length > 0 || newText.length > 0) { + locations.push({ path: response.filePath, line: newStart }) + content.push({ + type: 'diff', + path: response.filePath, + oldText: oldText.join('\n') || null, + newText: newText.join('\n'), + }) + } + } + + const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {} + if (content.length > 0) result.content = content + if (locations.length > 0) result.locations = locations + return result +} + +// ── Prompt conversion ───────────────────────────────────────────── + +/** + * Convert ACP PromptRequest content blocks into content for QueryEngine. + */ +export function promptToQueryContent( + prompt: Array | undefined, +): string { + if (!prompt) return '' + return prompt + .map((block) => { + const b = block as Record + if (b.type === 'text') return b.text as string + if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})` + if (b.type === 'resource') { + const resource = b.resource as Record | undefined + if (resource && 'text' in resource) return resource.text as string + } + return '' + }) + .filter(Boolean) + .join('\n') +} + +// ── Main forwarding function ────────────────────────────────────── + +/** + * Iterates SDKMessages from QueryEngine.submitMessage(), converts each + * to ACP SessionUpdate notifications, and sends them via conn.sessionUpdate(). + * Returns the final StopReason and accumulated usage for the prompt turn. + */ +export async function forwardSessionUpdates( + sessionId: string, + sdkMessages: AsyncGenerator, + conn: AgentSideConnection, + abortSignal: AbortSignal, + toolUseCache: ToolUseCache, + clientCapabilities?: ClientCapabilities, + cwd?: string, + isCancelled?: () => boolean, +): Promise<{ stopReason: StopReason; usage?: SessionUsage }> { + let stopReason: StopReason = 'end_turn' + const accumulatedUsage: SessionUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + } + + // Track last assistant usage/model for context window size computation + let lastAssistantTotalUsage: number | null = null + let lastAssistantModel: string | null = null + let lastContextWindowSize = 200000 + + try { + while (!abortSignal.aborted) { + // Race the next message against the abort signal so we unblock + // immediately when cancelled, even if the generator is waiting for + // a slow API response. + const nextResult = await Promise.race([ + sdkMessages.next(), + new Promise>((resolve) => { + if (abortSignal.aborted) { + resolve({ done: true, value: undefined }) + return + } + const handler = () => resolve({ done: true, value: undefined }) + abortSignal.addEventListener('abort', handler, { once: true }) + }), + ]) + if (nextResult.done || abortSignal.aborted) break + const msg = nextResult.value + + const type = msg.type as string + + switch (type) { + // ── System messages ──────────────────────────────────────── + case 'system': { + const subtype = msg.subtype as string | undefined + + if (subtype === 'compact_boundary') { + // Reset assistant usage tracking after compaction + lastAssistantTotalUsage = 0 + // Send usage reset after compaction + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'usage_update', + used: 0, + size: lastContextWindowSize, + }, + }) + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '\n\nCompacting completed.' }, + }, + }) + } + // api_retry, local_command_output — skip for now + break + } + + // ── Result messages ──────────────────────────────────────── + case 'result': { + const usage = msg.usage as + | { + input_tokens: number + output_tokens: number + cache_read_input_tokens: number + cache_creation_input_tokens: number + } + | undefined + + if (usage) { + accumulatedUsage.inputTokens += usage.input_tokens + accumulatedUsage.outputTokens += usage.output_tokens + accumulatedUsage.cachedReadTokens += usage.cache_read_input_tokens + accumulatedUsage.cachedWriteTokens += usage.cache_creation_input_tokens + } + + // Resolve context window size from modelUsage via prefix matching + const modelUsage = msg.modelUsage as + | Record + | undefined + if (modelUsage && lastAssistantModel) { + const match = getMatchingModelUsage(modelUsage, lastAssistantModel) + if (match?.contextWindow) { + lastContextWindowSize = match.contextWindow + } + } + + // Send usage_update — use lastAssistantTotalUsage if available + // (more accurate than accumulatedUsage which may include background tasks) + const usedTokens = lastAssistantTotalUsage ?? ( + accumulatedUsage.inputTokens + + accumulatedUsage.outputTokens + + accumulatedUsage.cachedReadTokens + + accumulatedUsage.cachedWriteTokens + ) + + const totalCostUsd = msg.total_cost_usd as number | undefined + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'usage_update', + used: usedTokens, + size: lastContextWindowSize, + cost: totalCostUsd != null + ? { amount: totalCostUsd, currency: 'USD' } + : undefined, + }, + }) + + // Determine stop reason + const subtype = msg.subtype as string | undefined + const isError = msg.is_error as boolean | undefined + + if (abortSignal.aborted) { + stopReason = 'cancelled' + break + } + + switch (subtype) { + case 'success': { + const stopReasonStr = msg.stop_reason as string | null + if (stopReasonStr === 'max_tokens') { + stopReason = 'max_tokens' + } + if (isError) { + // Report error as end_turn + stopReason = 'end_turn' + } + break + } + case 'error_during_execution': { + if ((msg.stop_reason as string | null) === 'max_tokens') { + stopReason = 'max_tokens' + } else if (isError) { + stopReason = 'end_turn' + } else { + stopReason = 'end_turn' + } + break + } + case 'error_max_budget_usd': + case 'error_max_turns': + case 'error_max_structured_output_retries': + if (isError) { + stopReason = 'max_turn_requests' + } else { + stopReason = 'max_turn_requests' + } + break + } + break + } + + // ── Stream events ────────────────────────────────────────── + case 'stream_event': { + const notifications = streamEventToAcpNotifications( + msg, + sessionId, + toolUseCache, + conn, + { + clientCapabilities, + cwd, + }, + ) + for (const notification of notifications) { + await conn.sessionUpdate(notification) + } + break + } + + // ── Assistant messages ───────────────────────────────────── + case 'assistant': { + // Track last assistant total usage for context window computation + // (only for top-level messages, not subagents) + const assistantMsg = msg.message as Record | undefined + const parentToolUseId = msg.parent_tool_use_id as string | null | undefined + if (assistantMsg?.usage && parentToolUseId === null) { + const msgUsage = assistantMsg.usage as Record + lastAssistantTotalUsage = + ((msgUsage.input_tokens as number) ?? 0) + + ((msgUsage.output_tokens as number) ?? 0) + + ((msgUsage.cache_read_input_tokens as number) ?? 0) + + ((msgUsage.cache_creation_input_tokens as number) ?? 0) + } + // Track the current top-level model for context window size lookup + if ( + parentToolUseId === null && + assistantMsg?.model && + assistantMsg.model !== '' + ) { + lastAssistantModel = assistantMsg.model as string + } + + const notifications = assistantMessageToAcpNotifications( + msg, + sessionId, + toolUseCache, + conn, + { + clientCapabilities, + cwd, + }, + ) + for (const notification of notifications) { + await conn.sessionUpdate(notification) + } + break + } + + // ── User messages ────────────────────────────────────────── + case 'user': { + // In ACP mode, user messages from replay/synthetic are typically skipped + // The client already knows what the user sent + break + } + + // ── Progress messages ────────────────────────────────────── + case 'progress': { + const progressData = msg.data as Record | undefined + if (!progressData) break + + // Handle agent/skill subagent progress + const progressType = progressData.type as string | undefined + if (progressType === 'agent_progress' || progressType === 'skill_progress') { + const progressMessage = progressData.message as + | Record + | undefined + if (progressMessage) { + const content = progressMessage.content as + | Array> + | undefined + if (content) { + for (const block of content) { + if (block.type === 'text') { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: block.text as string }, + }, + }) + } + } + } + } + } + break + } + + // ── Tool use summary ─────────────────────────────────────── + case 'tool_use_summary': { + // Skip for now — not critical for basic functionality + break + } + + // ── Attachment messages ──────────────────────────────────── + case 'attachment': { + // Skip — handled by QueryEngine internally + break + } + + // ── Compact boundary ─────────────────────────────────────── + case 'compact_boundary': { + lastAssistantTotalUsage = 0 + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'usage_update', + used: 0, + size: lastContextWindowSize, + }, + }) + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '\n\nCompacting completed.' }, + }, + }) + break + } + + default: + // Ignore unknown message types + break + } + } + + // If we exited the loop because abort fired or cancel was requested, return cancelled + if (abortSignal.aborted || isCancelled?.()) { + return { stopReason: 'cancelled', usage: accumulatedUsage } + } + } catch (err: unknown) { + if (abortSignal.aborted) { + return { stopReason: 'cancelled', usage: accumulatedUsage } + } + throw err + } + + return { stopReason, usage: accumulatedUsage } +} + +// ── Assistant message conversion ────────────────────────────────── + +function assistantMessageToAcpNotifications( + msg: SDKMessage, + sessionId: string, + toolUseCache: ToolUseCache, + conn: AgentSideConnection, + options?: { + clientCapabilities?: ClientCapabilities + parentToolUseId?: string | null + cwd?: string + }, +): SessionNotification[] { + const message = msg.message as Record | undefined + if (!message) return [] + + const content = message.content as + | string + | Array> + | undefined + if (!content) return [] + + // If content is a string, treat as text + if (typeof content === 'string') { + return [ + { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: content }, + }, + }, + ] + } + + return toAcpNotifications(content, 'assistant', sessionId, toolUseCache, conn, undefined, options) +} + +// ── Stream event conversion ─────────────────────────────────────── + +function streamEventToAcpNotifications( + msg: SDKMessage, + sessionId: string, + toolUseCache: ToolUseCache, + conn: AgentSideConnection, + options?: { + clientCapabilities?: ClientCapabilities + cwd?: string + }, +): SessionNotification[] { + const event = (msg as unknown as { event: Record }).event + if (!event) return [] + + switch (event.type as string) { + case 'content_block_start': { + const contentBlock = event.content_block as Record | undefined + if (!contentBlock) return [] + return toAcpNotifications( + [contentBlock], + 'assistant', + sessionId, + toolUseCache, + conn, + undefined, + { + clientCapabilities: options?.clientCapabilities, + parentToolUseId: msg.parent_tool_use_id as string | null | undefined, + cwd: options?.cwd, + }, + ) + } + case 'content_block_delta': { + const delta = event.delta as Record | undefined + if (!delta) return [] + return toAcpNotifications( + [delta], + 'assistant', + sessionId, + toolUseCache, + conn, + undefined, + { + clientCapabilities: options?.clientCapabilities, + parentToolUseId: msg.parent_tool_use_id as string | null | undefined, + cwd: options?.cwd, + }, + ) + } + // No content to emit + case 'message_start': + case 'message_delta': + case 'message_stop': + case 'content_block_stop': + return [] + + default: + return [] + } +} + +// ── Core content block → ACP notification conversion ────────────── + +function toAcpNotifications( + content: Array>, + role: 'assistant' | 'user', + sessionId: string, + toolUseCache: ToolUseCache, + _conn: AgentSideConnection, + _logger?: { error: (...args: unknown[]) => void }, + options?: { + registerHooks?: boolean + clientCapabilities?: ClientCapabilities + parentToolUseId?: string | null + cwd?: string + }, +): SessionNotification[] { + const output: SessionNotification[] = [] + + for (const chunk of content) { + const chunkType = chunk.type as string + let update: SessionUpdate | null = null + + switch (chunkType) { + case 'text': + case 'text_delta': { + const text = (chunk.text as string) ?? '' + update = { + sessionUpdate: + role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', + content: { type: 'text', text }, + } + break + } + + case 'thinking': + case 'thinking_delta': { + const thinking = (chunk.thinking as string) ?? '' + update = { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: thinking }, + } + break + } + + case 'image': { + const source = chunk.source as Record | undefined + if (source?.type === 'base64') { + update = { + sessionUpdate: + role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', + content: { + type: 'image', + data: source.data as string, + mimeType: source.media_type as string, + }, + } + } + break + } + + case 'tool_use': + case 'server_tool_use': + case 'mcp_tool_use': { + const toolUseId = (chunk.id as string) ?? '' + const toolName = (chunk.name as string) ?? 'unknown' + const toolInput = chunk.input as Record | undefined + const alreadyCached = toolUseId in toolUseCache + + // Cache this tool_use for later matching + toolUseCache[toolUseId] = { + type: chunkType as 'tool_use' | 'server_tool_use' | 'mcp_tool_use', + id: toolUseId, + name: toolName, + input: toolInput, + } + + // TodoWrite → plan update + if (toolName === 'TodoWrite') { + const todos = (toolInput as Record)?.todos as + | Array<{ content: string; status: string }> + | undefined + if (Array.isArray(todos)) { + const entries: PlanEntry[] = todos.map((todo) => ({ + content: todo.content, + status: normalizePlanStatus(todo.status), + priority: 'medium', + })) + update = { + sessionUpdate: 'plan', + entries, + } + } + } else { + // Regular tool call + let rawInput: Record | undefined + try { + rawInput = JSON.parse(JSON.stringify(toolInput ?? {})) + } catch { + // Ignore parse failures + } + + if (alreadyCached) { + // Second encounter — send as tool_call_update + update = { + _meta: { + claudeCode: { toolName }, + }, + toolCallId: toolUseId, + sessionUpdate: 'tool_call_update', + rawInput, + ...toolInfoFromToolUse( + { name: toolName, id: toolUseId, input: toolInput ?? {} }, + false, + options?.cwd, + ), + } + } else { + // First encounter — send as tool_call + update = { + _meta: { + claudeCode: { toolName }, + }, + toolCallId: toolUseId, + sessionUpdate: 'tool_call', + rawInput, + status: 'pending', + ...toolInfoFromToolUse( + { name: toolName, id: toolUseId, input: toolInput ?? {} }, + false, + options?.cwd, + ), + } + } + } + break + } + + case 'tool_result': + case 'mcp_tool_result': { + const toolUseId = + (chunk.tool_use_id as string | undefined) ?? '' + const toolUse = toolUseCache[toolUseId] + if (!toolUse) break + + if (toolUse.name !== 'TodoWrite') { + const toolUpdate = toolUpdateFromToolResult( + chunk as unknown as Record, + { name: toolUse.name, id: toolUse.id }, + false, + ) + + update = { + _meta: { + claudeCode: { toolName: toolUse.name }, + }, + toolCallId: toolUseId, + sessionUpdate: 'tool_call_update', + status: + (chunk.is_error as boolean | undefined) === true ? 'failed' : 'completed', + rawOutput: chunk.content, + ...toolUpdate, + } + } + break + } + + case 'redacted_thinking': + case 'input_json_delta': + case 'citations_delta': + case 'signature_delta': + case 'container_upload': + case 'compaction': + case 'compaction_delta': + // Skip these types + break + } + + if (update) { + // Add parentToolUseId to _meta if present + if (options?.parentToolUseId) { + const existingMeta = (update as Record)._meta as + | Record + | undefined + ;(update as Record)._meta = { + ...existingMeta, + claudeCode: { + ...((existingMeta?.claudeCode as Record) ?? {}), + parentToolUseId: options.parentToolUseId, + }, + } + } + output.push({ sessionId, update }) + } + } + + return output +} + +function normalizePlanStatus( + status: string, +): 'pending' | 'in_progress' | 'completed' { + if (status === 'in_progress') return 'in_progress' + if (status === 'completed') return 'completed' + return 'pending' +} + +// ── History replay ────────────────────────────────────────────────── + +/** + * Replays conversation history messages to the ACP client as session updates. + * Used when resuming/loading a session to show the client the previous conversation. + */ +export async function replayHistoryMessages( + sessionId: string, + messages: Array>, + conn: AgentSideConnection, + toolUseCache: ToolUseCache, + clientCapabilities?: ClientCapabilities, + cwd?: string, +): Promise { + for (const msg of messages) { + const type = msg.type as string + // Skip non-conversation messages + if (type !== 'user' && type !== 'assistant') continue + // Skip meta messages (synthetic continuation prompts) + if (msg.isMeta === true) continue + + const messageData = msg.message as Record | undefined + const content = messageData?.content + if (!content) continue + + const role: 'assistant' | 'user' = type === 'assistant' ? 'assistant' : 'user' + + if (typeof content === 'string') { + if (!content.trim()) continue + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: + role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk', + content: { type: 'text', text: content }, + }, + }) + continue + } + + if (Array.isArray(content)) { + const notifications = toAcpNotifications( + content as Array>, + role, + sessionId, + toolUseCache, + conn, + undefined, + { clientCapabilities, cwd }, + ) + for (const notification of notifications) { + await conn.sessionUpdate(notification) + } + } + } +} + +// ── Model usage matching ────────────────────────────────────────── + +function commonPrefixLength(a: string, b: string): number { + let i = 0 + const maxLen = Math.min(a.length, b.length) + while (i < maxLen && a[i] === b[i]) i++ + return i +} + +function getMatchingModelUsage( + modelUsage: Record, + currentModel: string, +): { contextWindow?: number } | null { + let bestKey: string | null = null + let bestLen = 0 + + for (const key of Object.keys(modelUsage)) { + const len = commonPrefixLength(key, currentModel) + if (len > bestLen) { + bestLen = len + bestKey = key + } + } + + return bestKey ? modelUsage[bestKey] ?? null : null +} diff --git a/src/services/acp/entry.ts b/src/services/acp/entry.ts new file mode 100644 index 000000000..85e5fb72a --- /dev/null +++ b/src/services/acp/entry.ts @@ -0,0 +1,77 @@ +import { + AgentSideConnection, + ndJsonStream, +} from '@agentclientprotocol/sdk' +import type { Stream } from '@agentclientprotocol/sdk' +import { Readable, Writable } from 'node:stream' +import { AcpAgent } from './agent.js' +import { enableConfigs } from '../../utils/config.js' +import { applySafeConfigEnvironmentVariables } from '../../utils/managedEnv.js' + +/** + * Creates an ACP Stream from a pair of Node.js streams. + */ +export function createAcpStream( + nodeReadable: NodeJS.ReadableStream, + nodeWritable: NodeJS.WritableStream, +): Stream { + const readableFromClient = Readable.toWeb( + nodeReadable as typeof process.stdin, + ) as unknown as ReadableStream + const writableToClient = Writable.toWeb( + nodeWritable as typeof process.stdout, + ) as unknown as WritableStream + return ndJsonStream(writableToClient, readableFromClient) +} + +/** + * Entry point for the ACP (Agent Client Protocol) agent mode. + */ +export async function runAcpAgent(): Promise { + enableConfigs() + + // Apply environment variables from settings.json (ANTHROPIC_BASE_URL, + // ANTHROPIC_AUTH_TOKEN, model overrides, etc.) so the API client can + // authenticate. Without this, Zed-launched processes won't have these + // env vars in process.env. + applySafeConfigEnvironmentVariables() + + const stream = createAcpStream(process.stdin, process.stdout) + + let agent!: AcpAgent + const connection = new AgentSideConnection((conn) => { + agent = new AcpAgent(conn) + return agent + }, stream) + + // stdout is used for ACP messages — redirect console to stderr + console.log = console.error + console.info = console.error + console.warn = console.error + console.debug = console.error + + async function shutdown(): Promise { + // Clean up all active sessions + for (const [sessionId] of agent.sessions) { + try { + await agent.unstable_closeSession({ sessionId }) + } catch { + // Best-effort cleanup + } + } + process.exit(0) + } + + // Exit cleanly when the ACP connection closes + connection.closed.then(shutdown).catch(shutdown) + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason) + }) + + // Keep process alive while connection is open + process.stdin.resume() +} diff --git a/src/services/acp/permissions.ts b/src/services/acp/permissions.ts new file mode 100644 index 000000000..782346f21 --- /dev/null +++ b/src/services/acp/permissions.ts @@ -0,0 +1,224 @@ +/** + * Permission bridge: maps Claude Code's canUseTool / PermissionDecision + * system to ACP's requestPermission() flow. + * + * Supports: + * - bypassPermissions mode (auto-allow all tools) + * - ExitPlanMode special handling (multi-option: Yes+auto/acceptEdits/default/No) + * - Always Allow + * - Standard allow_once/allow_always/reject_once + */ +import type { + AgentSideConnection, + PermissionOption, + ToolCallUpdate, + ClientCapabilities, +} from '@agentclientprotocol/sdk' +import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' +import type { + PermissionAllowDecision, + PermissionAskDecision, + PermissionDenyDecision, +} from '../../types/permissions.js' +import type { Tool as ToolType, ToolUseContext } from '../../Tool.js' +import type { AssistantMessage } from '../../types/message.js' +import { toolInfoFromToolUse } from './bridge.js' + +const IS_ROOT = + typeof process.geteuid === 'function' + ? process.geteuid() === 0 + : typeof process.getuid === 'function' + ? process.getuid() === 0 + : false +const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX + +/** + * Creates a CanUseToolFn that delegates permission decisions to the + * ACP client via requestPermission(). + */ +export function createAcpCanUseTool( + conn: AgentSideConnection, + sessionId: string, + getCurrentMode: () => string, + clientCapabilities?: ClientCapabilities, + cwd?: string, +): CanUseToolFn { + return async ( + tool: ToolType, + input: Record, + _context: ToolUseContext, + _assistantMessage: AssistantMessage, + toolUseID: string, + _forceDecision?: PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision, + ): Promise => { + const supportsTerminalOutput = checkTerminalOutput(clientCapabilities) + + // ── ExitPlanMode special handling ──────────────────────────── + if (tool.name === 'ExitPlanMode') { + return handleExitPlanMode(conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd) + } + + // ── bypassPermissions mode ─────────────────────────────────── + if (getCurrentMode() === 'bypassPermissions') { + return { + behavior: 'allow', + updatedInput: input, + } + } + + // ── Standard tool permission ───────────────────────────────── + const info = toolInfoFromToolUse( + { name: tool.name, id: toolUseID, input }, + supportsTerminalOutput, + cwd, + ) + + const toolCall: ToolCallUpdate = { + toolCallId: toolUseID, + title: info.title, + kind: info.kind, + status: 'pending', + rawInput: input, + } + + const options: Array = [ + { kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' }, + { kind: 'allow_once', name: 'Allow', optionId: 'allow' }, + { kind: 'reject_once', name: 'Reject', optionId: 'reject' }, + ] + + try { + const response = await conn.requestPermission({ + sessionId, + toolCall, + options, + }) + + if (response.outcome.outcome === 'cancelled') { + return { + behavior: 'deny', + message: 'Permission request cancelled by client', + decisionReason: { type: 'mode', mode: 'default' }, + } + } + + if ( + response.outcome.outcome === 'selected' && + 'optionId' in response.outcome && + response.outcome.optionId !== undefined + ) { + const optionId = response.outcome.optionId + if (optionId === 'allow' || optionId === 'allow_always') { + return { + behavior: 'allow', + updatedInput: input, + } + } + } + + // Default: deny + return { + behavior: 'deny', + message: 'Permission denied by client', + decisionReason: { type: 'mode', mode: 'default' }, + } + } catch { + return { + behavior: 'deny', + message: 'Permission request failed', + decisionReason: { type: 'mode', mode: 'default' }, + } + } + } +} + +async function handleExitPlanMode( + conn: AgentSideConnection, + sessionId: string, + toolUseID: string, + input: Record, + supportsTerminalOutput: boolean, + cwd?: string, +): Promise { + const options: Array = [ + { kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' }, + { kind: 'allow_always', name: 'Yes, and auto-accept edits', optionId: 'acceptEdits' }, + { kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' }, + { kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' }, + ] + if (ALLOW_BYPASS) { + options.unshift({ + kind: 'allow_always', + name: 'Yes, and bypass permissions', + optionId: 'bypassPermissions', + }) + } + + const info = toolInfoFromToolUse( + { name: 'ExitPlanMode', id: toolUseID, input }, + supportsTerminalOutput, + cwd, + ) + + const toolCall: ToolCallUpdate = { + toolCallId: toolUseID, + title: info.title, + kind: info.kind, + status: 'pending', + rawInput: input, + } + + const response = await conn.requestPermission({ + sessionId, + toolCall, + options, + }) + + if (response.outcome.outcome === 'cancelled') { + return { + behavior: 'deny', + message: 'Tool use aborted', + decisionReason: { type: 'mode', mode: 'default' }, + } + } + + if ( + response.outcome.outcome === 'selected' && + 'optionId' in response.outcome && + response.outcome.optionId !== undefined + ) { + const selectedOption = response.outcome.optionId + if ( + selectedOption === 'default' || + selectedOption === 'acceptEdits' || + selectedOption === 'auto' || + selectedOption === 'bypassPermissions' + ) { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'current_mode_update', + currentModeId: selectedOption, + }, + }) + + return { + behavior: 'allow', + updatedInput: input, + } + } + } + + return { + behavior: 'deny', + message: 'User rejected request to exit plan mode.', + decisionReason: { type: 'mode', mode: 'plan' }, + } +} + +function checkTerminalOutput(clientCapabilities?: ClientCapabilities): boolean { + if (!clientCapabilities) return false + const meta = (clientCapabilities as unknown as Record)._meta + if (!meta || typeof meta !== 'object') return false + return (meta as Record)['terminal_output'] === true +} diff --git a/src/services/acp/utils.ts b/src/services/acp/utils.ts new file mode 100644 index 000000000..c7bbb1e24 --- /dev/null +++ b/src/services/acp/utils.ts @@ -0,0 +1,208 @@ +/** + * Shared utilities for the ACP service. + * Ported from claude-agent-acp-main/src/utils.ts and acp-agent.ts helpers. + */ +import { Readable, Writable } from 'node:stream' +import type { PermissionMode } from '../../entrypoints/sdk/coreTypes.generated.js' + +// ── Pushable ────────────────────────────────────────────────────── + +/** + * A pushable async iterable: allows you to push items and consume them + * with for-await. Useful for bridging push-based and async-iterator-based code. + */ +export class Pushable implements AsyncIterable { + private queue: T[] = [] + private resolvers: ((value: IteratorResult) => void)[] = [] + private done = false + + push(item: T) { + if (this.resolvers.length > 0) { + const resolve = this.resolvers.shift()! + resolve({ value: item, done: false }) + } else { + this.queue.push(item) + } + } + + end() { + this.done = true + while (this.resolvers.length > 0) { + const resolve = this.resolvers.shift()! + resolve({ value: undefined as unknown as T, done: true }) + } + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + if (this.queue.length > 0) { + const value = this.queue.shift()! + return Promise.resolve({ value, done: false }) + } + if (this.done) { + return Promise.resolve({ value: undefined as unknown as T, done: true }) + } + return new Promise>((resolve) => { + this.resolvers.push(resolve) + }) + }, + } + } +} + +// ── Stream helpers ──────────────────────────────────────────────── + +export function nodeToWebWritable(nodeStream: Writable): WritableStream { + return new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + nodeStream.write(Buffer.from(chunk), (err) => { + if (err) reject(err) + else resolve() + }) + }) + }, + }) +} + +export function nodeToWebReadable(nodeStream: Readable): ReadableStream { + return new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + nodeStream.on('end', () => controller.close()) + nodeStream.on('error', (err) => controller.error(err)) + }, + }) +} + +// ── unreachable ─────────────────────────────────────────────────── + +export function unreachable( + value: never, + logger: { error: (...args: unknown[]) => void } = console, +): void { + let valueAsString: unknown + try { + valueAsString = JSON.stringify(value) + } catch { + valueAsString = value + } + logger.error(`Unexpected case: ${valueAsString}`) +} + +// ── Permission mode resolution ──────────────────────────────────── + +// Bypass Permissions doesn't work if we are a root/sudo user +const IS_ROOT = + typeof process.geteuid === 'function' + ? process.geteuid() === 0 + : typeof process.getuid === 'function' + ? process.getuid() === 0 + : false +const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX + +const PERMISSION_MODE_ALIASES: Record = { + auto: 'auto', + default: 'default', + acceptedits: 'acceptEdits', + dontask: 'dontAsk', + plan: 'plan', + bypasspermissions: 'bypassPermissions', + bypass: 'bypassPermissions', +} + +export function resolvePermissionMode(defaultMode?: unknown): PermissionMode { + if (defaultMode === undefined) { + return 'default' + } + + if (typeof defaultMode !== 'string') { + throw new Error('Invalid permissions.defaultMode: expected a string.') + } + + const normalized = defaultMode.trim().toLowerCase() + if (normalized === '') { + throw new Error('Invalid permissions.defaultMode: expected a non-empty string.') + } + + const mapped = PERMISSION_MODE_ALIASES[normalized] + if (!mapped) { + throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`) + } + + if (mapped === 'bypassPermissions' && !ALLOW_BYPASS) { + throw new Error( + 'Invalid permissions.defaultMode: bypassPermissions is not available when running as root.', + ) + } + + return mapped +} + +// ── Session fingerprint ─────────────────────────────────────────── + +/** + * Compute a stable fingerprint of the session-defining params so we can + * detect when a loadSession/resumeSession call requires tearing down and + * recreating the underlying QueryEngine. + */ +export function computeSessionFingerprint(params: { + cwd: string + mcpServers?: Array<{ name: string; [key: string]: unknown }> +}): string { + const servers = [...(params.mcpServers ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ) + return JSON.stringify({ cwd: params.cwd, mcpServers: servers }) +} + +// ── Title sanitization ──────────────────────────────────────────── + +const MAX_TITLE_LENGTH = 256 + +export function sanitizeTitle(text: string): string { + const sanitized = text + .replace(/[\r\n]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (sanitized.length <= MAX_TITLE_LENGTH) { + return sanitized + } + return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + '…' +} + +// ── Path display helpers ────────────────────────────────────────── + +import * as path from 'node:path' + +/** + * Convert an absolute file path to a project-relative path for display. + * Returns the original path if it's outside the project directory or if no cwd is provided. + */ +export function toDisplayPath(filePath: string, cwd?: string): string { + if (!cwd) return filePath + const resolvedCwd = path.resolve(cwd) + const resolvedFile = path.resolve(filePath) + if ( + resolvedFile.startsWith(resolvedCwd + path.sep) || + resolvedFile === resolvedCwd + ) { + return path.relative(resolvedCwd, resolvedFile) + } + return filePath +} + +// ── Markdown helpers ────────────────────────────────────────────── + +export function markdownEscape(text: string): string { + let escape = '```' + for (const m of text.matchAll(/^```+/gm) ?? []) { + while (m[0].length >= escape.length) { + escape += '`' + } + } + return escape + '\n' + text + (text.endsWith('\n') ? '' : '\n') + escape +}