mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
fix: 恢复 ACP usage 传递(usage_update + PromptResponse.usage)
撤销审计 §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 <zai-org@claude-code-best.win>
This commit is contained in:
@@ -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<typeof mock>).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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user