feat(acp): 补充 promptConversion 模块 + 修复 build

- 从上流 c2ac9a74 拉取缺失的 promptConversion.ts 及测试
- build 586 文件通过,test 3551 (5 fail/1 error 基线一致)
- acp-link manager 以上游 CCB 最新版为准
This commit is contained in:
James Feng 2026-06-04 18:22:41 +08:00
parent 082f91aa80
commit 3a542d4f50
8 changed files with 2194 additions and 2024 deletions

2330
bun.lock

File diff suppressed because it is too large Load Diff

189
docs/features/acp-zed.md Normal file
View 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` | ✅ | 配置更新通知 |

View File

@ -19,33 +19,33 @@ export const command = buildCommand({
kind: "parsed",
parse: numberParser,
brief: "Port to listen on",
default: "9315",
optional: true,
},
host: {
kind: "parsed",
parse: String,
brief: "Host to bind to (use 0.0.0.0 for remote access)",
default: "localhost",
optional: true,
},
debug: {
kind: "boolean",
brief: "Enable debug logging to file",
default: false,
optional: true,
},
"no-auth": {
kind: "boolean",
brief: "DANGEROUS: Disable authentication (not recommended)",
default: false,
optional: true,
},
https: {
kind: "boolean",
brief: "Enable HTTPS with auto-generated self-signed certificate",
default: false,
optional: true,
},
manager: {
kind: "boolean",
brief: "Start Manager Web UI (no proxy)",
default: false,
optional: true,
},
group: {
kind: "parsed",
@ -65,21 +65,22 @@ export const command = buildCommand({
brief: "Agent command and arguments (use -- before agent flags)",
parse: String,
placeholder: "command",
optional: true,
},
minimum: 0,
},
},
func: async function (
this: LocalContext,
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
flags: { port?: number; host?: string; debug?: boolean; "no-auth"?: boolean; https?: boolean; manager?: boolean; group?: string },
...args: readonly string[]
) {
const port = flags.port;
const host = flags.host;
const debug = flags.debug;
const noAuth = flags["no-auth"];
const https = flags.https;
const manager = flags.manager;
const port = flags.port ?? 9315;
const host = flags.host ?? "localhost";
const debug = flags.debug ?? false;
const noAuth = flags["no-auth"] ?? false;
const https = flags.https ?? false;
const manager = flags.manager ?? false;
const group = flags.group;
// Manager mode: start web UI only, no proxy

View File

@ -1282,16 +1282,6 @@ export async function startServer(config: ServerConfig): Promise<void> {
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
// Graceful shutdown — close RCS upstream
const shutdown = async () => {
if (rcsUpstream) {
await rcsUpstream.close();
}
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Keep the server running
await new Promise(() => {})
}

View File

@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test'
import { promptToQueryInput } from '../promptConversion.js'
describe('promptToQueryInput', () => {
test('converts text and embedded text resources', () => {
expect(
promptToQueryInput([
{ type: 'text', text: 'hello' },
{
type: 'resource',
resource: { text: 'resource body' },
} as any,
]),
).toBe('hello\nresource body')
})
test('renders resource_link as plain metadata instead of markdown link', () => {
expect(
promptToQueryInput([
{
type: 'resource_link',
name: 'Spec',
uri: 'file:///tmp/spec.md',
} as any,
]),
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
})
})

1346
src/services/acp/bridge.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,248 @@
/**
* 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
}

View File

@ -0,0 +1,40 @@
import type { ContentBlock } from '@agentclientprotocol/sdk'
export 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(String(b.text ?? ''))
} else if (b.type === 'resource_link') {
const name = typeof b.name === 'string' ? b.name : undefined
const uri = typeof b.uri === 'string' ? b.uri : undefined
// Keep resource links as metadata, not markdown links, so models do not
// infer user-visible click targets or silently rewrite URI semantics.
parts.push(formatResourceLink(name, uri))
} else if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && typeof resource.text === 'string') {
parts.push(resource.text)
}
}
}
return parts.filter(part => part.length > 0).join('\n')
}
function formatResourceLink(
name: string | undefined,
uri: string | undefined,
): string {
const details: string[] = []
if (name && name.length > 0) details.push(`name=${name}`)
if (uri && uri.length > 0) details.push(`uri=${uri}`)
return details.length > 0
? `Resource link: ${details.join(', ')}`
: 'Resource link'
}