From cac23e62ccc506cd023868a41f87e61907b43457 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Fri, 19 Jun 2026 16:14:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20ACP=20usage=20?= =?UTF-8?q?=E4=BC=A0=E9=80=92=EF=BC=88usage=5Fupdate=20+=20PromptResponse.?= =?UTF-8?q?usage=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 撤销审计 §4.1 的原修复(删除 usage_update 以求严格 v1 stable 合规)。 现实中的所有主流 ACP 客户端(Zed、Cursor 等)实现的是 unstable spec, 删除 usage_update 后客户端 context 使用量一律显示 0/0,严重破坏 UX。 SDK 已包含 UsageUpdate 类型(sessionUpdate: 'usage_update',字段 used + size + 可选 cost)和 PromptResponse.usage 根字段(UNSTABLE 但被广泛实现),这是 context 使用量报告的唯一标准化载体,故选择优先保证 interop。 变更: - bridge/forwarding.ts: 收到 'result' 消息且 lastAssistantTotalUsage !== null 时发送 usage_update - used = 最近一条 assistant 消息的 input + output + cache_read + cache_creation token 总和(≈ 当前上下文占用) - size = lastContextWindowSize(默认 200000,通过 modelUsage prefix-match 解析) - compact_boundary 时不发(不知道压缩后实际占用;下一轮 result 会自然修正) - agent/promptFlow.ts: PromptResponse 根部添加 usage 字段,并镜像到 _meta.claudeCode.usage 供消费者任选读取路径 - 测试更新:bridge.test.ts 三个相关 test 改为断言 usage_update 被发出且 used/size 正确;agent.test.ts 改为断言 root usage 存在 - 审计文档 §4.1 标记为已撤销,添加决策回滚说明 验证:bun run precheck 全通过(typecheck + lint + 5851 tests) ACP service tests: 176 pass / 0 fail Co-Authored-By: glm-5.2 --- docs/acp-compliance-audit.md | 33 +++++++++--------- src/services/acp/__tests__/agent.test.ts | 21 +++++++----- src/services/acp/__tests__/bridge.test.ts | 34 +++++++++++++------ src/services/acp/agent/promptFlow.ts | 41 ++++++++++++----------- src/services/acp/bridge/forwarding.ts | 33 ++++++++++++------ 5 files changed, 97 insertions(+), 65 deletions(-) diff --git a/docs/acp-compliance-audit.md b/docs/acp-compliance-audit.md index d0dc8d1f5..ce4e667b0 100644 --- a/docs/acp-compliance-audit.md +++ b/docs/acp-compliance-audit.md @@ -25,8 +25,8 @@ | P0 | acp-link 传输层违反 JSON-RPC 2.0(维度 8) | 4 (2 critical + 2 major) | 高 | 是 | | P0 | promptCapabilities.image 声明与实现脱节(维度 1/3/7) | 3 (3 major, 重复根因) | 低 | 是 | | P0 | session/resume 重放历史违反 MUST NOT(维度 2) | 1 (1 critical) | 中 | 是 | -| P0 | session/update usage_update 非稳定 v1 判别器(维度 4) | 1 (1 critical) | 低 | 是 | -| P1 | PromptResponse.usage 非规范根字段(维度 3) | 1 (1 major) | 低 | 否 | +| P0 | session/update usage_update 非稳定 v1 判别器(维度 4) | 1 (1 critical) | 低 | ⚠️ **撤销**(interop 优先,见 §4.1) | +| P1 | PromptResponse.usage 非规范根字段(维度 3) | 1 (1 major) | 低 | ⚠️ **撤销**(同 §4.1 决策,根部 usage 与 _meta 镜像并存) | | P1 | refusal stop_reason 丢失(维度 3) | 1 (1 major) | 低 | 否 | | P1 | terminal 能力误用 `_meta` + 缺失标准生命周期(维度 5) | 2 (2 major) | 高 | 否 | | P1 | 权限 `cancelled` 未传播为 StopReason::Cancelled(维度 5) | 1 (1 major) | 中 | 否 | @@ -357,22 +357,21 @@ ## 4. session/update 通知形状(所有 update 变体)(维度 4) -### 4.1 [critical] usage_update 非稳定 v1 SessionUpdate 判别器 +### 4.1 [critical] usage_update 非稳定 v1 SessionUpdate 判别器 🔶 已撤销原修复 (2026-06-19) -- 位置: `src/services/acp/bridge.ts:794, 846, 1027` (forwardSessionUpdates, 'result' 和 'compact_boundary' 情况) +- 位置: `src/services/acp/bridge/forwarding.ts` (forwardSessionUpdates, 'result' 情况) - 规范要求: ACP v1 稳定版 schema schema.json:2942-3108 定义 SessionUpdate 为通过 propertyName `sessionUpdate` 进行 oneOf 判别,包含 10 个有效常量: `user_message_chunk`、`agent_message_chunk`、`agent_thought_chunk`、`tool_call`、`tool_call_update`、`plan`、`available_commands_update`、`current_mode_update`、`config_option_update`、`session_info_update`。`usage_update` 不在 v1 稳定版规范中。(Claude Code 捆绑的 SDK schema v0.19.0 第 5789 行将其标记为 "UNSTABLE——此功能尚未包含在规范中,随时可能被删除或更改"。) -- 当前实现: bridge.ts 在 3 处发送 `sessionUpdate: 'usage_update'` 以及非标准字段(`used`、`size`、`cost`): (1) 第 791-798 行,在 'system' 消息上检测到 compact_boundary 子类型后;(2) 第 843-854 行,在带有累计 token 计数和 total_cost_usd 的每条 'result' 消息上;(3) 第 1024-1031 行,在独立的 'compact_boundary' 消息情况下。这些仅因为捆绑的 SDK 的类型允许(其草案模式包含 usage_update)而通过类型检查;合规 v1 的客户端会拒绝这些通知。 -- 修复建议: 完全移除 usage_update 通知。token/cost 信息没有 v1 稳定版的 SessionUpdate 变体;它必须通过 `_meta` 承载或直接丢弃。最小合规修复方案: 删除 bridge.ts:791-798、843-854 和 1024-1031 处的三个 sessionUpdate 块,保留 token 聚合用于 PromptResponse 结果。如果客户端仍然需要利用率信号,请通过有效的变体发送: +- **决策回滚**: 原修复(2026-06-19 早期)完全移除了 `usage_update` 以追求严格 v1 stable 合规。但现实中所有主流 ACP 客户端(Zed、Cursor 等)实现的是 unstable spec,移除 `usage_update` 后客户端 context 使用量一律显示 `0/0`,严重破坏 UX。鉴于: + - SDK 已包含 `UsageUpdate` 类型(`sessionUpdate: 'usage_update'`, 字段 `used` + `size` + 可选 `cost`) + - `PromptResponse.usage` 也已由 SDK 在根部支持(UNSTABLE 但被广泛实现) + - 这是 context 使用量报告的**唯一**标准化载体 - ~~~ts - await conn.sessionUpdate({ - sessionId, - update: { - sessionUpdate: 'session_info_update', - _meta: { claudeCode: { usage: { used: usedTokens, size: lastContextWindowSize, cost: totalCostUsd != null ? { amount: totalCostUsd, currency: 'USD' } : undefined } } }, - }, - }) - ~~~ + 现行实现选择**优先保证 interop**: 在 'result' 消息后发送 `usage_update`,并在 PromptResponse 根部填充 `usage`。同时保留 `_meta.claudeCode.usage` 作为厂商扩展命名空间下的镜像,以便消费者任选读取路径。 +- 当前实现: `bridge/forwarding.ts` 在收到 'result' 消息且 `lastAssistantTotalUsage !== null` 时发出 `usage_update`: + - `used` = 最近一条 assistant 消息的 input + output + cache_read + cache_creation token 总和(≈ 当前上下文占用) + - `size` = `lastContextWindowSize`(默认 200000,通过 modelUsage prefix-match 解析) + - compact_boundary 时不发(不知道压缩后的实际占用;下一轮的 result 会自然修正) +- 同步调整: `agent/promptFlow.ts` 在 PromptResponse 根部添加 `usage: { totalTokens, inputTokens, outputTokens, thoughtTokens, cachedReadTokens, cachedWriteTokens }`,并镜像到 `_meta.claudeCode.usage`。 ### 4.2 [minor] 从未发出 tool_call in_progress 状态 ✅ 已修复 (2026-06-19) @@ -803,7 +802,7 @@ | setSessionMode | setSessionMode | stable | 保留(需补 current_mode_update 通知) | | setSessionConfigOption | setSessionConfigOption | stable | 保留(需补 value 校验) | | unstable_setSessionModel | unstable_setSessionModel | UNSTABLE | 保留 | -| session/update | sessionUpdate (notification) | stable | 保留(需删除 usage_update) | +| session/update | sessionUpdate (notification) | stable | 保留(usage_update 为 UNSTABLE 但为 interop 保留,见 §4.1) | ## 附录 B: 不修复项及理由 @@ -840,7 +839,7 @@ 3. **session/resume 去除重放**(§2.1)——中成本,需要将 resume 与 load 路径分离,引入 `replay` 标志。 -4. **删除 usage_update 通知**(§4.1)——低成本,删除 bridge.ts 三处 sessionUpdate 块。Token 信息改由 PromptResponse._meta.claudeCode.usage 承载。 +4. **~~删除 usage_update 通知~~(§4.1)** —— ⚠️ **已撤销**: 删除后客户端显示 0/0,严重破坏 interop。现保留 `usage_update` 发送(见 §4.1 决策回滚说明)。 ### P1 重要修复(非阻断但影响协议契约) diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 526362083..75081d7b8 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -576,7 +576,7 @@ describe('AcpAgent', () => { ).rejects.toThrow('unexpected') }) - test('returns usage under _meta.claudeCode.usage from forwardSessionUpdates', async () => { + test('returns usage at root and under _meta.claudeCode.usage from forwardSessionUpdates', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( @@ -594,13 +594,18 @@ describe('AcpAgent', () => { sessionId, prompt: [{ type: 'text', text: 'hello' }], } as any) - // 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) + // Per session-usage.mdx RFD: PromptResponse.usage is at the root + // (UNSTABLE in v1 but implemented by all major ACP clients). + const rootUsage = (res as any).usage + expect(rootUsage).toBeDefined() + expect(rootUsage.inputTokens).toBe(100) + expect(rootUsage.outputTokens).toBe(50) + expect(rootUsage.totalTokens).toBe(165) + // The same payload is mirrored under _meta.claudeCode.usage for + // consumers that read the vendor namespace. + const metaUsage = (res as any)._meta?.claudeCode?.usage + expect(metaUsage).toBeDefined() + expect(metaUsage.totalTokens).toBe(165) }) }) diff --git a/src/services/acp/__tests__/bridge.test.ts b/src/services/acp/__tests__/bridge.test.ts index 54d413dd8..b6da315e8 100644 --- a/src/services/acp/__tests__/bridge.test.ts +++ b/src/services/acp/__tests__/bridge.test.ts @@ -1249,10 +1249,10 @@ describe('forwardSessionUpdates', () => { }) }) - 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. + test('returns accumulated usage on result message without sending usage_update when no assistant message seen', async () => { + // Without a preceding assistant message we have no reliable "tokens + // currently in context" reading, so usage_update is skipped. Token totals + // are still aggregated for the PromptResponse return value. const conn = makeConn() const msgs: SDKMessage[] = [ { @@ -1290,9 +1290,10 @@ describe('forwardSessionUpdates', () => { expect(usageUpdate).toBeUndefined() }) - 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. + test('emits usage_update with exact modelUsage context window when assistant message precedes result', async () => { + // Per session-usage.mdx RFD: after a turn, emit usage_update so clients can + // display context window utilization. The size comes from modelUsage keyed + // by exact model id match. const conn = makeConn() const msgs: SDKMessage[] = [ { @@ -1340,10 +1341,18 @@ describe('forwardSessionUpdates', () => { 'sessionUpdate' ] === 'usage_update', ) - expect(usageUpdate).toBeUndefined() + expect(usageUpdate).toBeDefined() + const update = ( + usageUpdate![0] as { update: { used: number; size: number } } + ).update + // used = lastAssistantTotalUsage = 100 + 50 + 10 + 5 = 165 + expect(update.used).toBe(165) + expect(update.size).toBe(1000000) }) - test('prefix-matches modelUsage without emitting usage_update', async () => { + test('emits usage_update with prefix-matched modelUsage context window', async () => { + // Model id 'claude-opus-4-6-20250514' prefix-matches the modelUsage key + // 'claude-opus-4-6' to resolve contextWindow = 2000000. const conn = makeConn() const msgs: SDKMessage[] = [ { @@ -1391,7 +1400,12 @@ describe('forwardSessionUpdates', () => { 'sessionUpdate' ] === 'usage_update', ) - expect(usageUpdate).toBeUndefined() + expect(usageUpdate).toBeDefined() + const update = ( + usageUpdate![0] as { update: { used: number; size: number } } + ).update + expect(update.used).toBe(150) + expect(update.size).toBe(2000000) }) test('maps refusal stop_reason to ACP refusal stop reason', async () => { diff --git a/src/services/acp/agent/promptFlow.ts b/src/services/acp/agent/promptFlow.ts index 29bdc847f..981ebfbdb 100644 --- a/src/services/acp/agent/promptFlow.ts +++ b/src/services/acp/agent/promptFlow.ts @@ -109,31 +109,34 @@ async function prompt( // channel. The title is derived from the first user prompt. await emitSessionInfoUpdate(this, 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. + // Per session-usage.mdx RFD and the bundled SDK schema, PromptResponse + // carries an optional `usage` field at the root with cumulative token + // totals for the session. The field is UNSTABLE in v1 but is implemented + // by all major ACP clients. We additionally mirror the same payload into + // `_meta.claudeCode.usage` for consumers that read the vendor namespace. + // thoughtTokens are reported as 0 until the bridge tracks them, but are + // included in totalTokens so totals match the sum of components. if (usage) { const thoughtTokens = 0 + const usagePayload = { + 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, + usage: usagePayload, _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, - }, + usage: usagePayload, }, }, } diff --git a/src/services/acp/bridge/forwarding.ts b/src/services/acp/bridge/forwarding.ts index 866e9a3e5..c3c2b2c41 100644 --- a/src/services/acp/bridge/forwarding.ts +++ b/src/services/acp/bridge/forwarding.ts @@ -92,12 +92,10 @@ export async function forwardSessionUpdates( const subtype = msg.subtype if (subtype === 'compact_boundary') { - // Reset assistant usage tracking after compaction + // Reset assistant usage tracking after compaction. We don't emit a + // usage_update here because we don't know the post-compaction context + // size — the next prompt's result will carry the corrected value. lastAssistantTotalUsage = 0 - // 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: { @@ -132,10 +130,23 @@ export async function forwardSessionUpdates( } } - // 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. + // Per session-usage.mdx RFD: emit usage_update so clients can display + // context window utilization (e.g. "53K / 200K"). Although usage_update + // is currently UNSTABLE in the v1 schema, it is the only standardized + // carrier for context-window state and is implemented by all major ACP + // clients (Zed, Cursor, etc.). Strict v1-stable compliance broke this + // UX (clients showed 0/0), so we emit it whenever we have usage data. + // See audit §4.1 for the prior strict-compliance rationale and revert. + if (lastAssistantTotalUsage !== null) { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'usage_update', + used: lastAssistantTotalUsage, + size: lastContextWindowSize, + }, + }) + } // Determine stop reason const subtype = msg.subtype @@ -307,9 +318,9 @@ export async function forwardSessionUpdates( // ── Compact boundary ─────────────────────────────────────── case 'compact_boundary': { + // Don't emit usage_update here — we don't know the post-compaction + // context size. The next prompt's result will carry the corrected value. lastAssistantTotalUsage = 0 - // 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: {