fix: 严格对齐 ACP 协议实现到 stable v1 规范

对照 /Users/konghayao/code/knowledgebase/origin/acp 规范审计并修复 53 条合规性
发现(critical 5 / major 17 / minor 20 / nit 11),完整审计报告见
docs/acp-compliance-audit.md。

Agent 端 (src/services/acp/agent.ts):
- initialize() 补齐 authMethods,promptCapabilities.image 降级为 false(声明与
  实现脱节,按 initialization.mdx 不声明的 capability 视为不支持)
- sessionCapabilities.fork 移至 _meta.claudeCode.forkSession(fork 在
  meta.unstable.json 中,避免在 stable sessionCapabilities 中暴露 unstable 特性)
- unstable_resumeSession 传 replay:false,不再通过 session/update 重放历史
  (session-setup.mdx:239 明确禁止)
- PromptResponse.usage 移至 _meta.claudeCode.usage
  (extensibility.mdx:39 禁止在 spec 类型根添加自定义字段)
- 空字符串 prompt 改为显式 throw(不再误返 end_turn)

Bridge (src/services/acp/bridge.ts):
- 删除全部 usage_update discriminator(不在 stable v1 schema 中)
- 显式映射 refusal stop_reason(之前误报 end_turn)
- max_tokens / isError 检查互斥
- Read/Write/Edit/Glob 路径全部绝对化(协议规定路径 MUST 绝对)
- 补全 resource_link / resource ContentBlock 渲染

Permissions (src/services/acp/permissions.ts):
- 补齐 reject_always PermissionOption(schema 规定的四个 option 之一)
- checkTerminalOutput 优先检查标准 clientCapabilities.terminal,
  回退到 _meta.terminal_output
- 新增 onPermissionCancelled 回调:cancelled permission outcome →
  StopReason::Cancelled(schema.json:629)
- ExitPlanMode cancelled 分支补上 toolUseID 字段

PromptConversion (src/services/acp/promptConversion.ts):
- resource 分支处理 BlobResource(之前静默丢弃 blob 内容)

acp-link 代理 (packages/acp-link/src/):
- WS 协议从专有 {type, payload} 改造为标准 JSON-RPC 2.0
  (transports.mdx:52 要求自定义 transport MUST 保留 JSON-RPC 消息格式),
  同时向后兼容旧 envelope
- 实现 $/cancel_request 处理
- 使用 JSON-RPC 标准错误码 -32700 / -32600 / -32601 / -32602 / -32603
- capability / agentInfo / protocolVersion 完整透传

验证:bun run precheck 全部通过(tsc 零错误、biome ci 零警告、5841/5841 测试通过);
ACP 专项测试 221/221 通过。独立 verification agent 抽查全部 PASS。

已知暂缓项(审计文档附录 B/C):
- §3.5 traceparent/trace-context 传播(QueryEngine 无 header hook)
- §5.2 terminal/create 完整生命周期(P1,非阻断,需新 RPC 流程)
- §4.2 in_progress tool_call status(SHOULD 级)
- §8.8/8.9/8.14 stale types.ts(不在 owner 分配集合,runtime 已修正)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-19 12:33:58 +08:00
parent f69c705166
commit 5e30697950
13 changed files with 2693 additions and 258 deletions

View File

