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); } /**