From 268e765f97970f3cfcbdbcae2c5a56626dbb8263 Mon Sep 17 00:00:00 2001
From: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Date: Tue, 30 Jun 2026 20:20:45 +0530
Subject: [PATCH] fix(usage): isolate orchestrator CTX gauge from sub-agent
tokens (#4271) (#4327)
---
.../chat/ComposerTokenStats.test.tsx | 21 ++++++---
app/src/store/chatRuntimeSlice.test.ts | 43 +++++++++++++++++++
app/src/store/chatRuntimeSlice.ts | 21 +++++++--
3 files changed, 74 insertions(+), 11 deletions(-)
diff --git a/app/src/components/chat/ComposerTokenStats.test.tsx b/app/src/components/chat/ComposerTokenStats.test.tsx
index 1550c9100..6003e5234 100644
--- a/app/src/components/chat/ComposerTokenStats.test.tsx
+++ b/app/src/components/chat/ComposerTokenStats.test.tsx
@@ -110,14 +110,21 @@ describe('', () => {
]);
fireEvent.click(screen.getByRole('button'));
const bd = screen.getByTestId('composer-token-breakdown');
- // Orchestrator = totals − sub-agents: tokens 600−240=360, cost 0.01−0.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 600−240=360, cost 0.01−0.004=0.006.
+ // Scope to the row's
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', () => {
diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts
index 33b607164..ab3475088 100644
--- a/app/src/store/chatRuntimeSlice.test.ts
+++ b/app/src/store/chatRuntimeSlice.test.ts
@@ -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(
diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts
index 849cdda70..deedd6b64 100644
--- a/app/src/store/chatRuntimeSlice.ts
+++ b/app/src/store/chatRuntimeSlice.ts
@@ -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;
@@ -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);
}
/**