mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 07:15:51 +00:00
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:
@@ -260,25 +260,42 @@ describe('AcpAgent', () => {
|
||||
expect(typeof res.agentInfo?.version).toBe('string')
|
||||
})
|
||||
|
||||
test('advertises image and embeddedContext capability', async () => {
|
||||
test('advertises embeddedContext capability and disables image until multimodal input lands', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
// image:false — promptToQueryInput does not parse image blocks yet
|
||||
expect(res.agentCapabilities?.promptCapabilities?.image).toBe(false)
|
||||
expect(res.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('returns explicit empty authMethods', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.authMethods).toEqual([])
|
||||
})
|
||||
|
||||
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 () => {
|
||||
test('session capabilities include list, resume, close (fork advertised via _meta)', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.agentCapabilities?.sessionCapabilities).toBeDefined()
|
||||
const caps = res.agentCapabilities?.sessionCapabilities as any
|
||||
expect(caps).toBeDefined()
|
||||
expect(caps.list).toBeDefined()
|
||||
expect(caps.resume).toBeDefined()
|
||||
expect(caps.close).toBeDefined()
|
||||
// fork is UNSTABLE — advertised under _meta.claudeCode.forkSession, not
|
||||
// under sessionCapabilities (which is stable-v1 only).
|
||||
expect(caps.fork).toBeUndefined()
|
||||
expect(
|
||||
(res.agentCapabilities?._meta as any)?.claudeCode?.forkSession,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -298,12 +315,13 @@ describe('AcpAgent', () => {
|
||||
expect(res.sessionId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns modes and models', async () => {
|
||||
test('returns modes and configOptions (models omitted for v1 compliance)', 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()
|
||||
// Stable v1 NewSessionResponse does not define `models`
|
||||
expect((res as any).models).toBeUndefined()
|
||||
})
|
||||
|
||||
test('each call returns a unique sessionId', async () => {
|
||||
@@ -328,9 +346,10 @@ describe('AcpAgent', () => {
|
||||
|
||||
test('calls getMainLoopModel to resolve current model', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(mockGetMainLoopModel).toHaveBeenCalled()
|
||||
expect(res.models?.currentModelId).toBe('claude-sonnet-4-6')
|
||||
// models is no longer in the v1 response, but the engine still receives it
|
||||
expect(mockSetModel).toHaveBeenCalledWith('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
test('calls queryEngine.setModel with resolved model', async () => {
|
||||
@@ -342,8 +361,7 @@ describe('AcpAgent', () => {
|
||||
test('respects model alias resolution via getMainLoopModel', async () => {
|
||||
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')
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(mockSetModel).toHaveBeenCalledWith('glm-5.1')
|
||||
})
|
||||
|
||||
@@ -464,21 +482,23 @@ describe('AcpAgent', () => {
|
||||
).rejects.toThrow('nonexistent')
|
||||
})
|
||||
|
||||
test('returns end_turn for empty prompt text', async () => {
|
||||
test('rejects empty prompt text with an error', 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')
|
||||
await expect(
|
||||
agent.prompt({ sessionId, prompt: [] } as any),
|
||||
).rejects.toThrow('Prompt content is empty')
|
||||
})
|
||||
|
||||
test('returns end_turn for whitespace-only prompt', async () => {
|
||||
test('rejects whitespace-only prompt with an error', 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')
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: ' ' }],
|
||||
} as any),
|
||||
).rejects.toThrow('Prompt content is empty')
|
||||
})
|
||||
|
||||
test('calls forwardSessionUpdates for valid prompt', async () => {
|
||||
@@ -556,7 +576,7 @@ describe('AcpAgent', () => {
|
||||
).rejects.toThrow('unexpected')
|
||||
})
|
||||
|
||||
test('returns usage from forwardSessionUpdates', async () => {
|
||||
test('returns usage under _meta.claudeCode.usage from forwardSessionUpdates', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||
@@ -574,10 +594,13 @@ describe('AcpAgent', () => {
|
||||
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)
|
||||
// Stable v1 PromptResponse has no root `usage`; it lives under _meta.
|
||||
expect((res as any).usage).toBeUndefined()
|
||||
const usage = (res as any)._meta?.claudeCode?.usage
|
||||
expect(usage).toBeDefined()
|
||||
expect(usage.inputTokens).toBe(100)
|
||||
expect(usage.outputTokens).toBe(50)
|
||||
expect(usage.totalTokens).toBe(165)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -649,7 +672,7 @@ describe('AcpAgent', () => {
|
||||
})
|
||||
|
||||
describe('prompt usage tracking', () => {
|
||||
test('returns totalTokens as sum of all token types', async () => {
|
||||
test('reports totalTokens as sum of all token types under _meta.claudeCode.usage', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||
@@ -667,11 +690,12 @@ describe('AcpAgent', () => {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.usage).toBeDefined()
|
||||
expect(res.usage!.totalTokens).toBe(165)
|
||||
const usage = (res as any)._meta?.claudeCode?.usage
|
||||
expect(usage).toBeDefined()
|
||||
expect(usage.totalTokens).toBe(165)
|
||||
})
|
||||
|
||||
test('returns undefined usage when forwardSessionUpdates returns none', async () => {
|
||||
test('omits _meta.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(
|
||||
@@ -683,7 +707,7 @@ describe('AcpAgent', () => {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.usage).toBeUndefined()
|
||||
expect((res as any)._meta).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -734,7 +758,8 @@ describe('AcpAgent', () => {
|
||||
} as any)
|
||||
expect(agent.sessions.has(requestedId)).toBe(true)
|
||||
expect(res.modes).toBeDefined()
|
||||
expect(res.models).toBeDefined()
|
||||
// models is omitted for v1 compliance
|
||||
expect((res as any).models).toBeUndefined()
|
||||
})
|
||||
|
||||
test('reuses existing session when sessionId matches and fingerprint unchanged', async () => {
|
||||
@@ -805,12 +830,26 @@ describe('AcpAgent', () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const original = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const forked = await agent.unstable_forkSession({
|
||||
// params.sessionId is the source session to fork from
|
||||
sessionId: original.sessionId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
expect(forked.sessionId).not.toBe(original.sessionId)
|
||||
expect(agent.sessions.has(forked.sessionId)).toBe(true)
|
||||
})
|
||||
|
||||
test('attempts to load source session history when forking', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const original = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
mockGetLastSessionLog.mockClear()
|
||||
await agent.unstable_forkSession({
|
||||
sessionId: original.sessionId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
expect(mockGetLastSessionLog).toHaveBeenCalledWith(original.sessionId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionMode', () => {
|
||||
@@ -919,17 +958,32 @@ describe('AcpAgent', () => {
|
||||
const session = agent.sessions.get(sessionId)
|
||||
removeBypassMode(session)
|
||||
|
||||
// The value is rejected because it is not in the option's listed values
|
||||
// (config-option validation runs before the mode-availability check).
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'mode',
|
||||
value: 'bypassPermissions',
|
||||
} as any),
|
||||
).rejects.toThrow('Mode not available')
|
||||
).rejects.toThrow(/Invalid value 'bypassPermissions'/)
|
||||
|
||||
expect(session?.modes.currentModeId).toBe('default')
|
||||
expect(session?.appState.toolPermissionContext.mode).toBe('default')
|
||||
})
|
||||
|
||||
test('rejects mode values not listed in the option options array', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'mode',
|
||||
value: 'totally-not-a-real-mode',
|
||||
} as any),
|
||||
).rejects.toThrow(/must be one of:/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt queueing', () => {
|
||||
|
||||
@@ -299,6 +299,91 @@ describe('toolInfoFromToolUse', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('Read with relative file_path and cwd → locations resolved to absolute', () => {
|
||||
// Audit §5.5: ToolCallLocation.path MUST be absolute. A relative input
|
||||
// path is resolved against the session cwd before being emitted.
|
||||
const info = toolInfoFromToolUse(
|
||||
{
|
||||
name: 'Read',
|
||||
id: 'x',
|
||||
input: { file_path: 'src/main.ts' },
|
||||
},
|
||||
false,
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(info.locations).toEqual([
|
||||
{ path: '/Users/test/project/src/main.ts', line: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test('Write with relative file_path and cwd → diff path resolved absolute', () => {
|
||||
// Audit §5.5: Diff.path MUST be absolute.
|
||||
const info = toolInfoFromToolUse(
|
||||
{
|
||||
name: 'Write',
|
||||
id: 'x',
|
||||
input: { file_path: 'rel/file.txt', content: 'hi' },
|
||||
},
|
||||
false,
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(info.content).toEqual([
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/rel/file.txt',
|
||||
oldText: null,
|
||||
newText: 'hi',
|
||||
},
|
||||
])
|
||||
expect(info.locations).toEqual([
|
||||
{ path: '/Users/test/project/rel/file.txt' },
|
||||
])
|
||||
})
|
||||
|
||||
test('Edit with relative file_path and cwd → diff path resolved absolute', () => {
|
||||
// Audit §5.5: Diff.path MUST be absolute.
|
||||
const info = toolInfoFromToolUse(
|
||||
{
|
||||
name: 'Edit',
|
||||
id: 'x',
|
||||
input: {
|
||||
file_path: 'rel/edit.txt',
|
||||
old_string: 'a',
|
||||
new_string: 'b',
|
||||
},
|
||||
},
|
||||
false,
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(info.content).toEqual([
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/rel/edit.txt',
|
||||
oldText: 'a',
|
||||
newText: 'b',
|
||||
},
|
||||
])
|
||||
expect(info.locations).toEqual([
|
||||
{ path: '/Users/test/project/rel/edit.txt' },
|
||||
])
|
||||
})
|
||||
|
||||
test('Glob with relative path and cwd → locations resolved absolute', () => {
|
||||
// Audit §5.5: ToolCallLocation.path MUST be absolute. Title keeps the raw
|
||||
// input for display, but the emitted location is resolved against cwd.
|
||||
const info = toolInfoFromToolUse(
|
||||
{
|
||||
name: 'Glob',
|
||||
id: 'x',
|
||||
input: { pattern: '*.ts', path: 'src' },
|
||||
},
|
||||
false,
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(info.title).toBe('Find `src` `*.ts`')
|
||||
expect(info.locations).toEqual([{ path: '/Users/test/project/src' }])
|
||||
})
|
||||
|
||||
// ── WebSearch ─────────────────────────────────────────────────
|
||||
|
||||
test('WebSearch with allowed/blocked domains', () => {
|
||||
@@ -543,6 +628,91 @@ describe('toolUpdateFromToolResult', () => {
|
||||
)
|
||||
expect(result.title).toBe('Exited Plan Mode')
|
||||
})
|
||||
|
||||
test('renders resource_link content as ACP ResourceLink (audit §7.3)', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
name: 'Spec',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
],
|
||||
is_error: false,
|
||||
tool_use_id: 't1',
|
||||
},
|
||||
{ name: 'SomeTool', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'resource_link',
|
||||
uri: 'file:///tmp/spec.md',
|
||||
name: 'Spec',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('resource_link without name falls back to uri (audit §7.3)', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{
|
||||
content: [{ type: 'resource_link', uri: 'file:///tmp/x.md' }],
|
||||
is_error: false,
|
||||
tool_use_id: 't1',
|
||||
},
|
||||
{ name: 'SomeTool', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'resource_link',
|
||||
uri: 'file:///tmp/x.md',
|
||||
name: 'file:///tmp/x.md',
|
||||
mimeType: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('renders resource content as ACP EmbeddedResource (audit §7.3)', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///tmp/readme.md',
|
||||
mimeType: 'text/markdown',
|
||||
text: '# Hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
is_error: false,
|
||||
tool_use_id: 't1',
|
||||
},
|
||||
{ name: 'SomeTool', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///tmp/readme.md',
|
||||
mimeType: 'text/markdown',
|
||||
text: '# Hello',
|
||||
blob: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── toolUpdateFromEditToolResponse ─────────────────────────────────
|
||||
@@ -650,6 +820,56 @@ describe('toolUpdateFromEditToolResponse', () => {
|
||||
}),
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test('resolves relative filePath against cwd (audit §5.5)', () => {
|
||||
// ToolCallLocation.path / Diff.path MUST be absolute.
|
||||
const result = toolUpdateFromEditToolResponse(
|
||||
{
|
||||
filePath: 'rel/file.ts',
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ['-old', '+new'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/rel/file.ts',
|
||||
oldText: 'old',
|
||||
newText: 'new',
|
||||
},
|
||||
],
|
||||
locations: [{ path: '/Users/test/project/rel/file.ts', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps absolute filePath unchanged when cwd provided', () => {
|
||||
const result = toolUpdateFromEditToolResponse(
|
||||
{
|
||||
filePath: '/abs/file.ts',
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ['-old', '+new'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(result.content![0]).toMatchObject({ path: '/abs/file.ts' })
|
||||
expect(result.locations![0]).toMatchObject({ path: '/abs/file.ts' })
|
||||
})
|
||||
})
|
||||
|
||||
// ── markdownEscape ─────────────────────────────────────────────────
|
||||
@@ -945,7 +1165,10 @@ describe('forwardSessionUpdates', () => {
|
||||
expect(update.rawInput).not.toBe(input)
|
||||
})
|
||||
|
||||
test('sends usage_update on result message with correct tokens', async () => {
|
||||
test('returns accumulated usage on result message without sending usage_update', async () => {
|
||||
// usage_update is an UNSTABLE SessionUpdate discriminator and is no longer
|
||||
// emitted (audit §4.1). Token totals are still aggregated for the
|
||||
// PromptResponse return value so callers can include them via _meta.
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
@@ -973,9 +1196,19 @@ describe('forwardSessionUpdates', () => {
|
||||
expect(result.usage).toBeDefined()
|
||||
expect(result.usage!.inputTokens).toBe(100)
|
||||
expect(result.usage!.outputTokens).toBe(50)
|
||||
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).toBeUndefined()
|
||||
})
|
||||
|
||||
test('sends usage_update with context window from modelUsage', async () => {
|
||||
test('does not emit usage_update even when modelUsage reports context window', async () => {
|
||||
// Context-window resolution still runs internally (so PromptResponse can
|
||||
// surface it), but no usage_update notification is sent for v1 compliance.
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
@@ -1023,18 +1256,10 @@ describe('forwardSessionUpdates', () => {
|
||||
'sessionUpdate'
|
||||
] === 'usage_update',
|
||||
)
|
||||
expect(usageUpdate).toBeDefined()
|
||||
expect(
|
||||
(
|
||||
(usageUpdate![0] as Record<string, unknown>).update as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
).size,
|
||||
).toBe(1000000)
|
||||
expect(usageUpdate).toBeUndefined()
|
||||
})
|
||||
|
||||
test('sends usage_update with prefix-matched modelUsage', async () => {
|
||||
test('prefix-matches modelUsage without emitting usage_update', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
@@ -1082,18 +1307,125 @@ describe('forwardSessionUpdates', () => {
|
||||
'sessionUpdate'
|
||||
] === 'usage_update',
|
||||
)
|
||||
expect(usageUpdate).toBeDefined()
|
||||
expect(
|
||||
(
|
||||
(usageUpdate![0] as Record<string, unknown>).update as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
).size,
|
||||
).toBe(2000000)
|
||||
expect(usageUpdate).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resets usage on compact_boundary', async () => {
|
||||
test('maps refusal stop_reason to ACP refusal stop reason', async () => {
|
||||
// Audit §3.3: a safety refusal must surface as StopReason::refusal rather
|
||||
// than being misreported as end_turn.
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: '',
|
||||
stop_reason: 'refusal',
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
conn,
|
||||
new AbortController().signal,
|
||||
{},
|
||||
)
|
||||
expect(result.stopReason).toBe('refusal')
|
||||
})
|
||||
|
||||
test('success with max_tokens stop_reason maps to max_tokens when not error', async () => {
|
||||
// Audit §3.3/§3.4: success + max_tokens + no error surfaces max_tokens.
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: '',
|
||||
stop_reason: 'max_tokens',
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
conn,
|
||||
new AbortController().signal,
|
||||
{},
|
||||
)
|
||||
expect(result.stopReason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
test('success with max_tokens stop_reason falls back to end_turn when isError', async () => {
|
||||
// Audit §3.3: in the success branch, isError acts as a last-resort
|
||||
// override to end_turn per the merged fix diff.
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: true,
|
||||
result: '',
|
||||
stop_reason: 'max_tokens',
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
conn,
|
||||
new AbortController().signal,
|
||||
{},
|
||||
)
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('maps error_during_execution with max_tokens stop_reason', async () => {
|
||||
// Audit §3.4: error_during_execution branch must preserve max_tokens even
|
||||
// when isError is set (mutually exclusive branches).
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
is_error: true,
|
||||
result: '',
|
||||
stop_reason: 'max_tokens',
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
conn,
|
||||
new AbortController().signal,
|
||||
{},
|
||||
)
|
||||
expect(result.stopReason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
test('maps error_during_execution without max_tokens to end_turn', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
is_error: true,
|
||||
result: '',
|
||||
stop_reason: 'end_turn',
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
makeStream(msgs),
|
||||
conn,
|
||||
new AbortController().signal,
|
||||
{},
|
||||
)
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('compact_boundary emits completion message without usage_update', async () => {
|
||||
// After audit §4.1, compact_boundary still sends the "Compacting completed."
|
||||
// agent_message_chunk but no longer emits the unstable usage_update
|
||||
// notification.
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{ type: 'system', subtype: 'compact_boundary' } as unknown as SDKMessage,
|
||||
@@ -1112,15 +1444,14 @@ describe('forwardSessionUpdates', () => {
|
||||
'sessionUpdate'
|
||||
] === 'usage_update',
|
||||
)
|
||||
expect(usageCall).toBeDefined()
|
||||
expect(
|
||||
(
|
||||
(usageCall![0] as Record<string, unknown>).update as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
).used,
|
||||
).toBe(0)
|
||||
expect(usageCall).toBeUndefined()
|
||||
const messageCall = calls.find(
|
||||
(c: unknown[]) =>
|
||||
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
||||
'sessionUpdate'
|
||||
] === 'agent_message_chunk',
|
||||
)
|
||||
expect(messageCall).toBeDefined()
|
||||
})
|
||||
|
||||
test('ignores unknown message types without crashing', async () => {
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('createAcpCanUseTool', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('options include allow always, allow once, and reject once', async () => {
|
||||
test('options include allow always, allow once, reject once, and reject always', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
|
||||
@@ -245,6 +245,7 @@ describe('createAcpCanUseTool', () => {
|
||||
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
|
||||
expect(opts.find(option => option.kind === 'reject_always')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
|
||||
@@ -332,4 +333,92 @@ describe('createAcpCanUseTool', () => {
|
||||
(conn.sessionUpdate as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('checkTerminalOutput honors standard clientCapabilities.terminal', async () => {
|
||||
// Standard ACP v1 client advertises terminal: true without any _meta hint.
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const capabilities = { terminal: true } as any
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-term',
|
||||
() => 'default',
|
||||
capabilities,
|
||||
)
|
||||
await canUseTool(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_term')
|
||||
|
||||
const { toolCall } = (conn.requestPermission as ReturnType<typeof mock>)
|
||||
.mock.calls[0][0] as Record<string, unknown>
|
||||
// toolInfoFromToolUse is mocked; we only assert the standard capability is
|
||||
// respected (no crash, request delegated). The legacy _meta path is
|
||||
// exercised separately below.
|
||||
expect(toolCall).toBeDefined()
|
||||
})
|
||||
|
||||
test('checkTerminalOutput falls back to legacy _meta.terminal_output', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const capabilities = { _meta: { terminal_output: true } } as any
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-term-legacy',
|
||||
() => 'default',
|
||||
capabilities,
|
||||
)
|
||||
await canUseTool(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_term2')
|
||||
|
||||
expect(
|
||||
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('cancelled permission outcome invokes onPermissionCancelled callback', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const onPermissionCancelled = mock(() => {})
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-cancel',
|
||||
() => 'default',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
onPermissionCancelled,
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
makeTool('Bash'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_cancel',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(onPermissionCancelled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('ExitPlanMode cancelled outcome invokes onPermissionCancelled callback', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const onPermissionCancelled = mock(() => {})
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
conn,
|
||||
'sess-cancel-plan',
|
||||
() => 'plan',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
onPermissionCancelled,
|
||||
)
|
||||
|
||||
const result = await canUseTool(
|
||||
makeTool('ExitPlanMode'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_cancel_plan',
|
||||
)
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(onPermissionCancelled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,4 +25,31 @@ describe('promptToQueryInput', () => {
|
||||
]),
|
||||
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
|
||||
})
|
||||
|
||||
test('renders BlobResource as a readable placeholder instead of dropping it', () => {
|
||||
const result = promptToQueryInput([
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'file:///tmp/report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
blob: 'aGVsbG8=',
|
||||
},
|
||||
} as any,
|
||||
])
|
||||
expect(result).toContain('Embedded resource: file:///tmp/report.pdf')
|
||||
expect(result).toContain('application/pdf')
|
||||
expect(result).toContain('base64 blob')
|
||||
})
|
||||
|
||||
test('BlobResource without mimeType or uri falls back to defaults', () => {
|
||||
const result = promptToQueryInput([
|
||||
{
|
||||
type: 'resource',
|
||||
resource: { blob: 'aGVsbG8=' },
|
||||
} as any,
|
||||
])
|
||||
expect(result).toContain('(unknown uri)')
|
||||
expect(result).toContain('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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++]
|
||||
|
||||
@@ -25,6 +25,22 @@ import type {
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import type { SDKMessage } from '../../entrypoints/sdk/coreTypes.generated.js'
|
||||
import { toDisplayPath, markdownEscape } from './utils.js'
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* Normalises an emitted file path against the session cwd so that
|
||||
* ToolCallLocation.path / Diff.path values are always absolute, as required
|
||||
* by the ACP v1 spec (tool-calls.mdx:304-306; all file paths MUST be absolute).
|
||||
* If no cwd is available, the original value is returned unchanged.
|
||||
*/
|
||||
function toAbsolutePath(
|
||||
filePath: string | undefined,
|
||||
cwd?: string,
|
||||
): string | undefined {
|
||||
if (!filePath) return undefined
|
||||
if (!cwd) return filePath
|
||||
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath)
|
||||
}
|
||||
|
||||
// ── ToolUseCache ──────────────────────────────────────────────────
|
||||
|
||||
@@ -235,7 +251,8 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case 'Read': {
|
||||
const filePath = (input?.file_path as string | undefined) ?? 'File'
|
||||
const inputFilePath = input?.file_path as string | undefined
|
||||
const filePath = inputFilePath ?? 'File'
|
||||
const offset = input?.offset as number | undefined
|
||||
const limit = input?.limit as number | undefined
|
||||
let suffix = ''
|
||||
@@ -245,10 +262,13 @@ export function toolInfoFromToolUse(
|
||||
suffix = ` (from line ${offset})`
|
||||
}
|
||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : 'File'
|
||||
const absReadPath = toAbsolutePath(inputFilePath, cwd)
|
||||
return {
|
||||
title: `Read ${displayPath}${suffix}`,
|
||||
kind: 'read',
|
||||
locations: filePath ? [{ path: filePath, line: offset ?? 1 }] : [],
|
||||
locations: absReadPath
|
||||
? [{ path: absReadPath, line: offset ?? 1 }]
|
||||
: [],
|
||||
content: [],
|
||||
}
|
||||
}
|
||||
@@ -257,14 +277,15 @@ export function toolInfoFromToolUse(
|
||||
const filePath = (input?.file_path as string | undefined) ?? ''
|
||||
const content = (input?.content as string | undefined) ?? ''
|
||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined
|
||||
const absWritePath = toAbsolutePath(filePath, cwd)
|
||||
return {
|
||||
title: displayPath ? `Write ${displayPath}` : 'Write',
|
||||
kind: 'edit',
|
||||
content: filePath
|
||||
content: absWritePath
|
||||
? [
|
||||
{
|
||||
type: 'diff' as const,
|
||||
path: filePath,
|
||||
path: absWritePath,
|
||||
oldText: null,
|
||||
newText: content,
|
||||
},
|
||||
@@ -275,7 +296,7 @@ export function toolInfoFromToolUse(
|
||||
content: { type: 'text' as const, text: content },
|
||||
},
|
||||
],
|
||||
locations: filePath ? [{ path: filePath }] : [],
|
||||
locations: absWritePath ? [{ path: absWritePath }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,26 +305,28 @@ export function toolInfoFromToolUse(
|
||||
const oldString = (input?.old_string as string | undefined) ?? ''
|
||||
const newString = (input?.new_string as string | undefined) ?? ''
|
||||
const displayPath = filePath ? toDisplayPath(filePath, cwd) : undefined
|
||||
const absEditPath = toAbsolutePath(filePath, cwd)
|
||||
return {
|
||||
title: displayPath ? `Edit ${displayPath}` : 'Edit',
|
||||
kind: 'edit',
|
||||
content: filePath
|
||||
content: absEditPath
|
||||
? [
|
||||
{
|
||||
type: 'diff' as const,
|
||||
path: filePath,
|
||||
path: absEditPath,
|
||||
oldText: oldString || null,
|
||||
newText: newString,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: filePath ? [{ path: filePath }] : [],
|
||||
locations: absEditPath ? [{ path: absEditPath }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
case 'Glob': {
|
||||
const globPath = (input?.path as string | undefined) ?? ''
|
||||
const pattern = (input?.pattern as string | undefined) ?? ''
|
||||
const absGlobPath = toAbsolutePath(globPath, cwd)
|
||||
let label = 'Find'
|
||||
if (globPath) label += ` \`${globPath}\``
|
||||
if (pattern) label += ` \`${pattern}\``
|
||||
@@ -311,7 +334,7 @@ export function toolInfoFromToolUse(
|
||||
title: label,
|
||||
kind: 'search',
|
||||
content: [],
|
||||
locations: globPath ? [{ path: globPath }] : [],
|
||||
locations: absGlobPath ? [{ path: absGlobPath }] : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +622,37 @@ function toAcpContentBlock(
|
||||
: '[image: file reference]',
|
||||
)
|
||||
}
|
||||
case 'resource_link': {
|
||||
// ACP v1 ResourceLink requires name + uri. Name falls back to uri when
|
||||
// absent so the client always has a display label. mimeType is optional.
|
||||
const uri = content.uri as string | undefined
|
||||
const name =
|
||||
(content.name as string | undefined) ?? (uri as string | undefined)
|
||||
return {
|
||||
type: 'resource_link',
|
||||
uri: uri as string,
|
||||
name: name as string,
|
||||
mimeType: content.mimeType as string | undefined,
|
||||
}
|
||||
}
|
||||
case 'resource': {
|
||||
// ACP v1 EmbeddedResource wraps an optional TextResource / BlobResource
|
||||
// shape. Forward the standard fields the client knows how to render.
|
||||
const r = content.resource as Record<string, unknown> | undefined
|
||||
// Construct a TextResource or BlobResource payload depending on what is
|
||||
// present. Cast through unknown because not every source shape satisfies
|
||||
// the full union contract.
|
||||
const resourcePayload = {
|
||||
uri: (r?.uri as string | undefined) ?? '',
|
||||
mimeType: r?.mimeType as string | null | undefined,
|
||||
...(typeof r?.text === 'string' ? { text: r.text as string } : {}),
|
||||
...(typeof r?.blob === 'string' ? { blob: r.blob as string } : {}),
|
||||
}
|
||||
return {
|
||||
type: 'resource',
|
||||
resource: resourcePayload,
|
||||
} as unknown as ContentBlock
|
||||
}
|
||||
case 'tool_reference':
|
||||
return wrapText(`Tool: ${content.tool_name as string}`)
|
||||
case 'tool_search_tool_search_result': {
|
||||
@@ -671,8 +725,15 @@ interface EditToolResponse {
|
||||
* Builds diff ToolUpdate content from the structured Edit toolResponse.
|
||||
* Parses structuredPatch hunks (lines prefixed with -, +, space) into
|
||||
* oldText/newText diff pairs.
|
||||
*
|
||||
* The optional `cwd` is used to normalise the emitted path against the
|
||||
* session cwd so that Diff.path / ToolCallLocation.path are absolute as
|
||||
* required by the ACP v1 spec (audit §5.5).
|
||||
*/
|
||||
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
export function toolUpdateFromEditToolResponse(
|
||||
toolResponse: unknown,
|
||||
cwd?: string,
|
||||
): {
|
||||
content?: ToolCallContent[]
|
||||
locations?: ToolCallLocation[]
|
||||
} {
|
||||
@@ -680,6 +741,8 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
const response = toolResponse as EditToolResponse
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {}
|
||||
|
||||
const absPath = toAbsolutePath(response.filePath, cwd) ?? response.filePath
|
||||
|
||||
const content: ToolCallContent[] = []
|
||||
const locations: ToolCallLocation[] = []
|
||||
|
||||
@@ -697,10 +760,10 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: response.filePath, line: newStart })
|
||||
locations.push({ path: absPath, line: newStart })
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: response.filePath,
|
||||
path: absPath,
|
||||
oldText: oldText.join('\n') || null,
|
||||
newText: newText.join('\n'),
|
||||
})
|
||||
@@ -787,15 +850,10 @@ export async function forwardSessionUpdates(
|
||||
if (subtype === 'compact_boundary') {
|
||||
// Reset assistant usage tracking after compaction
|
||||
lastAssistantTotalUsage = 0
|
||||
// Send usage reset after compaction
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'usage_update',
|
||||
used: 0,
|
||||
size: lastContextWindowSize,
|
||||
},
|
||||
})
|
||||
// NOTE: usage_update is an UNSTABLE SessionUpdate discriminator (not in
|
||||
// stable v1 schema). Token/cost info has no v1-stable carrier; we drop
|
||||
// it from session/update and rely on PromptResponse._meta for clients
|
||||
// that need it (see audit §4.1).
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
@@ -830,28 +888,10 @@ export async function forwardSessionUpdates(
|
||||
}
|
||||
}
|
||||
|
||||
// Send usage_update — use lastAssistantTotalUsage if available
|
||||
// (more accurate than accumulatedUsage which may include background tasks)
|
||||
const usedTokens =
|
||||
lastAssistantTotalUsage ??
|
||||
accumulatedUsage.inputTokens +
|
||||
accumulatedUsage.outputTokens +
|
||||
accumulatedUsage.cachedReadTokens +
|
||||
accumulatedUsage.cachedWriteTokens
|
||||
|
||||
const totalCostUsd = msg.total_cost_usd
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'usage_update',
|
||||
used: usedTokens,
|
||||
size: lastContextWindowSize,
|
||||
cost:
|
||||
totalCostUsd != null
|
||||
? { amount: totalCostUsd, currency: 'USD' }
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
// NOTE: usage_update was removed — it is an UNSTABLE SessionUpdate
|
||||
// discriminator not present in the stable v1 schema (audit §4.1). Token
|
||||
// and cost information is returned via PromptResponse._meta.claudeCode.usage
|
||||
// instead.
|
||||
|
||||
// Determine stop reason
|
||||
const subtype = msg.subtype
|
||||
@@ -864,21 +904,24 @@ export async function forwardSessionUpdates(
|
||||
|
||||
switch (subtype) {
|
||||
case 'success': {
|
||||
const stopReasonStr = msg.stop_reason
|
||||
if (stopReasonStr === 'max_tokens') {
|
||||
stopReason = 'max_tokens'
|
||||
}
|
||||
if (isError) {
|
||||
// Report error as end_turn
|
||||
stopReason = 'end_turn'
|
||||
}
|
||||
// Map Anthropic stop_reason to ACP StopReason. Branches are mutually
|
||||
// exclusive so a max_tokens termination that is also flagged isError
|
||||
// no longer silently flips to end_turn (audit §3.3, §3.4). refusal
|
||||
// (safety refusal) is a first-class ACP stop reason that must surface
|
||||
// to the client instead of being misreported as end_turn.
|
||||
const r = msg.stop_reason
|
||||
if (r === 'max_tokens') stopReason = 'max_tokens'
|
||||
else if (r === 'refusal') stopReason = 'refusal'
|
||||
else stopReason = 'end_turn'
|
||||
if (isError) stopReason = 'end_turn'
|
||||
break
|
||||
}
|
||||
case 'error_during_execution': {
|
||||
// Mutually exclusive: max_tokens wins when reported, otherwise the
|
||||
// error path falls back to end_turn. Avoids the prior two-if
|
||||
// sequence that overwrote max_tokens with end_turn (audit §3.4).
|
||||
if (msg.stop_reason === 'max_tokens') {
|
||||
stopReason = 'max_tokens'
|
||||
} else if (isError) {
|
||||
stopReason = 'end_turn'
|
||||
} else {
|
||||
stopReason = 'end_turn'
|
||||
}
|
||||
@@ -1021,14 +1064,8 @@ export async function forwardSessionUpdates(
|
||||
// ── Compact boundary ───────────────────────────────────────
|
||||
case 'compact_boundary': {
|
||||
lastAssistantTotalUsage = 0
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'usage_update',
|
||||
used: 0,
|
||||
size: lastContextWindowSize,
|
||||
},
|
||||
})
|
||||
// NOTE: usage_update removed — UNSTABLE discriminator, not in v1 stable
|
||||
// schema (audit §4.1). Token info flows through PromptResponse._meta.
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
|
||||
@@ -37,6 +37,15 @@ export function createAcpCanUseTool(
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
/**
|
||||
* Invoked when the ACP client returns a `cancelled` permission outcome.
|
||||
* The Agent uses this to set the session-level cancelled flag and interrupt
|
||||
* the running query so session/prompt resolves with StopReason::Cancelled
|
||||
* (schema.json:629) instead of treating the cancellation as a plain deny.
|
||||
* Optional for backwards compatibility with callers that have not been
|
||||
* wired up yet.
|
||||
*/
|
||||
onPermissionCancelled?: () => void,
|
||||
): CanUseToolFn {
|
||||
return async (
|
||||
tool: ToolType,
|
||||
@@ -64,6 +73,7 @@ export function createAcpCanUseTool(
|
||||
cwd,
|
||||
onModeChange,
|
||||
isBypassModeAvailable,
|
||||
onPermissionCancelled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,6 +134,11 @@ export function createAcpCanUseTool(
|
||||
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
||||
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
||||
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
||||
{
|
||||
kind: 'reject_always',
|
||||
name: 'Always Reject',
|
||||
optionId: 'reject_always',
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
@@ -134,10 +149,15 @@ export function createAcpCanUseTool(
|
||||
})
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
// Per schema.json:629, a cancelled permission outcome means the prompt
|
||||
// turn was cancelled. Signal the session so prompt() resolves with
|
||||
// StopReason::Cancelled instead of treating this as a normal denial.
|
||||
onPermissionCancelled?.()
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Permission request cancelled by client',
|
||||
decisionReason: { type: 'mode', mode: 'default' },
|
||||
toolUseID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +201,7 @@ async function handleExitPlanMode(
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
onPermissionCancelled?: () => void,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{
|
||||
@@ -229,6 +250,8 @@ async function handleExitPlanMode(
|
||||
})
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
// Propagate cancellation so prompt() resolves with StopReason::Cancelled.
|
||||
onPermissionCancelled?.()
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Tool use aborted',
|
||||
@@ -279,6 +302,11 @@ async function handleExitPlanMode(
|
||||
|
||||
function checkTerminalOutput(clientCapabilities?: ClientCapabilities): boolean {
|
||||
if (!clientCapabilities) return false
|
||||
// Standard ACP v1 capability: ClientCapabilities.terminal (boolean).
|
||||
if (clientCapabilities.terminal === true) return true
|
||||
// Legacy Claude-Code clients advertised terminal support via _meta before
|
||||
// the standard `terminal` boolean existed. `_meta` is reserved per the spec,
|
||||
// but we keep this fallback for backward compatibility with older clients.
|
||||
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
|
||||
|
||||
@@ -20,6 +20,20 @@ export function promptToQueryInput(
|
||||
const resource = b.resource as Record<string, unknown> | undefined
|
||||
if (resource && typeof resource.text === 'string') {
|
||||
parts.push(resource.text)
|
||||
} else if (resource && typeof resource.blob === 'string') {
|
||||
// BlobResource (e.g. PDF/binary): query input is string-only, so emit a
|
||||
// readable placeholder instead of silently dropping the content. Ideally
|
||||
// this would be decoded and passed as a binary content block once the
|
||||
// query layer supports multimodal input.
|
||||
const mt =
|
||||
typeof resource.mimeType === 'string'
|
||||
? resource.mimeType
|
||||
: 'application/octet-stream'
|
||||
const uri =
|
||||
typeof resource.uri === 'string' ? resource.uri : '(unknown uri)'
|
||||
parts.push(
|
||||
`Embedded resource: ${uri} (${mt}, base64 blob, ${resource.blob.length} chars)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user