From 48329facc3f4752e2e6d6e4db648eb1761fd1784 Mon Sep 17 00:00:00 2001 From: Galaxy Roaming <45086632+apple-ouyang@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:06:41 +0800 Subject: [PATCH] fix(acp): surface context-window usage from the ACP usage_update notification (#4848) * fix(acp): show context-window usage for generic ACP agents Generic ACP providers never populated Paseo's context meter: the standard ACP `usage_update` notification was discarded by `handleUsageUpdate` (`void update`), while the meter only reads `lastUsage.contextWindowMaxTokens` / `contextWindowUsedTokens`, which `usage_updated` stream events write. Forward `size` and `used` on a `usage_updated` event for the active turn, guarded by the same validity rules the meter applies so a provider cannot push a window the meter rejects, and preserve the turn usage already recorded. Verified with vitest acp-agent.test.ts (106 passed), the server typecheck, and an isolated daemon run where a dsh agent reported `lastUsage={contextWindowMaxTokens:1000000,contextWindowUsedTokens:13623}`. * fix(acp): emit context-window usage only with both meter fields Require a valid size and used together so a partial usage_update cannot wipe the composer meter. Drive the tests through sessionUpdate instead of the private translator. --- .../server/agent/providers/acp-agent.test.ts | 40 +++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 23 ++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 9d106a2c1a..f847745d69 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -825,6 +825,46 @@ describe("mapACPUsage", () => { }); }); +describe("ACP context-window usage", () => { + async function emitUsageUpdate(update: { + used: number; + size: number; + }): Promise<{ events: unknown[] }> { + const session = createSessionWithConfig({ provider: "dsh" }); + asInternals(session).sessionId = "session-1"; + const events: unknown[] = []; + session.subscribe((event) => { + if (event.type === "usage_updated") events.push(event); + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { sessionUpdate: "usage_update", used: update.used, size: update.size }, + }); + return { events }; + } + + test("forwards usage_update as context-window usage state", async () => { + const { events } = await emitUsageUpdate({ used: 13_759, size: 1_000_000 }); + + expect(events).toEqual([ + { + type: "usage_updated", + provider: "dsh", + usage: { contextWindowMaxTokens: 1_000_000, contextWindowUsedTokens: 13_759 }, + }, + ]); + }); + + test("emits nothing when size and used cannot both drive a meter", async () => { + await expect( + emitUsageUpdate({ used: -1, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + await expect( + emitUsageUpdate({ used: 13_759, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + }); +}); + describe("deriveModesFromACP", () => { test("prefers explicit ACP mode state", () => { const result = deriveModesFromACP( diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 524ede92dd..de8055c787 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -3178,8 +3178,29 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + // ACP reports context occupancy out of band from the prompt response, so the + // composer's context meter cannot read it from `turn_completed`. Forward it as + // the same `usage_updated` snapshot the native adapters emit, preserving the + // turn usage this session already recorded. The guards match the meter's own + // validity rules, so a provider cannot push a window the meter would reject. private handleUsageUpdate(update: UsageUpdate): void { - void update; + const contextWindowMaxTokens = + Number.isFinite(update.size) && update.size > 0 ? update.size : undefined; + const contextWindowUsedTokens = + Number.isFinite(update.used) && update.used >= 0 ? update.used : undefined; + if (contextWindowMaxTokens === undefined || contextWindowUsedTokens === undefined) { + return; + } + this.pushEvent({ + type: "usage_updated", + provider: this.provider, + usage: { + ...this.currentTurnUsage, + contextWindowMaxTokens, + contextWindowUsedTokens, + }, + turnId: this.activeForegroundTurnId ?? undefined, + }); } private handlePromptResponse(response: PromptResponse, turnId: string): void {