fix(opencode): surface ACP context usage live to web status bar (#756)

This commit is contained in:
SSU-WEI HUANG
2026-05-31 19:36:13 +08:00
committed by GitHub
parent a1d144d290
commit 994a820e43
2 changed files with 146 additions and 6 deletions
@@ -644,4 +644,105 @@ describe('AcpSdkBackend', () => {
expect(stragglerIdx).toBeGreaterThanOrEqual(0);
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;
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: 4_200, size: 200_000 }
});
await sleep(5);
// No `usage` field on the response: simulates slash-handled
// turns or errored turns that skip the model.
return { stopReason: 'end_turn' };
},
close: async () => {}
};
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
});
}
});
});
+45 -6
View File
@@ -55,6 +55,7 @@ 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 = {
@@ -283,6 +284,7 @@ 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;
@@ -323,11 +325,27 @@ export class AcpSdkBackend implements AgentBackend {
contextTokens: latestUsageUpdate ? latestUsageUpdate.contextTokens : undefined,
contextWindow: latestUsageUpdate ? latestUsageUpdate.contextWindow : undefined
});
} else if (
latestUsageUpdate
&& (latestUsageUpdate.contextTokens !== undefined || latestUsageUpdate.contextWindow !== undefined)
) {
// Agent did not return prompt usage (slash-handled turns,
// errored turns), but we did see ACP usage updates during
// the turn. Emit a context-only usage so the status bar
// reflects the current context size.
onUpdate({
type: 'usage',
inputTokens: 0,
outputTokens: 0,
contextTokens: latestUsageUpdate.contextTokens,
contextWindow: latestUsageUpdate.contextWindow
});
}
if (stopReason) {
onUpdate({ type: 'turn_complete', stopReason });
}
} finally {
this.activeOnUpdate = null;
this.isProcessingMessage = false;
this.notifyResponseComplete();
}
@@ -407,6 +425,7 @@ 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();
@@ -431,12 +450,32 @@ 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);
const contextWindow = this.asFiniteNumber(update.size);
this.latestUsageUpdate = {
contextTokens: contextTokens ?? undefined,
contextWindow: contextWindow ?? undefined
};
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
});
}
}
private readLatestUsageUpdate(): AcpUsageUpdate | null {