From 0c022caa6e47c1e1d1cff9209e82505b0e3d64c3 Mon Sep 17 00:00:00 2001 From: swear01 Date: Sun, 31 May 2026 15:43:52 +0000 Subject: [PATCH] fix(acp): drop mid-stream usage emit; OpenCode only sends usage_update at end-of-turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #756 added a mid-turn emit in captureUsageUpdate to surface live context usage via the web status bar. Testing against OpenCode 1.15.11 on a real session shows OpenCode emits a single usage_update per turn, within ~1ms of session/prompt resolving — never during streaming. That makes the mid-turn path dead code for OpenCode (and for any other ACP agent that follows the same pattern). It also persists a useless inputTokens:0/outputTokens:0 token_count message that gets immediately overwritten by the finalize emit, churning the session history. Drop the mid-stream emit and the activeOnUpdate plumbing it required. Keep the finalize fallback for agents that don't return a usage block on session/prompt (slash-handled turns, errored turns). The persistent "live" counter requires the agent to emit usage_update during streaming; filed upstream against anomalyco/opencode. Refs #750 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../agent/backends/acp/AcpSdkBackend.test.ts | 70 ++----------------- cli/src/agent/backends/acp/AcpSdkBackend.ts | 36 ++-------- 2 files changed, 13 insertions(+), 93 deletions(-) diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 59a0b45e0..8d70f0ccc 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -645,59 +645,6 @@ describe('AcpSdkBackend', () => { expect(turnCompleteIdx).toBeGreaterThan(stragglerIdx); }); - it('emits a context-only usage mid-turn so the status bar updates live', async () => { - backendStatics.UPDATE_QUIET_PERIOD_MS = 25; - backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; - backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1; - backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 50; - backendStatics.LATE_FLUSH_INTERVAL_MS = 5; - backendStatics.LATE_FLUSH_QUIET_PERIOD_MS = 10; - backendStatics.LATE_FLUSH_WINDOW_MS = 50; - - const backend = new AcpSdkBackend({ command: 'opencode' }); - const backendInternal = backend as unknown as { - transport: { - sendRequest: (...args: unknown[]) => Promise; - close: () => Promise; - } | null; - handleSessionUpdate: (params: unknown) => void; - }; - - const messages: AgentMessage[] = []; - backendInternal.transport = { - sendRequest: async () => { - backendInternal.handleSessionUpdate({ - sessionId: 'session-1', - update: { sessionUpdate: 'usage_update', used: 1_000, size: 200_000 } - }); - backendInternal.handleSessionUpdate({ - sessionId: 'session-1', - update: { sessionUpdate: 'usage_update', used: 1_000, size: 200_000 } - }); - backendInternal.handleSessionUpdate({ - sessionId: 'session-1', - update: { sessionUpdate: 'usage_update', used: 2_500, size: 200_000 } - }); - await sleep(5); - return { - stopReason: 'end_turn', - usage: { inputTokens: 100, outputTokens: 50 } - }; - }, - close: async () => {} - }; - - await backend.prompt('session-1', [{ type: 'text', text: 'hello' }], (m) => messages.push(m)); - - const usageMessages = messages.filter((m): m is Extract => m.type === 'usage'); - // Two mid-turn ticks (deduped second 1_000) + one final emit with the - // prompt-level input/output totals. - expect(usageMessages.length).toBe(3); - expect(usageMessages[0]).toMatchObject({ inputTokens: 0, outputTokens: 0, contextTokens: 1_000, contextWindow: 200_000 }); - expect(usageMessages[1]).toMatchObject({ inputTokens: 0, outputTokens: 0, contextTokens: 2_500, contextWindow: 200_000 }); - expect(usageMessages[2]).toMatchObject({ inputTokens: 100, outputTokens: 50, contextTokens: 2_500, contextWindow: 200_000 }); - }); - it('emits a context-only usage on finalize when the prompt response carries no usage', async () => { backendStatics.UPDATE_QUIET_PERIOD_MS = 25; backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; @@ -734,15 +681,12 @@ describe('AcpSdkBackend', () => { await backend.prompt('session-1', [{ type: 'text', text: 'hi' }], (m) => messages.push(m)); const usageMessages = messages.filter((m): m is Extract => m.type === 'usage'); - // One mid-turn emit and one finalize fallback — both context-only. - expect(usageMessages.length).toBe(2); - for (const usage of usageMessages) { - expect(usage).toMatchObject({ - inputTokens: 0, - outputTokens: 0, - contextTokens: 4_200, - contextWindow: 200_000 - }); - } + expect(usageMessages.length).toBe(1); + expect(usageMessages[0]).toMatchObject({ + inputTokens: 0, + outputTokens: 0, + contextTokens: 4_200, + contextWindow: 200_000 + }); }); }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index c7b0e41a4..8426e776e 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -55,7 +55,6 @@ export class AcpSdkBackend implements AgentBackend { private responseCompleteResolvers: Array<() => void> = []; private lastSessionUpdateAt = 0; private latestUsageUpdate: AcpUsageUpdate | null = null; - private activeOnUpdate: ((msg: AgentMessage) => void) | null = null; /** Retry configuration for ACP initialization */ private static readonly INIT_RETRY_OPTIONS = { @@ -284,7 +283,6 @@ export class AcpSdkBackend implements AgentBackend { ); this.messageHandler?.drainBuffers(); this.messageHandler = new AcpMessageHandler(onUpdate); - this.activeOnUpdate = onUpdate; this.isProcessingMessage = true; this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; @@ -345,7 +343,6 @@ export class AcpSdkBackend implements AgentBackend { onUpdate({ type: 'turn_complete', stopReason }); } } finally { - this.activeOnUpdate = null; this.isProcessingMessage = false; this.notifyResponseComplete(); } @@ -425,7 +422,6 @@ export class AcpSdkBackend implements AgentBackend { if (!this.transport) return; this.messageHandler?.drainBuffers(); this.messageHandler = null; - this.activeOnUpdate = null; this.activeSessionId = null; this.isProcessingMessage = false; this.sessionModelsMetadata.clear(); @@ -450,32 +446,12 @@ export class AcpSdkBackend implements AgentBackend { if (!isObject(update)) return; if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.usageUpdate) return; - const contextTokens = this.asFiniteNumber(update.used) ?? undefined; - const contextWindow = this.asFiniteNumber(update.size) ?? undefined; - const prev = this.latestUsageUpdate; - const changed = !prev - || prev.contextTokens !== contextTokens - || prev.contextWindow !== contextWindow; - this.latestUsageUpdate = { contextTokens, contextWindow }; - - // Surface context updates mid-turn so the web status bar shows live - // ctx N/M (X%) instead of staying blank until the final prompt usage - // arrives. ACP usage_update only carries context tokens, so I/O is - // sent as 0; the final prompt-finalize emit overwrites with the real - // input/output totals. - if ( - changed - && this.activeOnUpdate - && (contextTokens !== undefined || contextWindow !== undefined) - ) { - this.activeOnUpdate({ - type: 'usage', - inputTokens: 0, - outputTokens: 0, - contextTokens, - contextWindow - }); - } + const contextTokens = this.asFiniteNumber(update.used); + const contextWindow = this.asFiniteNumber(update.size); + this.latestUsageUpdate = { + contextTokens: contextTokens ?? undefined, + contextWindow: contextWindow ?? undefined + }; } private readLatestUsageUpdate(): AcpUsageUpdate | null {