fix(usage): isolate orchestrator CTX gauge from sub-agent tokens (#4271) (#4327)

This commit is contained in:
oxoxDev
2026-06-30 20:20:45 +05:30
committed by GitHub
parent 6938e3268f
commit 268e765f97
3 changed files with 74 additions and 11 deletions
@@ -110,14 +110,21 @@ describe('<ComposerTokenStats />', () => {
]);
fireEvent.click(screen.getByRole('button'));
const bd = screen.getByTestId('composer-token-breakdown');
// Orchestrator = totals sub-agents: tokens 600240=360, cost 0.010.004=0.006.
expect(within(bd).getByText('token.orchestrator')).toBeInTheDocument();
expect(within(bd).getByText(/360/)).toBeInTheDocument();
expect(within(bd).getByText(/\$0\.006/)).toBeInTheDocument();
// Orchestrator row = totals sub-agents: tokens 600240=360, cost 0.010.004=0.006.
// Scope to the row's <li> because the orchestrator-only context numerator (#4271)
// is also 360 for a single turn, so an unscoped /360/ matches twice.
const orchRow = within(bd).getByText('token.orchestrator').closest('li') as HTMLElement;
expect(within(orchRow).getByText(/360/)).toBeInTheDocument();
expect(within(orchRow).getByText(/\$0\.006/)).toBeInTheDocument();
// Sub-agent row: 200 + 40 = 240 combined tokens, its own cost.
expect(within(bd).getByText('researcher')).toBeInTheDocument();
expect(within(bd).getByText(/240/)).toBeInTheDocument();
expect(within(bd).getByText(/\$0\.004/)).toBeInTheDocument();
const subRow = within(bd).getByText('researcher').closest('li') as HTMLElement;
expect(within(subRow).getByText(/240/)).toBeInTheDocument();
expect(within(subRow).getByText(/\$0\.004/)).toBeInTheDocument();
// Context-usage row shows the orchestrator-only numerator (360), not the
// combined 600 (parent + sub-agent), so the gauge can't overflow (#4271).
const ctxRow = within(bd).getByText('token.popContext').closest('div') as HTMLElement;
expect(within(ctxRow).getByText(/\b360\b/)).toBeInTheDocument();
expect(within(ctxRow).queryByText(/\b600\b/)).toBeNull();
});
it('reads the active thread bucket when a threadId is provided', () => {
+43
View File
@@ -114,6 +114,49 @@ describe('chatRuntimeSlice recordChatTurnUsage', () => {
expect(subs.coder.inputTokens).toBe(80);
});
it('excludes sub-agent tokens from the context gauge numerator (#4271)', () => {
const store = makeStore();
// Core sends combined parent+sub-agent turn totals; the gauge must reflect
// the orchestrator thread's own window only, never the sum across agents.
store.dispatch(
recordChatTurnUsage({
inputTokens: 1_000_000,
outputTokens: 50_000,
contextWindow: 1_000_000,
subAgents: [
{ agentId: 'researcher', inputTokens: 600_000, outputTokens: 30_000, costUsd: 0.6 },
{ agentId: 'context_scout', inputTokens: 150_000, outputTokens: 10_000, costUsd: 0.15 },
],
})
);
const usage = store.getState().chatRuntime.sessionTokenUsage;
// orchestrator-only = (1_000_000 + 50_000) - (600_000+30_000 + 150_000+10_000)
expect(usage.lastTurnContextUsed).toBe(260_000);
// Gauge stays within its window: 260_000 / 1_000_000 = 26% ≤ 100%.
expect(usage.lastTurnContextUsed).toBeLessThanOrEqual(usage.contextWindow);
});
it('keeps the full turn as the gauge numerator with no sub-agents (#4271)', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: 800, outputTokens: 120, contextWindow: 200_000 })
);
// Single-agent path is unchanged: nothing to subtract.
expect(store.getState().chatRuntime.sessionTokenUsage.lastTurnContextUsed).toBe(920);
});
it('clamps the gauge numerator to zero when sub-agents exceed the turn total (#4271)', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 50,
outputTokens: 10,
subAgents: [{ agentId: 'researcher', inputTokens: 100, outputTokens: 20, costUsd: 0.001 }],
})
);
expect(store.getState().chatRuntime.sessionTokenUsage.lastTurnContextUsed).toBe(0);
});
it('keeps the prior context window when a turn reports an unknown (0) window', () => {
const store = makeStore();
store.dispatch(
+17 -4
View File
@@ -245,7 +245,12 @@ export interface SessionTokenUsage {
* real value; the UI falls back to a default when unknown.
*/
contextWindow: number;
/** Last turn's input+output tokens — the context-window gauge numerator. */
/**
* Last turn's **orchestrator-only** input+output tokens — the context-window
* gauge numerator. Sub-agent spend is excluded so the gauge tracks the parent
* thread's own window (each sub-agent runs in its own context window); summing
* them in let the gauge exceed 100% in multi-agent sessions (#4271).
*/
lastTurnContextUsed: number;
/** Per-sub-agent spend for the session, keyed by archetype id. */
subAgents: Record<string, SubAgentUsage>;
@@ -300,13 +305,20 @@ function applyTurnUsage(usage: SessionTokenUsage, payload: ChatTurnUsagePayload)
usage.lastUpdated = Date.now();
usage.lastTurnInputTokens = inTok;
usage.lastTurnOutputTokens = outTok;
usage.lastTurnContextUsed = inTok + outTok;
// Only overwrite the known context window when the turn reported a real value
// (>0); an unknown-window turn leaves the prior value intact.
const ctxWindow = nonNeg(payload.contextWindow);
if (ctxWindow > 0) usage.contextWindow = ctxWindow;
// `inTok`/`outTok` are combined parent+sub-agent turn totals (the core sends
// one number for cost), but the context window is the orchestrator model's
// alone. Subtract this turn's sub-agent spend so the gauge numerator is the
// orchestrator thread's own occupancy and can't overflow its window (#4271).
let subTurnTokens = 0;
for (const sub of payload.subAgents ?? []) {
if (!sub || typeof sub.agentId !== 'string' || sub.agentId.length === 0) continue;
const subIn = nonNeg(sub.inputTokens);
const subOut = nonNeg(sub.outputTokens);
subTurnTokens += subIn + subOut;
const existing = usage.subAgents[sub.agentId] ?? {
agentId: sub.agentId,
inputTokens: 0,
@@ -314,12 +326,13 @@ function applyTurnUsage(usage: SessionTokenUsage, payload: ChatTurnUsagePayload)
costUsd: 0,
runs: 0,
};
existing.inputTokens += nonNeg(sub.inputTokens);
existing.outputTokens += nonNeg(sub.outputTokens);
existing.inputTokens += subIn;
existing.outputTokens += subOut;
existing.costUsd += nonNeg(sub.costUsd);
existing.runs += 1;
usage.subAgents[sub.agentId] = existing;
}
usage.lastTurnContextUsed = Math.max(0, inTok + outTok - subTurnTokens);
}
/**