mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
@@ -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) |
|
||||
|
||||
2
build.ts
2
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',
|
||||
|
||||
3
bun.lock
3
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",
|
||||
},
|
||||
@@ -265,6 +266,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"],
|
||||
|
||||
189
docs/features/acp-zed.md
Normal file
189
docs/features/acp-zed.md
Normal file
@@ -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` | ✅ | 配置更新通知 |
|
||||
@@ -60,8 +60,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -132,6 +132,14 @@ async function main(): Promise<void> {
|
||||
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=<kind>` (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 —
|
||||
|
||||
735
src/services/acp/__tests__/agent.test.ts
Normal file
735
src/services/acp/__tests__/agent.test.ts
Normal file
@@ -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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).mockImplementationOnce(
|
||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
// Second prompt resolves normally
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).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<typeof mock>).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<typeof mock>).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<typeof mock>).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]' })
|
||||
})
|
||||
})
|
||||
})
|
||||
677
src/services/acp/__tests__/bridge.test.ts
Normal file
677
src/services/acp/__tests__/bridge.test.ts
Normal file
@@ -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> = {}): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: mock(async () => {}),
|
||||
requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } }) as any),
|
||||
...overrides,
|
||||
} as unknown as AgentSideConnection
|
||||
}
|
||||
|
||||
async function* makeStream(msgs: SDKMessage[]): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
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<string, unknown>).terminal_info).toEqual({ terminal_id: 't1' })
|
||||
expect((result._meta as Record<string, unknown>).terminal_output).toEqual({ terminal_id: 't1', data: 'output' })
|
||||
expect((result._meta as Record<string, unknown>).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<string, unknown>
|
||||
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<typeof mock>).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<typeof mock>).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<typeof mock>).mock.calls[0][0].update as Record<string, unknown>
|
||||
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<typeof mock>).mock.calls
|
||||
const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record<string, Record<string, unknown>>).update ?? {})['sessionUpdate'] === 'usage_update')
|
||||
expect(usageUpdate).toBeDefined()
|
||||
expect(((usageUpdate![0] as Record<string, unknown>).update as Record<string, unknown>).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<typeof mock>).mock.calls
|
||||
const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record<string, Record<string, unknown>>).update ?? {})['sessionUpdate'] === 'usage_update')
|
||||
expect(usageUpdate).toBeDefined()
|
||||
expect(((usageUpdate![0] as Record<string, unknown>).update as Record<string, unknown>).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<typeof mock>).mock.calls
|
||||
const usageCall = calls.find((c: unknown[]) => ((c[0] as Record<string, Record<string, unknown>>).update ?? {})['sessionUpdate'] === 'usage_update')
|
||||
expect(usageCall).toBeDefined()
|
||||
expect(((usageCall![0] as Record<string, unknown>).update as Record<string, unknown>).used).toBe(0)
|
||||
})
|
||||
|
||||
test('re-throws unexpected errors from stream', async () => {
|
||||
const conn = makeConn()
|
||||
async function* errorStream(): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
throw new Error('stream exploded')
|
||||
}
|
||||
await expect(
|
||||
forwardSessionUpdates('s1', errorStream(), conn, new AbortController().signal, {}),
|
||||
).rejects.toThrow('stream exploded')
|
||||
})
|
||||
})
|
||||
144
src/services/acp/__tests__/permissions.test.ts
Normal file
144
src/services/acp/__tests__/permissions.test.ts
Normal file
@@ -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<string, unknown>,
|
||||
_context: any,
|
||||
_assistantMessage: any,
|
||||
toolUseID: string,
|
||||
): Promise<{ behavior: string; message?: string; updatedInput?: Record<string, unknown> }> => {
|
||||
if (getCurrentMode() === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
const TOOL_KIND_MAP: Record<string, string> = {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>
|
||||
const dummyMsg = {} as Record<string, unknown>
|
||||
|
||||
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<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const callArgs = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
expect(callArgs.sessionId).toBe('my-session')
|
||||
expect((callArgs.toolCall as Record<string, unknown>).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<typeof mock>
|
||||
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<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const { options } = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
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()
|
||||
})
|
||||
})
|
||||
801
src/services/acp/agent.ts
Normal file
801
src/services/acp/agent.ts
Normal file
@@ -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<string, { resolve: (cancelled: boolean) => 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<string, AcpSession>()
|
||||
private clientCapabilities?: ClientCapabilities
|
||||
|
||||
constructor(conn: AgentSideConnection) {
|
||||
this.conn = conn
|
||||
}
|
||||
|
||||
// ── initialize ────────────────────────────────────────────────
|
||||
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = params.clientCapabilities
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentInfo: {
|
||||
name: 'claude-code',
|
||||
title: 'Claude Code',
|
||||
version:
|
||||
typeof (globalThis as unknown as Record<string, unknown>).MACRO ===
|
||||
'object' &&
|
||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
||||
.MACRO !== null
|
||||
? String(
|
||||
(
|
||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
||||
.MACRO as Record<string, unknown>
|
||||
).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<AuthenticateResponse> {
|
||||
// No authentication required — this is a self-hosted/custom deployment
|
||||
return {}
|
||||
}
|
||||
|
||||
// ── newSession ────────────────────────────────────────────────
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
return this.createSession(params)
|
||||
}
|
||||
|
||||
// ── resumeSession ──────────────────────────────────────────────
|
||||
|
||||
async unstable_resumeSession(
|
||||
params: ResumeSessionRequest,
|
||||
): Promise<ResumeSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── loadSession ────────────────────────────────────────────────
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params)
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId)
|
||||
}, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
// ── listSessions ───────────────────────────────────────────────
|
||||
|
||||
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
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<ForkSessionResponse> {
|
||||
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<CloseSessionResponse> {
|
||||
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<PromptResponse> {
|
||||
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<boolean>((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<void> {
|
||||
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<SetSessionModeResponse> {
|
||||
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<SetSessionModelResponse | void> {
|
||||
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<SetSessionConfigOptionResponse> {
|
||||
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<NewSessionResponse> {
|
||||
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<string>('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<NewSessionResponse> {
|
||||
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<Record<string, unknown>>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<T>(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<ContentBlock> | undefined,
|
||||
): string {
|
||||
if (!prompt || prompt.length === 0) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
for (const block of prompt) {
|
||||
const b = block as Record<string, unknown>
|
||||
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<string, unknown> | 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[]
|
||||
}
|
||||
1254
src/services/acp/bridge.ts
Normal file
1254
src/services/acp/bridge.ts
Normal file
File diff suppressed because it is too large
Load Diff
77
src/services/acp/entry.ts
Normal file
77
src/services/acp/entry.ts
Normal file
@@ -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<Uint8Array>
|
||||
const writableToClient = Writable.toWeb(
|
||||
nodeWritable as typeof process.stdout,
|
||||
) as unknown as WritableStream<Uint8Array>
|
||||
return ndJsonStream(writableToClient, readableFromClient)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the ACP (Agent Client Protocol) agent mode.
|
||||
*/
|
||||
export async function runAcpAgent(): Promise<void> {
|
||||
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<void> {
|
||||
// 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()
|
||||
}
|
||||
224
src/services/acp/permissions.ts
Normal file
224
src/services/acp/permissions.ts
Normal file
@@ -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<string, unknown>,
|
||||
_context: ToolUseContext,
|
||||
_assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
_forceDecision?: PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision,
|
||||
): Promise<PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision> => {
|
||||
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<PermissionOption> = [
|
||||
{ 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<string, unknown>,
|
||||
supportsTerminalOutput: boolean,
|
||||
cwd?: string,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{ 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<string, unknown>)._meta
|
||||
if (!meta || typeof meta !== 'object') return false
|
||||
return (meta as Record<string, unknown>)['terminal_output'] === true
|
||||
}
|
||||
208
src/services/acp/utils.ts
Normal file
208
src/services/acp/utils.ts
Normal file
@@ -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<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = []
|
||||
private resolvers: ((value: IteratorResult<T>) => 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<T> {
|
||||
return {
|
||||
next: (): Promise<IteratorResult<T>> => {
|
||||
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<IteratorResult<T>>((resolve) => {
|
||||
this.resolvers.push(resolve)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stream helpers ────────────────────────────────────────────────
|
||||
|
||||
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
nodeStream.write(Buffer.from(chunk), (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<string, PermissionMode> = {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user