mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user