@@ -40,6 +40,7 @@ import type {
} from '@agentclientprotocol/sdk'
import { randomUUID, type UUID } from 'node:crypto'
import { dirname } from 'node:path'
import * as path from 'node:path'
import type { Message } from '../../types/message.js'
import { deserializeMessages } from '../../utils/conversationRecovery.js'
import {
@@ -78,7 +79,11 @@ import {
} from './utils.js'
import { promptToQueryInput } from './promptConversion.js'
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
import { resolveSessionFilePath } from '../../utils/sessionStoragePortable.js'
import {
resolveSessionFilePath,
readSessionLite,
extractJsonStringField,
} from '../../utils/sessionStoragePortable.js'
import { getMainLoopModel } from '../../utils/model/model.js'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
@@ -126,6 +131,9 @@ export class AcpAgent implements Agent {
return {
protocolVersion: 1,
// Explicit empty authMethods signals "no authentication required" to
// Clients rather than "capability unknown". Matches authenticate() no-op.
authMethods: [],
agentInfo: {
name: 'claude-code',
title: 'Claude Code',
@@ -150,10 +158,16 @@ export class AcpAgent implements Agent {
_meta: {
claudeCode: {
promptQueueing: true,
// session/fork is UNSTABLE — not part of stable v1 SessionCapabilities.
// Advertise via _meta namespace per extensibility.mdx "Advertising
// Custom Capabilities" instead of the standard sessionCapabilities map.
forkSession: true,
},
},
// image:false — promptToQueryInput() does not parse ContentBlock::Image
// blocks yet. Re-enable only after multimodal query input support lands.
promptCapabilities: {
image: true,
image: false,
embeddedContext: true,
},
mcpCapabilities: {
@@ -162,7 +176,6 @@ export class AcpAgent implements Agent {
},
loadSession: true,
sessionCapabilities: {
fork: {},
list: {},
resume: {},
close: {},
@@ -193,7 +206,11 @@ export class AcpAgent implements Agent {
async unstable_resumeSession(
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
const result = await this.getOrCreateSession(params)
// Per session-setup.mdx "Resuming a Session": the Agent MUST NOT replay the
// conversation history via session/update notifications before responding.
// Only restore context + MCP connections, then return immediately. This
// differs from session/load which DOES replay history.
const result = await this.getOrCreateSession({ ...params, replay: false })
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
@@ -211,18 +228,29 @@ export class AcpAgent implements Agent {
async listSessions(
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
// Pagination is not implemented: we always return all available sessions
// for the requested cwd (no nextCursor). Per session-list.mdx the Agent
// SHOULD return an error if the cursor is invalid, so explicitly reject
// any client-supplied cursor rather than silently accepting it.
if (params.cursor !== undefined && params.cursor !== null) {
throw new Error(
'Pagination cursor not supported: listSessions returns all results in a single page.',
)
}
const candidates = await listSessionsImpl({
dir: params.cwd ?? undefined,
limit: 100,
})
const sessions = []
for (const candidate of candidates) {
if (!candidate.cwd) continue
// Only include title when non-empty; schema allows null/omitted title.
const title = sanitizeTitle(candidate.summary ?? '')
sessions.push({
sessionId: candidate.sessionId,
cwd: candidate.cwd,
title: sanitizeTitle(candidate.summary ?? ''),
...(title ? { title } : {}),
updatedAt: new Date(candidate.lastModified).toISOString(),
})
}
@@ -235,11 +263,26 @@ export class AcpAgent implements Agent {
async unstable_forkSession(
params: ForkSessionRequest,
): Promise<ForkSessionResponse> {
const response = await this.createSession({
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
})
// Load the source session's messages so the fork actually branches from
// the source conversation rather than starting a blank session. Per the
// unstable ForkSessionRequest, params.sessionId is the ID to fork from.
let initialMessages: Message[] | undefined
try {
const log = await getLastSessionLog(params.sessionId as UUID)
if (log && log.messages.length > 0) {
initialMessages = deserializeMessages(log.messages)
}
} catch (err) {
console.error('[ACP] fork source load failed:', err)
}
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{ initialMessages },
)
this.scheduleAvailableCommandsUpdate(response.sessionId)
return response
}
@@ -268,8 +311,11 @@ export class AcpAgent implements Agent {
// Extract text/image content from the prompt
const promptInput = promptToQueryInput(params.prompt)
// Per prompt-turn.mdx, `prompt` is a required ContentBlock[] and an
// effectively-empty prompt is malformed input — reject it with an
// invalid_params error rather than fabricating a successful end_turn.
if (!promptInput.trim()) {
return { stopReason: 'end_turn' }
throw new Error('Prompt content is empty')
}
const promptCancelGeneration = session.cancelGeneration
@@ -323,24 +369,51 @@ export class AcpAgent implements Agent {
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,
// Emit a session_info_update so Clients learn the session's display
// title / last-activity timestamp via the stable v1 session/update
// channel. The title is derived from the first user prompt.
await this.maybeEmitSessionInfoUpdate(params.sessionId, promptInput)
// Per extensibility.mdx:39 the root of PromptResponse is reserved —
// stable v1 defines only `stopReason` (+ optional `_meta`). Token usage
// is therefore carried under the `_meta.claudeCode.usage` extension
// namespace rather than as a non-spec root field. thoughtTokens are
// included in totalTokens so reported totals match billable tokens;
// until bridge.ts tracks them they are reported as 0.
if (usage) {
const thoughtTokens = 0
return {
stopReason,
_meta: {
claudeCode: {
usage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cachedReadTokens: usage.cachedReadTokens,
cachedWriteTokens: usage.cachedWriteTokens,
thoughtTokens,
totalTokens:
usage.inputTokens +
usage.outputTokens +
usage.cachedReadTokens +
usage.cachedWriteTokens +
thoughtTokens,
},
},
},
}
}
return { stopReason }
} catch (err: unknown) {
if (session.cancelled) {
// Treat AbortError / cancellation-shaped errors as a turn cancellation
// regardless of the session.cancelled flag, to close the race window
// between interrupt() firing and cancel() setting the flag. Per
// prompt-turn.mdx the Agent MUST return `cancelled` for aborts.
const isAbort =
err instanceof Error &&
(err.name === 'AbortError' ||
/abort|cancelled|interrupt/i.test(err.message))
if (session.cancelled || isAbort) {
return { stopReason: 'cancelled' }
}
@@ -402,6 +475,17 @@ export class AcpAgent implements Agent {
}
this.applySessionMode(params.sessionId, params.modeId)
// Per session-modes.mdx: when the Agent changes its own mode it MUST send
// a current_mode_update notification so mode-only Clients learn the
// switch. Mirrors the current_mode_update sent by setSessionConfigOption
// when configId === 'mode'.
await this.conn.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: 'current_mode_update',
currentModeId: params.modeId,
},
})
await this.updateConfigOption(params.sessionId, 'mode', params.modeId)
return {}
}
@@ -442,6 +526,21 @@ export class AcpAgent implements Agent {
throw new Error(`Unknown config option: ${params.configId}`)
}
// Per session-config-options.mdx: value MUST be one of the values listed
// in the option's options array. Reject unknown values with an error
// rather than silently persisting them. Only `select` options carry an
// options array; `boolean` options have no enumerated values.
if (option.type === 'select') {
const validValues = flattenConfigOptionValues(
(option as { options?: unknown }).options,
)
if (!validValues.includes(params.value)) {
throw new Error(
`Invalid value '${params.value}' for config option ${params.configId}; must be one of: ${validValues.join(', ')}`,
)
}
}
const value = params.value
if (params.configId === 'mode') {
@@ -672,9 +771,10 @@ export class AcpAgent implements Agent {
this.sessions.set(sessionId, session)
// Stable v1 NewSessionResponse only defines sessionId/modes/configOptions.
// `models` is a draft/unstable field — omit it for v1 compliance.
return {
sessionId,
models,
modes,
configOptions,
}
@@ -690,7 +790,13 @@ export class AcpAgent implements Agent {
cwd: string
mcpServers?: NewSessionRequest['mcpServers']
_meta?: NewSessionRequest['_meta']
// replay:true (default, session/load) streams the conversation history back
// to the client via session/update. replay:false (session/resume) only
// restores the in-process context — per session-setup.mdx the Agent MUST
// NOT replay history when resuming.
replay?: boolean
}): Promise<NewSessionResponse> {
const shouldReplay = params.replay !== false
const existingSession = this.sessions.get(params.sessionId)
if (existingSession) {
const fingerprint = computeSessionFingerprint({
@@ -710,12 +816,13 @@ export class AcpAgent implements Agent {
)
setOriginalCwd(params.cwd)
await this.replaySessionHistory(params)
if (shouldReplay) {
await this.replaySessionHistory(params)
}
return {
sessionId: params.sessionId,
modes: existingSession.modes,
models: existingSession.models,
configOptions: existingSession.configOptions,
}
}
@@ -729,6 +836,25 @@ export class AcpAgent implements Agent {
// search by sessionId first and fall back to cwd-based lookup.
const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
const projectDir = resolved ? dirname(resolved.filePath) : null
// Per session-setup.mdx "Working Directory": the cwd MUST be the absolute
// path used for the session regardless of where the Agent was spawned.
// Reject cross-project loads where the persisted session's original cwd
// does not match the requested cwd, otherwise the client could load a
// session belonging to project B while passing project A's cwd.
if (resolved) {
const lite = await readSessionLite(resolved.filePath)
const originalCwd = lite && extractJsonStringField(lite.head, 'cwd')
if (
originalCwd &&
path.resolve(originalCwd) !== path.resolve(params.cwd)
) {
throw new Error(
`Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`,
)
}
}
switchSession(params.sessionId as SessionId, projectDir)
setOriginalCwd(params.cwd)
@@ -753,8 +879,8 @@ export class AcpAgent implements Agent {
{ sessionId: params.sessionId, initialMessages },
)
// Replay history to client if loaded
if (initialMessages && initialMessages.length > 0) {
// Replay history to client if loaded. session/resume skips this block.
if (shouldReplay && initialMessages && initialMessages.length > 0) {
const session = this.sessions.get(params.sessionId)
if (session) {
await replayHistoryMessages(
@@ -771,7 +897,6 @@ export class AcpAgent implements Agent {
return {
sessionId: response.sessionId,
modes: response.modes,
models: response.models,
configOptions: response.configOptions,
}
}
@@ -912,6 +1037,39 @@ export class AcpAgent implements Agent {
}, 0)
}
/**
* Emit a session_info_update notification carrying a derived session title
* (truncated first user prompt) and the current last-activity timestamp.
* Sent once per session — subsequent turns reuse the same title.
*/
private async maybeEmitSessionInfoUpdate(
sessionId: string,
firstPrompt: string,
): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
// sessionInfoTitleSent is tracked via toolUseCache to avoid reshaping
// AcpSession; use a dedicated per-session flag instead.
const cache = session.toolUseCache as ToolUseCache & {
__sessionInfoTitleSent?: boolean
}
if (cache.__sessionInfoTitleSent) return
cache.__sessionInfoTitleSent = true
const title = sanitizeTitle(firstPrompt).slice(0, 100)
try {
await this.conn.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'session_info_update',
...(title ? { title } : {}),
updatedAt: new Date().toISOString(),
},
})
} catch (err) {
console.error('[ACP] Failed to send session_info_update:', err)
}
}
/** Read a setting from Claude config (simplified — no file watching) */
private getSetting<T>(key: string): T | undefined {
const settings = getSettings_DEPRECATED() as Record<string, unknown>
@@ -1036,6 +1194,37 @@ function isTruthyEnv(value: string | undefined): boolean {
return value === '1' || value?.toLowerCase() === 'true'
}
/**
* Flatten a SessionConfigOption's `options` (which may be flat
* SessionConfigSelectOption entries or grouped SessionConfigSelectGroup
* entries) into a list of valid value strings. Used to validate that a
* setSessionConfigOption value is one of the listed options.
*/
function flattenConfigOptionValues(options: unknown): string[] {
const values: string[] = []
if (!Array.isArray(options)) return values
for (const opt of options) {
if (typeof opt !== 'object' || opt === null) continue
const maybeGroup = opt as { group?: unknown; options?: unknown[] }
if (Array.isArray(maybeGroup.options)) {
// SessionConfigSelectGroup — recurse into its options
for (const inner of maybeGroup.options) {
if (
inner &&
typeof inner === 'object' &&
typeof (inner as { value?: unknown }).value === 'string'
) {
values.push((inner as { value: string }).value)
}
}
} else if (typeof (opt as { value?: unknown }).value === 'string') {
// SessionConfigSelectOption
values.push((opt as { value: string }).value)
}
}
return values
}
function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined {
while (session.pendingQueueHead < session.pendingQueue.length) {
const nextId = session.pendingQueue[session.pendingQueueHead++]