From 44a2049415ea6d60e5db817dc3cddf727e487d78 Mon Sep 17 00:00:00 2001 From: guoyuchen <2273345925@qq.com> Date: Thu, 30 Jul 2026 18:59:33 +0800 Subject: [PATCH 1/5] feat(acp-adapter): push context window usage via ACP usage_update notification Map agent.status.updated events to the ACP protocol UsageUpdate type (size/used fields), dispatched as fire-and-forget session notifications so ACP clients (Zed, JetBrains) can render a real-time context consumption indicator. - events-map.ts: add contextUsageToUsageUpdate() builder - session.ts: subscribe to agent.status.updated in runTurnBody() - index.ts: export new function - plan-and-commands.test.ts: 7 tests (4 unit + 3 e2e) --- packages/acp-adapter/src/events-map.ts | 30 ++++ packages/acp-adapter/src/index.ts | 1 + packages/acp-adapter/src/session.ts | 18 ++ .../test/plan-and-commands.test.ts | 155 ++++++++++++++++++ 4 files changed, 204 insertions(+) diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts index 0448f2eb9c..f241172669 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -525,3 +525,33 @@ export function configOptionUpdateNotification( }, }; } + +/** + * Build an ACP `usage_update` session notification from contextual + * token usage data (e.g. an `agent.status.updated` SDK event). + * + * Wire shape ({@link https://agentclientprotocol.com/ protocol} UNSTABLE / + * `@experimental`): + * - `size` = total context window capacity (maxContextTokens) + * - `used` = tokens currently in context (contextTokens) + * - `cost` = cumulative cost snapshot (unset for now — can be wired + * later from the session cost tracker). + * + * Returns `null` when either value is missing so callers can silently + * skip rather than pushing a degenerate update. + */ +export function contextUsageToUsageUpdate( + sessionId: string, + contextTokens: number | undefined, + maxContextTokens: number | undefined, +): SessionNotification | null { + if (contextTokens === undefined || maxContextTokens === undefined) return null; + return { + sessionId, + update: { + sessionUpdate: 'usage_update', + size: maxContextTokens, + used: contextTokens, + }, + }; +} diff --git a/packages/acp-adapter/src/index.ts b/packages/acp-adapter/src/index.ts index 4b0af447f9..5dbf6e9174 100644 --- a/packages/acp-adapter/src/index.ts +++ b/packages/acp-adapter/src/index.ts @@ -19,6 +19,7 @@ export { export { acpToolCallId, assistantDeltaToSessionUpdate, + contextUsageToUsageUpdate, inferToolKind, stringifyArgs, thinkingDeltaToSessionUpdate, diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..eb29b145f8 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -44,6 +44,7 @@ import { acpToolCallId, assistantDeltaToSessionUpdate, configOptionUpdateNotification, + contextUsageToUsageUpdate, planFromDisplayBlock, stringifyArgs, thinkingDeltaToSessionUpdate, @@ -1097,6 +1098,23 @@ export class AcpSession { }); return; } + if (event.type === 'agent.status.updated') { + if (!isFromMainAgent(event)) return; + const update = contextUsageToUsageUpdate( + sessionId, + event.contextTokens, + event.maxContextTokens, + ); + if (update !== null) { + conn.sessionUpdate(update).catch((err) => { + log.warn('acp: failed to push usage_update', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + return; + } if (event.type === 'tool.call.started') { if (!isFromMainAgent(event)) return; // Seed the accumulator with the **stringified initial args**. diff --git a/packages/acp-adapter/test/plan-and-commands.test.ts b/packages/acp-adapter/test/plan-and-commands.test.ts index cc5463d736..0ea3f51cbd 100644 --- a/packages/acp-adapter/test/plan-and-commands.test.ts +++ b/packages/acp-adapter/test/plan-and-commands.test.ts @@ -20,6 +20,7 @@ import { AcpServer } from '../src/server'; import { AUTHED_STATUS } from './_helpers/harness-stubs'; import { availableCommandsUpdateNotification, + contextUsageToUsageUpdate, planFromDisplayBlock, todoListToSessionUpdate, } from '../src/events-map'; @@ -359,3 +360,157 @@ describe('Phase 9.3 e2e · todo_list display block becomes a plan session_update expect(planUpdates).toHaveLength(0); }); }); + +describe('contextUsageToUsageUpdate', () => { + it('builds a usage_update notification with size and used', () => { + const note = contextUsageToUsageUpdate('sess-u', 12345, 200000); + expect(note).not.toBeNull(); + expect(note?.sessionId).toBe('sess-u'); + expect(note?.update).toEqual({ + sessionUpdate: 'usage_update', + size: 200000, + used: 12345, + }); + }); + + it('returns null when contextTokens is undefined', () => { + expect(contextUsageToUsageUpdate('sess-u', undefined, 200000)).toBeNull(); + }); + + it('returns null when maxContextTokens is undefined', () => { + expect(contextUsageToUsageUpdate('sess-u', 12345, undefined)).toBeNull(); + }); + + it('returns null when both values are undefined', () => { + expect(contextUsageToUsageUpdate('sess-u', undefined, undefined)).toBeNull(); + }); +}); + +describe('e2e · agent.status.updated emits usage_update', () => { + it('forwards agent.status.updated events as usage_update session notifications', async () => { + const sessionId = 'sess-usage'; + const turnId = 1; + const session = makeScriptedSession(sessionId, [ + { + type: 'agent.status.updated', + sessionId, + agentId: 'main', + contextTokens: 5000, + maxContextTokens: 100000, + contextUsage: 0.05, + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'completed', + } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('hello')] }); + await flushNdjson(); + + const usageUpdates = collecting.updates.filter( + (n) => + (n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update', + ); + expect(usageUpdates).toHaveLength(1); + expect(usageUpdates[0]?.sessionId).toBe(sessionId); + expect(usageUpdates[0]?.update).toEqual({ + sessionUpdate: 'usage_update', + size: 100000, + used: 5000, + }); + }); + + it('does not emit usage_update for sub-agent status events', async () => { + const sessionId = 'sess-usage-sub'; + const turnId = 1; + const session = makeScriptedSession(sessionId, [ + { + type: 'agent.status.updated', + sessionId, + agentId: 'sub-1', + contextTokens: 100, + maxContextTokens: 50000, + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'completed', + } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('hello')] }); + await flushNdjson(); + + const usageUpdates = collecting.updates.filter( + (n) => + (n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update', + ); + expect(usageUpdates).toHaveLength(0); + }); + + it('skips status events with missing contextTokens', async () => { + const sessionId = 'sess-usage-skip'; + const turnId = 1; + const session = makeScriptedSession(sessionId, [ + { + type: 'agent.status.updated', + sessionId, + agentId: 'main', + maxContextTokens: 100000, + // contextTokens deliberately omitted + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'completed', + } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('hello')] }); + await flushNdjson(); + + const usageUpdates = collecting.updates.filter( + (n) => + (n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update', + ); + expect(usageUpdates).toHaveLength(0); + }); +}); From 6072663eff95e392c18f6967b23ef2a9fb28bcd6 Mon Sep 17 00:00:00 2001 From: guoyuchen <2273345925@qq.com> Date: Thu, 30 Jul 2026 19:11:20 +0800 Subject: [PATCH 2/5] chore: add changeset for acp context window usage display --- .changeset/acp-context-window-usage.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/acp-context-window-usage.md diff --git a/.changeset/acp-context-window-usage.md b/.changeset/acp-context-window-usage.md new file mode 100644 index 0000000000..f7c768dc98 --- /dev/null +++ b/.changeset/acp-context-window-usage.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +ACP agents now push live context window usage via the `usage_update` session notification so compatible clients (Zed, JetBrains) can render a real-time token consumption indicator. From 6c903df6b4781844096a60b3f0cc1a311269cf7e Mon Sep 17 00:00:00 2001 From: guoyuchen <2273345925@qq.com> Date: Fri, 31 Jul 2026 21:43:18 +0800 Subject: [PATCH 3/5] fix(acp-adapter): push usage_update after /compact completes After compaction, the core updates context token counts via emitStatusUpdated(), but the agent.status.updated handler only runs inside runTurnBody(). Push a one-shot usage_update after compaction completes so the client reflects the compacted context size immediately instead of waiting for the next normal prompt. Co-Authored-By: Claude --- packages/acp-adapter/src/session.ts | 19 +++++ .../acp-adapter/test/session-slash.test.ts | 79 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index eb29b145f8..bb54a8172a 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -965,6 +965,25 @@ export class AcpSession { const outcome = await completion; if (outcome.kind === 'completed') { await this.emitLocalCommandMessage(formatCompactionCompleted(outcome.result)); + // After compaction, the core updates context token counts via + // emitStatusUpdated(), but the agent.status.updated handler is + // only registered in runTurnBody(). Push a one-shot usage_update + // so the client reflects the compacted context size immediately + // rather than waiting for the next normal prompt. + const status = await this.session.getStatus(); + const update = contextUsageToUsageUpdate( + this.id, + status.contextTokens, + status.maxContextTokens, + ); + if (update !== null) { + this.conn.sessionUpdate(update).catch((err) => { + log.warn('acp: failed to push usage_update after compaction', { + sessionId: this.id, + error: err instanceof Error ? err.message : String(err), + }); + }); + } } else { await this.emitLocalCommandMessage('Compaction cancelled.'); } diff --git a/packages/acp-adapter/test/session-slash.test.ts b/packages/acp-adapter/test/session-slash.test.ts index f13dea0b1d..15c8c2ae2e 100644 --- a/packages/acp-adapter/test/session-slash.test.ts +++ b/packages/acp-adapter/test/session-slash.test.ts @@ -140,6 +140,10 @@ async function waitForAvailableCommands( throw new Error('available_commands_update never arrived'); } +async function flushNdjson(): Promise { + await new Promise((resolve) => setTimeout(resolve, 25)); +} + describe('AcpSession slash routing', () => { it('routes `/skill:foo bar` to Session.activateSkill (not Session.prompt)', async () => { const sessionId = 'sess-slash-A'; @@ -371,4 +375,79 @@ describe('AcpSession slash routing', () => { expect(text).toContain('Model: mock-model'); expect(text).toContain('Context: 1,234 / 200,000 (0.6%)'); }); + + it('pushes usage_update notification after /compact completes', async () => { + const sessionId = 'sess-slash-compact-usage'; + // We need a fake session with controlled listeners so compact() can + // emit compaction lifecycle events through the adapter's onEvent + // subscription (which is registered before compact() is called). + const listeners = new Set<(event: Event) => void>(); + const session = { + id: sessionId, + prompt: async () => undefined, + cancel: async () => undefined, + activateSkill: async () => undefined, + listSkills: async () => [] as const, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { listeners.delete(fn); }; + }, + compact: async () => { + // The adapter subscribes to onEvent (line 926) before calling + // compact() (line 960), so listeners already contains the + // adapter's handler. Emit the compaction lifecycle. + // Use setTimeout to yield so the adapter's subscription is active. + await new Promise((resolve) => { + setTimeout(() => { + for (const fn of listeners) { + fn({ type: 'compaction.started', sessionId, agentId: 'main' } as Event); + fn({ + type: 'compaction.completed', + sessionId, + agentId: 'main', + result: { tokensBefore: 50000, tokensAfter: 5000, compactedCount: 45, keptUserMessageCount: 1, droppedCount: 0 }, + } as Event); + } + resolve(); + }, 0); + }); + }, + getStatus: async () => ({ + model: 'mock-model', + thinkingEffort: 'low', + permission: 'ask', + planMode: false, + contextTokens: 5000, + maxContextTokens: 200000, + contextUsage: 0.025, + }), + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await waitForAvailableCommands(collecting); + const beforeCount = collecting.updates.length; + + await client.prompt({ sessionId, prompt: [textBlock('/compact')] }); + await flushNdjson(); + + const usageUpdates = collecting.updates.slice(beforeCount).filter( + (n) => + (n.update as { sessionUpdate: string }).sessionUpdate === 'usage_update', + ); + expect(usageUpdates).toHaveLength(1); + expect(usageUpdates[0]?.update).toEqual({ + sessionUpdate: 'usage_update', + size: 200000, + used: 5000, + }); + }); }); From db9db41936d7ed7f094254ba556a140499f4a1f8 Mon Sep 17 00:00:00 2001 From: guoyuchen <2273345925@qq.com> Date: Fri, 31 Jul 2026 22:44:28 +0800 Subject: [PATCH 4/5] fix(acp-adapter): wrap post-compaction usage refresh in try-catch. --- packages/acp-adapter/src/session.ts | 31 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index bb54a8172a..3a82ebc9a7 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -970,18 +970,27 @@ export class AcpSession { // only registered in runTurnBody(). Push a one-shot usage_update // so the client reflects the compacted context size immediately // rather than waiting for the next normal prompt. - const status = await this.session.getStatus(); - const update = contextUsageToUsageUpdate( - this.id, - status.contextTokens, - status.maxContextTokens, - ); - if (update !== null) { - this.conn.sessionUpdate(update).catch((err) => { - log.warn('acp: failed to push usage_update after compaction', { - sessionId: this.id, - error: err instanceof Error ? err.message : String(err), + // Best-effort only — a failed status refresh must not turn a + // successful compaction into a failure. + try { + const status = await this.session.getStatus(); + const update = contextUsageToUsageUpdate( + this.id, + status.contextTokens, + status.maxContextTokens, + ); + if (update !== null) { + this.conn.sessionUpdate(update).catch((err) => { + log.warn('acp: failed to push usage_update after compaction', { + sessionId: this.id, + error: err instanceof Error ? err.message : String(err), + }); }); + } + } catch (err) { + log.warn('acp: failed to refresh usage after compaction', { + sessionId: this.id, + error: err instanceof Error ? err.message : String(err), }); } } else { From 2f2a3ad8033e3991d48e3721ec383f27427ceb0d Mon Sep 17 00:00:00 2001 From: guoyuchen <2273345925@qq.com> Date: Fri, 31 Jul 2026 23:03:07 +0800 Subject: [PATCH 5/5] fix(acp-adapter): add maxContextTokens guard and push usage after session setup - Guard maxContextTokens <= 0 in contextUsageToUsageUpdate to prevent degenerate { size: 0 } updates, matching existing guards in formatStatusReport and emitStatusUpdated. - Add AcpSession.pushContextUsage() (best-effort, typeof-guarded) and call it after newSession, loadSession replay, and resumeSession so clients see context usage immediately instead of waiting for the next active turn. - Update session-new test to expect two getStatus calls (thinking effort resolution + pushContextUsage). --- packages/acp-adapter/src/events-map.ts | 1 + packages/acp-adapter/src/server.ts | 3 ++ packages/acp-adapter/src/session.ts | 35 +++++++++++++++++++ .../test/plan-and-commands.test.ts | 5 +++ packages/acp-adapter/test/session-new.test.ts | 4 ++- 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts index f241172669..85648cc688 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -546,6 +546,7 @@ export function contextUsageToUsageUpdate( maxContextTokens: number | undefined, ): SessionNotification | null { if (contextTokens === undefined || maxContextTokens === undefined) return null; + if (maxContextTokens <= 0) return null; return { sessionId, update: { diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a9ef407bee..4a0f992231 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -416,6 +416,7 @@ export class AcpServer implements Agent { DEFAULT_MODE_ID, ); this.scheduleAvailableCommandsUpdate(session.id); + void acpSession.pushContextUsage(); return { sessionId: session.id, configOptions, @@ -459,6 +460,7 @@ export class AcpServer implements Agent { // `resumeSession`, which intentionally omits this step. await acpSession.replayHistory(); this.scheduleAvailableCommandsUpdate(session.id); + void acpSession.pushContextUsage(); return { configOptions }; } @@ -489,6 +491,7 @@ export class AcpServer implements Agent { mode: 'resume', }); this.scheduleAvailableCommandsUpdate(session.id); + void this.sessions.get(session.id)?.pushContextUsage(); return { configOptions }; } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 3a82ebc9a7..018d29d9d1 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -551,6 +551,41 @@ export class AcpSession { } } + /** + * Push a one-shot `usage_update` snapshot derived from the SDK + * `getStatus()` call. Best-effort — failures are logged and never + * surface to the client as errors. + * + * Called on session creation and after history replay so the client + * sees context usage immediately instead of waiting for the next + * `agent.status.updated` event (which only fires inside an active + * turn, via `runTurnBody`). + */ + async pushContextUsage(): Promise { + if (typeof this.session.getStatus !== 'function') return; + try { + const status = await this.session.getStatus(); + const update = contextUsageToUsageUpdate( + this.id, + status.contextTokens, + status.maxContextTokens, + ); + if (update !== null) { + this.conn.sessionUpdate(update).catch((err) => { + log.warn('acp: failed to push usage_update', { + sessionId: this.id, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + } catch (err) { + log.warn('acp: failed to push usage_update', { + sessionId: this.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + /** * Replay the underlying SDK session's persisted history as a stream * of ACP `session/update` notifications. diff --git a/packages/acp-adapter/test/plan-and-commands.test.ts b/packages/acp-adapter/test/plan-and-commands.test.ts index 0ea3f51cbd..a5e119d103 100644 --- a/packages/acp-adapter/test/plan-and-commands.test.ts +++ b/packages/acp-adapter/test/plan-and-commands.test.ts @@ -384,6 +384,11 @@ describe('contextUsageToUsageUpdate', () => { it('returns null when both values are undefined', () => { expect(contextUsageToUsageUpdate('sess-u', undefined, undefined)).toBeNull(); }); + + it('returns null when maxContextTokens is zero or negative', () => { + expect(contextUsageToUsageUpdate('sess-u', 100, 0)).toBeNull(); + expect(contextUsageToUsageUpdate('sess-u', 100, -1)).toBeNull(); + }); }); describe('e2e · agent.status.updated emits usage_update', () => { diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index 61220b462b..2a32bf467a 100644 --- a/packages/acp-adapter/test/session-new.test.ts +++ b/packages/acp-adapter/test/session-new.test.ts @@ -266,7 +266,9 @@ describe('AcpServer session/new', () => { const response = await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); - expect(fakeSession.getStatus).toHaveBeenCalledOnce(); + // Called twice: once by resolveCurrentThinkingEffort, once by + // pushContextUsage (best-effort usage_update after session creation). + expect(fakeSession.getStatus).toHaveBeenCalledTimes(2); const thinking = response.configOptions?.find((option) => option.id === 'thinking'); if (thinking?.type !== 'select') throw new Error('thinking option must be a select'); expect(thinking.currentValue).toBe(expected);