fix(acp): drop mid-stream usage emit; OpenCode only sends usage_update at end-of-turn (#760)

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 <noreply@hapi.run>
This commit is contained in:
SSU-WEI HUANG
2026-06-01 12:07:40 +08:00
committed by GitHub
co-authored by HAPI
parent cc4025abdb
commit 02d4e93178
2 changed files with 13 additions and 93 deletions
@@ -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<unknown>;
close: () => Promise<void>;
} | 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<AgentMessage, { type: 'usage' }> => 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<AgentMessage, { type: 'usage' }> => 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
});
});
});
+6 -30
View File
@@ -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 {