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.
This commit is contained in:
Galaxy Roaming
2026-10-04 18:06:41 +02:00
committed by GitHub
parent 97083dd736
commit 48329facc3
2 changed files with 62 additions and 1 deletions
@@ -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<ACPSessionInternals>(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(
@@ -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 {