From d2f9e62af819f8684427e9280e55d51d477c6aed Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 27 Aug 2026 15:46:14 -0500 Subject: [PATCH 01/13] fix(server): scope the Claude context meter to the parent session The parent thread's Context Window meter counted tokens spent by its subagents, and could measure them against the largest context window in the agent tree rather than its own. A workflow with background agents could drive the parent toward 100% while its own transcript was small. Two paths caused it: - normalizeClaudeTaskProgressTokenUsage folded each child's task_progress and task_notification total into the parent's used count via Math.max. A child's tokens live in the child's own context, so they now advance only totalProcessedTokens. Task usage arriving before the parent has any usage of its own emits no parent event, since there is no baseline to attach a running total to; the child's numbers still ride on the task.progress event. - Where turn completion falls back to result modelUsage for the window, it took the maximum across every entry, so one 1M subagent widened a 200k parent's denominator. It now prefers the session model's entry. When getContextUsage() answers, its maxTokens still wins and neither change applies. Sessions without an explicit model selection had no recorded model to key that lookup on, so system/init's model is now recorded when none is set. Its value matches how modelUsage is keyed, suffix included. A refusal retry swaps the model for the rest of the session, so model_refusal_fallback now records the model that actually ran; without it the lookup keys the rejected model and silently falls back. Because child tokens now flow only into the running total, completing a turn had to stop overwriting it with the parent's own smaller figure. Total processed is cumulative thread work, so it keeps the larger value. Fixes #5942 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 465 +++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 84 +++- 2 files changed, 504 insertions(+), 45 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3b5c0f8586a7..70a86c51734d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2886,7 +2886,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( Stream.runCollect, Effect.forkChild, ); @@ -3144,12 +3144,12 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("emits thread token usage updates from Claude task progress", () => { + it.effect("ignores task progress usage before the parent has any usage of its own", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( Stream.runCollect, Effect.forkChild, ); @@ -3177,20 +3177,414 @@ describe("ClaudeAdapterLive", () => { const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); const progressEvent = runtimeEvents.find((event) => event.type === "task.progress"); - assert.equal(usageEvent?.type, "thread.token-usage.updated"); - if (usageEvent?.type === "thread.token-usage.updated") { - assert.deepEqual(usageEvent.payload, { - usage: { - usedTokens: 321, - lastUsedTokens: 321, - toolUses: 2, - durationMs: 654, - }, - }); - } + // The subagent's 321 tokens are spent in its own window. With no parent + // usage recorded yet there is nothing to attach a running total to, so + // the parent meter stays silent rather than adopting the child's count. + assert.equal(usageEvent, undefined); assert.equal(progressEvent?.type, "task.progress"); - if (usageEvent && progressEvent) { - assert.notStrictEqual(usageEvent.eventId, progressEvent.eventId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps subagent tokens out of the parent context meter (#5942)", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "delegate this", + attachments: [], + }); + + // The parent finishes a small turn of its own. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 10, + duration_api_ms: 8, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-child-usage", + usage: { input_tokens: 4_000, output_tokens: 200 }, + modelUsage: { "claude-opus-4-6": { contextWindow: 1_000_000 } }, + } as unknown as SDKMessage); + + // A background subagent then burns far more than the parent ever has. + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-child-1", + description: "Background agent doing the heavy work", + usage: { total_tokens: 900_000, tool_uses: 40, duration_ms: 1_000 }, + session_id: "sdk-session-child-usage", + uuid: "task-child-progress-1", + } as unknown as SDKMessage); + harness.query.finish(); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + // The parent's own 4,200 stands. The child's 900,000 only advances the + // running total. Before this fix usedTokens became 900,000. + assert.equal(latest.payload.usage.usedTokens, 4_200); + assert.equal(latest.payload.usage.totalProcessedTokens, 900_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("measures the meter against the session model's window, not a subagent's", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-sonnet-5", + ), + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "summarize", + attachments: [], + }); + + // A 1M subagent ran alongside a 200k session model. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-session-window-scope", + uuid: "result-window-scope", + usage: { input_tokens: 50_000, output_tokens: 1_000 }, + modelUsage: { + "claude-sonnet-5": { contextWindow: 200_000 }, + "claude-opus-5[1m]": { contextWindow: 1_000_000 }, + }, + } as unknown as SDKMessage); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + // Before this fix the Math.max over modelUsage reported 1,000,000. + assert.equal(latest.payload.usage.maxTokens, 200_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("uses the init model's window when no model was explicitly selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "summarize", + attachments: [], + }); + + // No selection was made, so the session runs Claude Code's default and + // init is the first place its name appears. + harness.query.emit({ + type: "system", + subtype: "init", + model: "claude-sonnet-5", + session_id: "sdk-session-init-window", + uuid: "init-window", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 10, + duration_api_ms: 8, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-init-window", + usage: { input_tokens: 50_000, output_tokens: 1_000 }, + modelUsage: { + "claude-sonnet-5": { contextWindow: 200_000 }, + "claude-opus-5[1m]": { contextWindow: 1_000_000 }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + assert.equal(latest.payload.usage.maxTokens, 200_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("carries a subagent's running total into the parent's next snapshot", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "delegate this", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + event: { + type: "message_delta", + delta: { stop_reason: null, stop_sequence: null }, + usage: { input_tokens: 3_000, output_tokens: 0 }, + }, + parent_tool_use_id: null, + session_id: "sdk-session-running-total", + uuid: "parent-running-total", + } as unknown as SDKMessage); + + // The child's spend is real work the thread paid for, so it belongs in + // the running total even though it never touches the parent's own + // used count. + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-running-total", + description: "Child doing the heavy work", + usage: { total_tokens: 480_000 }, + session_id: "sdk-session-running-total", + uuid: "task-running-total-progress", + } as unknown as SDKMessage); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + assert.equal(latest.payload.usage.totalProcessedTokens, 480_000); + assert.equal(latest.payload.usage.usedTokens, 3_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps a subagent's running total when the parent turn completes", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "go", attachments: [] }); + + harness.query.emit({ + type: "stream_event", + event: { + type: "message_delta", + delta: { stop_reason: null, stop_sequence: null }, + usage: { input_tokens: 3_000, output_tokens: 0 }, + }, + parent_tool_use_id: null, + session_id: "sdk-session-running-total-result", + uuid: "running-total-result-delta", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-running-total-result", + description: "Child doing the heavy work", + usage: { total_tokens: 900_000 }, + session_id: "sdk-session-running-total-result", + uuid: "running-total-result-task", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 10, + duration_api_ms: 8, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-running-total-result", + usage: { input_tokens: 4_000, output_tokens: 200 }, + modelUsage: { "claude-opus-4-6": { contextWindow: 200_000 } }, + } as unknown as SDKMessage); + harness.query.finish(); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + const ev = runtimeEvents.filter((e) => e.type === "thread.token-usage.updated"); + const latest = ev.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + // The parent's own result is smaller than the running total the child + // already raised. Used tokens follow the parent; total processed is + // cumulative thread work and must not regress. + assert.equal(latest.payload.usage.usedTokens, 4_200); + assert.equal(latest.payload.usage.totalProcessedTokens, 900_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("follows a persistent refusal fallback to the model that ran", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "summarize", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "init", + model: "claude-sonnet-5", + session_id: "sdk-session-refusal", + uuid: "refusal-init", + } as unknown as SDKMessage); + + // A refusal retry swaps the model for the rest of the session, so the + // window has to be measured against the model that actually ran. + harness.query.emit({ + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + original_model: "claude-sonnet-5", + fallback_model: "claude-opus-5[1m]", + request_id: null, + session_id: "sdk-session-refusal", + uuid: "refusal-swap", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 10, + duration_api_ms: 8, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-refusal", + usage: { input_tokens: 50_000, output_tokens: 1_000 }, + modelUsage: { + "claude-sonnet-5": { contextWindow: 200_000 }, + "claude-opus-5[1m]": { contextWindow: 1_000_000 }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + assert.equal(latest.payload.usage.maxTokens, 1_000_000); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -3335,10 +3729,10 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( - Stream.runCollect, - Effect.forkChild, - ); + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -3352,6 +3746,27 @@ describe("ClaudeAdapterLive", () => { attachments: [], }); + // The parent records usage of its own first, so the task snapshot that + // follows is actually retained rather than dropped for want of a + // baseline. Without this the assertion below would hold even if + // completion silently discarded a recorded task total. + harness.query.emit({ + type: "assistant", + message: { + id: "msg-parent-baseline", + type: "message", + role: "assistant", + model: "claude-opus-4-6", + content: [{ type: "text", text: "working" }], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 12000, output_tokens: 0 }, + }, + parent_tool_use_id: null, + session_id: "sdk-session-task-usage-clamped", + uuid: "parent-baseline-clamped", + } as unknown as SDKMessage); + harness.query.emit({ type: "system", subtype: "task_progress", @@ -3386,17 +3801,21 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); const finalUsageEvent = usageEvents.at(-1); assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); if (finalUsageEvent?.type === "thread.token-usage.updated") { + // The task_progress 190,000 belongs to a subagent, so it survives as + // the running total only. The parent's own used count comes from the + // result, clamped to the window it reported. assert.deepEqual(finalUsageEvent.payload, { usage: { - usedTokens: 190000, - lastUsedTokens: 190000, + usedTokens: 200000, + lastUsedTokens: 200000, totalProcessedTokens: 535000, maxTokens: 200000, }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 62763f947c7d..73c26c60eba7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -465,11 +465,20 @@ function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.make(value); } -function maxClaudeContextWindowFromModelUsage( +// `modelUsage` is keyed by every model that ran during the turn, subagents +// included, so the maximum could be a child's window rather than this +// session's. Fall back to it only when the session model has no entry. +function claudeContextWindowFromModelUsage( modelUsage: Record | undefined, + sessionModel: string | undefined, ): number | undefined { if (!modelUsage) return undefined; + const sessionEntry = sessionModel ? modelUsage[sessionModel] : undefined; + if (sessionEntry) { + return sessionEntry.contextWindow; + } + let maxContextWindow: number | undefined; for (const value of Object.values(modelUsage)) { const contextWindow = value.contextWindow; @@ -629,6 +638,8 @@ function compactBoundaryTokenUsageSnapshot( }); } +// A subagent's tokens are spent in its own context window, so they advance the +// thread's running total but never the parent's used count (#5942). function normalizeClaudeTaskProgressTokenUsage( value: unknown, context: ClaudeSessionContext, @@ -638,32 +649,25 @@ function normalizeClaudeTaskProgressTokenUsage( return undefined; } - const lastUsedTokens = context.lastKnownTokenUsage?.usedTokens; - const activeTokens = - lastUsedTokens !== undefined ? Math.max(totalTokens, lastUsedTokens) : totalTokens; - if (lastUsedTokens !== undefined && activeTokens === lastUsedTokens) { + const lastKnown = context.lastKnownTokenUsage; + if (!lastKnown) { return undefined; } - const usage = value as Record; - const snapshot = makeClaudeTokenUsageSnapshot({ - activeTokens, - ...(context.lastKnownContextWindow !== undefined - ? { contextWindow: context.lastKnownContextWindow } - : {}), - totalProcessedTokens: Math.max( - totalTokens, - context.lastKnownTotalProcessedTokens ?? totalTokens, - ), - }); - if (!snapshot) { + const totalProcessedTokens = Math.max( + totalTokens, + context.lastKnownTotalProcessedTokens ?? totalTokens, + ); + if (totalProcessedTokens === context.lastKnownTotalProcessedTokens) { return undefined; } + const usage = value as Record; const toolUses = finiteNonNegativeInteger(usage.tool_uses); const durationMs = finiteNonNegativeInteger(usage.duration_ms); return { - ...snapshot, + ...lastKnown, + totalProcessedTokens, ...(toolUses !== undefined ? { toolUses } : {}), ...(durationMs !== undefined ? { durationMs } : {}), }; @@ -2251,13 +2255,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( errorMessage?: string, result?: SDKResultMessage, ) { - const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); + const resultContextWindow = claudeContextWindowFromModelUsage( + result?.modelUsage, + context.currentApiModelId, + ); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; } const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; - const accumulatedTotalProcessedTokens = claudeTotalProcessedTokens(result?.usage); + // The result reports the parent's own usage, so it can be smaller than a + // running total a subagent already raised. Total processed is cumulative + // work for the thread and only ever grows, so keep the larger figure + // rather than letting the parent's turn erase the children's. + const resultTotalProcessedTokens = claudeTotalProcessedTokens(result?.usage); + const accumulatedTotalProcessedTokens = + resultTotalProcessedTokens !== undefined + ? Math.max(resultTotalProcessedTokens, context.lastKnownTotalProcessedTokens ?? 0) + : undefined; if (accumulatedTotalProcessedTokens !== undefined) { context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens; } @@ -3180,7 +3195,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } switch (message.subtype) { - case "init": + case "init": { + // Without an explicit selection there is no recorded model to key the + // context window on, and init is where the SDK first names the default. + // A selection is left alone: setModel compares against this id to + // decide whether a mid-thread switch still needs to be sent, so + // overwriting it with the reported id would suppress that call. A + // selected slug that the CLI resolves to a different id therefore + // still misses the modelUsage lookup and falls back to the maximum, + // which is the behavior that was already there. + const initModel = trimmedString(message.model); + if (initModel && !context.currentApiModelId) { + context.currentApiModelId = initModel; + } yield* offerRuntimeEvent({ ...base, type: "session.configured", @@ -3189,6 +3216,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; + } case "status": yield* offerRuntimeEvent({ ...base, @@ -3482,9 +3510,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* emitRuntimeWarning(context, message.text, message); } return; + case "model_refusal_fallback": { + // A refusal retry swaps the model for the rest of the session, so the + // recorded id has to follow or the window lookup keys a model that no + // longer runs. Only "retry" is emitted today; the other directions + // remain in the enum for compatibility and do not swap. + if (message.direction === "retry") { + const fallbackModel = trimmedString(message.fallback_model); + if (fallbackModel) { + context.currentApiModelId = fallbackModel; + } + } + return; + } // Inner protocol/UX details with no T3 surface today — consumed // deliberately so they don't masquerade as unknown-subtype warnings. - case "model_refusal_fallback": case "local_command_output": case "plugin_install": case "commands_changed": From 81341c558c28aac6e0b207028165aecce20297c2 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 28 Aug 2026 14:51:25 -0500 Subject: [PATCH 02/13] fix(server): hold the running total until it exceeds parent usage A subagent's first small progress tick produced a cumulative figure below the parent's own used count, rendering as e.g. 3,000 used of 2,000 total (review finding on #8453). The snapshot builder already drops such totals; now the no-op event is skipped entirely instead of re-emitting the last snapshot unchanged. Also states at the accumulation site why the running total is a floor (Math.max) rather than a sum: the SDK does not document whether result usage aggregates children, and double-counting would overstate work while a floor only understates it. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 72 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 10 +++ 2 files changed, 82 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 70a86c51734d..afa636de6be7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3188,6 +3188,78 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("holds the running total until it exceeds the parent's own usage", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "go", attachments: [] }); + + harness.query.emit({ + type: "stream_event", + event: { + type: "message_delta", + delta: { stop_reason: null, stop_sequence: null }, + usage: { input_tokens: 3_000, output_tokens: 0 }, + }, + parent_tool_use_id: null, + session_id: "sdk-session-total-floor", + uuid: "total-floor-delta", + } as unknown as SDKMessage); + + // A cumulative figure below the parent's active usage would read as + // "3,000 of 2,000 total". The child's small tick stays off the meter. + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-total-floor", + description: "Child barely started", + usage: { total_tokens: 2_000 }, + session_id: "sdk-session-total-floor", + uuid: "total-floor-small", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-total-floor", + description: "Child past the parent", + usage: { total_tokens: 5_000 }, + session_id: "sdk-session-total-floor", + uuid: "total-floor-large", + } as unknown as SDKMessage); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + assert.equal(usageEvents.length, 2); + const [afterDelta, afterLargeTick] = usageEvents; + if (afterDelta?.type === "thread.token-usage.updated") { + assert.equal(afterDelta.payload.usage.usedTokens, 3_000); + assert.equal(afterDelta.payload.usage.totalProcessedTokens, undefined); + } + if (afterLargeTick?.type === "thread.token-usage.updated") { + assert.equal(afterLargeTick.payload.usage.usedTokens, 3_000); + assert.equal(afterLargeTick.payload.usage.totalProcessedTokens, 5_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps subagent tokens out of the parent context meter (#5942)", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 73c26c60eba7..78c78aeb69be 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -654,6 +654,10 @@ function normalizeClaudeTaskProgressTokenUsage( return undefined; } + // Math.max floors the running total at the largest single contributor + // rather than summing per-task totals: the SDK does not say whether the + // result usage already aggregates children, and a sum that double-counted + // would overstate work, while a floor only ever understates it. const totalProcessedTokens = Math.max( totalTokens, context.lastKnownTotalProcessedTokens ?? totalTokens, @@ -661,6 +665,12 @@ function normalizeClaudeTaskProgressTokenUsage( if (totalProcessedTokens === context.lastKnownTotalProcessedTokens) { return undefined; } + // A total no larger than the parent's own used count would render as a + // cumulative figure smaller than the active usage (review finding). The + // snapshot builder drops such totals; skip the no-op event entirely. + if (totalProcessedTokens <= lastKnown.usedTokens) { + return undefined; + } const usage = value as Record; const toolUses = finiteNonNegativeInteger(usage.tool_uses); From 00a7e9f48152ace4ceb8e46c31eb8fb6ca0aa2fc Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 28 Aug 2026 18:11:24 -0500 Subject: [PATCH 03/13] style(server): tighten comments in the context meter change Comments now state only what the code cannot: the floor-not-sum choice, the snapshot invariant, and why init and refusal fallback record the model. Removed restated mechanics and before/after changelog prose from tests. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 6 ++-- .../src/provider/Layers/ClaudeAdapter.ts | 34 +++++++------------ 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index afa636de6be7..f7961d0d106f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3317,8 +3317,8 @@ describe("ClaudeAdapterLive", () => { const latest = usageEvents.at(-1); assert.equal(latest?.type, "thread.token-usage.updated"); if (latest?.type === "thread.token-usage.updated") { - // The parent's own 4,200 stands. The child's 900,000 only advances the - // running total. Before this fix usedTokens became 900,000. + // The parent's own 4,200 stands; the child's 900,000 only advances + // the running total, never usedTokens. assert.equal(latest.payload.usage.usedTokens, 4_200); assert.equal(latest.payload.usage.totalProcessedTokens, 900_000); } @@ -3376,7 +3376,7 @@ describe("ClaudeAdapterLive", () => { const latest = usageEvents.at(-1); assert.equal(latest?.type, "thread.token-usage.updated"); if (latest?.type === "thread.token-usage.updated") { - // Before this fix the Math.max over modelUsage reported 1,000,000. + // The maximum over modelUsage would report the child's 1,000,000. assert.equal(latest.payload.usage.maxTokens, 200_000); } }).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 78c78aeb69be..3f2ee33e74f6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -654,10 +654,9 @@ function normalizeClaudeTaskProgressTokenUsage( return undefined; } - // Math.max floors the running total at the largest single contributor - // rather than summing per-task totals: the SDK does not say whether the - // result usage already aggregates children, and a sum that double-counted - // would overstate work, while a floor only ever understates it. + // The running total floors at the largest single contributor rather than + // summing per-task totals: the SDK does not document whether result usage + // already aggregates children, and a floor can only understate. const totalProcessedTokens = Math.max( totalTokens, context.lastKnownTotalProcessedTokens ?? totalTokens, @@ -665,9 +664,8 @@ function normalizeClaudeTaskProgressTokenUsage( if (totalProcessedTokens === context.lastKnownTotalProcessedTokens) { return undefined; } - // A total no larger than the parent's own used count would render as a - // cumulative figure smaller than the active usage (review finding). The - // snapshot builder drops such totals; skip the no-op event entirely. + // Match makeClaudeTokenUsageSnapshot's invariant: a running total is only + // meaningful once it exceeds the parent's own used count. if (totalProcessedTokens <= lastKnown.usedTokens) { return undefined; } @@ -2274,10 +2272,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; - // The result reports the parent's own usage, so it can be smaller than a - // running total a subagent already raised. Total processed is cumulative - // work for the thread and only ever grows, so keep the larger figure - // rather than letting the parent's turn erase the children's. + // The result carries only the parent's own usage, which can be smaller + // than a running total a subagent already raised; the thread total only + // ever grows. const resultTotalProcessedTokens = claudeTotalProcessedTokens(result?.usage); const accumulatedTotalProcessedTokens = resultTotalProcessedTokens !== undefined @@ -3206,14 +3203,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( switch (message.subtype) { case "init": { - // Without an explicit selection there is no recorded model to key the - // context window on, and init is where the SDK first names the default. - // A selection is left alone: setModel compares against this id to - // decide whether a mid-thread switch still needs to be sent, so - // overwriting it with the reported id would suppress that call. A - // selected slug that the CLI resolves to a different id therefore - // still misses the modelUsage lookup and falls back to the maximum, - // which is the behavior that was already there. + // init is where the SDK first names the model, so record it when no + // explicit selection exists. A selection is left alone: setModel + // compares against this id to decide whether a mid-thread switch + // still needs to be sent. const initModel = trimmedString(message.model); if (initModel && !context.currentApiModelId) { context.currentApiModelId = initModel; @@ -3523,8 +3516,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "model_refusal_fallback": { // A refusal retry swaps the model for the rest of the session, so the // recorded id has to follow or the window lookup keys a model that no - // longer runs. Only "retry" is emitted today; the other directions - // remain in the enum for compatibility and do not swap. + // longer runs. if (message.direction === "retry") { const fallbackModel = trimmedString(message.fallback_model); if (fallbackModel) { From 83e3938452846ffabdaeb1061203bd5dd33be2a9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 28 Aug 2026 20:45:15 -0500 Subject: [PATCH 04/13] fix(server): keep the refusal fallback out of setModel bookkeeping currentApiModelId is the last id handed to setModel, so sendTurn compares the user's selection against it to decide whether a mid-thread switch still needs sending. Recording the refusal fallback there made the next turn with an unchanged selection look like a switch and re-send the model the API had just refused. The window lookup needs the model that actually ran, which after a refusal is not the selected one, so it now reads a separate observedApiModelId that init and the refusal fallback write and setModel never consults. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 69 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 25 ++++--- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index f7961d0d106f..6b7c48ebd4fa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3664,6 +3664,75 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("does not re-send a refused model on the next turn with the same selection", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.runForEach( + adapter.streamEvents, + () => Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const selection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-4-6", + ); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "first", + modelSelection: selection, + attachments: [], + }); + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + + harness.query.emit({ + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + original_model: "claude-opus-4-6[1m]", + fallback_model: "claude-sonnet-5", + request_id: null, + session_id: "sdk-session-refusal-resend", + uuid: "refusal-resend-swap", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 10, + duration_api_ms: 8, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-refusal-resend", + usage: { input_tokens: 1_000, output_tokens: 10 }, + } as unknown as SDKMessage); + yield* Effect.yieldNow; + + // The selection has not changed, so the second turn must not call + // setModel at all — least of all with the model the API just refused. + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "second", + modelSelection: selection, + attachments: [], + }); + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + + yield* Fiber.interrupt(runtimeEventsFiber); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("emits Claude context window on result completion usage snapshots", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3f2ee33e74f6..2ec318a962b6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -287,6 +287,11 @@ interface ClaudeSessionContext { readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; + /** The model the SDK reports is actually serving this session, which is not + * always the selected one: a refusal retry swaps it for the rest of the + * session. Kept apart from `currentApiModelId`, which tracks the last id + * passed to `setModel` and must keep matching the user's selection. */ + observedApiModelId: string | undefined; /** Effective effort for the session's turns; subagents without an explicit * effort override inherit this. */ currentEffort: string | undefined; @@ -2265,7 +2270,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { const resultContextWindow = claudeContextWindowFromModelUsage( result?.modelUsage, - context.currentApiModelId, + context.observedApiModelId ?? context.currentApiModelId, ); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; @@ -3203,13 +3208,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( switch (message.subtype) { case "init": { - // init is where the SDK first names the model, so record it when no - // explicit selection exists. A selection is left alone: setModel - // compares against this id to decide whether a mid-thread switch - // still needs to be sent. + // init is the first place the SDK names the model actually serving the + // session, which is the id `modelUsage` is keyed by. const initModel = trimmedString(message.model); - if (initModel && !context.currentApiModelId) { - context.currentApiModelId = initModel; + if (initModel) { + context.observedApiModelId = initModel; } yield* offerRuntimeEvent({ ...base, @@ -3515,12 +3518,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; case "model_refusal_fallback": { // A refusal retry swaps the model for the rest of the session, so the - // recorded id has to follow or the window lookup keys a model that no - // longer runs. + // window lookup has to follow it. `currentApiModelId` deliberately + // does not: it mirrors the user's selection for `setModel`, and moving + // it here would make the next turn re-send the refused model. if (message.direction === "retry") { const fallbackModel = trimmedString(message.fallback_model); if (fallbackModel) { - context.currentApiModelId = fallbackModel; + context.observedApiModelId = fallbackModel; } } return; @@ -4502,6 +4506,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, + observedApiModelId: undefined, currentEffort: effectiveEffort ?? undefined, resumeSessionId: sessionId, pendingApprovals, From 6c39a6cad692f669f92197e66d2d2c0dcafc620a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 28 Aug 2026 20:55:31 -0500 Subject: [PATCH 05/13] fix(server): follow a mid-thread model switch in the window lookup observedApiModelId tracked what init and the refusal fallback reported but not a deliberate switch, so after sendTurn changed models the meter kept measuring against the previous model's window - or fell through to the maximum across modelUsage, which is the inflation this branch removes. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 70 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 3 + 2 files changed, 73 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6b7c48ebd4fa..440f1966c272 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3664,6 +3664,76 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("measures the window against the model a mid-thread switch selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "init", + model: "claude-opus-4-6[1m]", + session_id: "sdk-session-switch", + uuid: "switch-init", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + + // The user switches to a 200k model partway through the thread. + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "switch", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-5", + }, + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 10, + duration_api_ms: 8, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-switch", + usage: { input_tokens: 20_000, output_tokens: 500 }, + modelUsage: { + "claude-opus-4-6[1m]": { contextWindow: 1_000_000 }, + "claude-sonnet-5": { contextWindow: 200_000 }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + yield* Effect.yieldNow; + yield* Fiber.interrupt(runtimeEventsFiber); + + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + assert.equal(latest.payload.usage.maxTokens, 200_000); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("does not re-send a refused model on the next turn with the same selection", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2ec318a962b6..87196251c358 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4633,6 +4633,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), }); context.currentApiModelId = apiModelId; + // A deliberate switch supersedes whatever init or a refusal observed; + // leaving it stale would measure the meter against the old model. + context.observedApiModelId = apiModelId; } context.session = { ...context.session, From a7a984ac6d3590b0989de0dc1a38e1a2d55a6b66 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 29 Aug 2026 03:19:40 -0500 Subject: [PATCH 06/13] test(server): follow the assistant-usage completion path from #8610 Turn completion now prefers the last assistant usage over the cumulative result total, so the parent's used count here is its real 12,000 rather than the clamped window. The subagent total this guards is unchanged. --- apps/server/src/provider/Layers/ClaudeAdapter.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 440f1966c272..01e18856a16a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -4021,13 +4021,14 @@ describe("ClaudeAdapterLive", () => { assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); if (finalUsageEvent?.type === "thread.token-usage.updated") { // The task_progress 190,000 belongs to a subagent, so it survives as - // the running total only. The parent's own used count comes from the - // result, clamped to the window it reported. + // the running total only. The parent's own used count comes from its + // last assistant usage, not the cumulative result total. assert.deepEqual(finalUsageEvent.payload, { usage: { - usedTokens: 200000, - lastUsedTokens: 200000, + usedTokens: 12000, + lastUsedTokens: 12000, totalProcessedTokens: 535000, + inputTokens: 12000, maxTokens: 200000, }, }); From 448f19c64be52802af29b31602abf8ca2bee6044 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:03:26 -0500 Subject: [PATCH 07/13] test(server): key the context meter tests to the synthetic model catalog #9084 moved Claude's model ids, aliases and context windows out of server source and into the manifest catalog, and gave the transport tests a synthetic catalog so they stop depending on what the real manifest ships. These tests still named claude-opus-4-6, claude-opus-5 and claude-sonnet-5, and hardcoded the [1m] suffix that only ever came from manifest data, so the refusal-resend test asserted a setModel id the catalog no longer produces. Every id now comes from ClaudeModelCatalog.testFixtures: the 200k session model is SYNTHETIC_CLAUDE_STANDARD_MODEL selected with contextWindow "standard", and the 1M model is SYNTHETIC_CLAUDE_CAPABLE_MODEL with "expanded", whose suffix the fixture maps to [expanded]. The numbers and the property each assertion pins are unchanged. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 01e18856a16a..a8894b2fbc84 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3293,7 +3293,7 @@ describe("ClaudeAdapterLive", () => { stop_reason: "end_turn", session_id: "sdk-session-child-usage", usage: { input_tokens: 4_000, output_tokens: 200 }, - modelUsage: { "claude-opus-4-6": { contextWindow: 1_000_000 } }, + modelUsage: { [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 1_000_000 } }, } as unknown as SDKMessage); // A background subagent then burns far more than the parent ever has. @@ -3344,7 +3344,8 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-5", + SYNTHETIC_CLAUDE_STANDARD_MODEL, + [{ id: "contextWindow", value: "standard" }], ), }); @@ -3363,8 +3364,8 @@ describe("ClaudeAdapterLive", () => { uuid: "result-window-scope", usage: { input_tokens: 50_000, output_tokens: 1_000 }, modelUsage: { - "claude-sonnet-5": { contextWindow: 200_000 }, - "claude-opus-5[1m]": { contextWindow: 1_000_000 }, + [SYNTHETIC_CLAUDE_STANDARD_MODEL]: { contextWindow: 200_000 }, + [`${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`]: { contextWindow: 1_000_000 }, }, } as unknown as SDKMessage); @@ -3411,7 +3412,7 @@ describe("ClaudeAdapterLive", () => { harness.query.emit({ type: "system", subtype: "init", - model: "claude-sonnet-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, session_id: "sdk-session-init-window", uuid: "init-window", } as unknown as SDKMessage); @@ -3428,8 +3429,8 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-init-window", usage: { input_tokens: 50_000, output_tokens: 1_000 }, modelUsage: { - "claude-sonnet-5": { contextWindow: 200_000 }, - "claude-opus-5[1m]": { contextWindow: 1_000_000 }, + [SYNTHETIC_CLAUDE_STANDARD_MODEL]: { contextWindow: 200_000 }, + [`${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`]: { contextWindow: 1_000_000 }, }, } as unknown as SDKMessage); harness.query.finish(); @@ -3564,7 +3565,7 @@ describe("ClaudeAdapterLive", () => { stop_reason: "end_turn", session_id: "sdk-session-running-total-result", usage: { input_tokens: 4_000, output_tokens: 200 }, - modelUsage: { "claude-opus-4-6": { contextWindow: 200_000 } }, + modelUsage: { [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200_000 } }, } as unknown as SDKMessage); harness.query.finish(); @@ -3610,7 +3611,7 @@ describe("ClaudeAdapterLive", () => { harness.query.emit({ type: "system", subtype: "init", - model: "claude-sonnet-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, session_id: "sdk-session-refusal", uuid: "refusal-init", } as unknown as SDKMessage); @@ -3622,8 +3623,8 @@ describe("ClaudeAdapterLive", () => { subtype: "model_refusal_fallback", trigger: "refusal", direction: "retry", - original_model: "claude-sonnet-5", - fallback_model: "claude-opus-5[1m]", + original_model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + fallback_model: `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, request_id: null, session_id: "sdk-session-refusal", uuid: "refusal-swap", @@ -3641,8 +3642,8 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-refusal", usage: { input_tokens: 50_000, output_tokens: 1_000 }, modelUsage: { - "claude-sonnet-5": { contextWindow: 200_000 }, - "claude-opus-5[1m]": { contextWindow: 1_000_000 }, + [SYNTHETIC_CLAUDE_STANDARD_MODEL]: { contextWindow: 200_000 }, + [`${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`]: { contextWindow: 1_000_000 }, }, } as unknown as SDKMessage); harness.query.finish(); @@ -3682,7 +3683,7 @@ describe("ClaudeAdapterLive", () => { harness.query.emit({ type: "system", subtype: "init", - model: "claude-opus-4-6[1m]", + model: `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, session_id: "sdk-session-switch", uuid: "switch-init", } as unknown as SDKMessage); @@ -3692,10 +3693,11 @@ describe("ClaudeAdapterLive", () => { yield* adapter.sendTurn({ threadId: THREAD_ID, input: "switch", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-5", - }, + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_STANDARD_MODEL, + [{ id: "contextWindow", value: "standard" }], + ), attachments: [], }); @@ -3711,8 +3713,8 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-switch", usage: { input_tokens: 20_000, output_tokens: 500 }, modelUsage: { - "claude-opus-4-6[1m]": { contextWindow: 1_000_000 }, - "claude-sonnet-5": { contextWindow: 200_000 }, + [`${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`]: { contextWindow: 1_000_000 }, + [SYNTHETIC_CLAUDE_STANDARD_MODEL]: { contextWindow: 200_000 }, }, } as unknown as SDKMessage); harness.query.finish(); @@ -3751,7 +3753,8 @@ describe("ClaudeAdapterLive", () => { const selection = createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], ); yield* adapter.sendTurn({ threadId: THREAD_ID, @@ -3759,15 +3762,17 @@ describe("ClaudeAdapterLive", () => { modelSelection: selection, attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + ]); harness.query.emit({ type: "system", subtype: "model_refusal_fallback", trigger: "refusal", direction: "retry", - original_model: "claude-opus-4-6[1m]", - fallback_model: "claude-sonnet-5", + original_model: `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + fallback_model: SYNTHETIC_CLAUDE_STANDARD_MODEL, request_id: null, session_id: "sdk-session-refusal-resend", uuid: "refusal-resend-swap", @@ -3794,7 +3799,9 @@ describe("ClaudeAdapterLive", () => { modelSelection: selection, attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + ]); yield* Fiber.interrupt(runtimeEventsFiber); }).pipe( @@ -3967,7 +3974,7 @@ describe("ClaudeAdapterLive", () => { id: "msg-parent-baseline", type: "message", role: "assistant", - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, content: [{ type: "text", text: "working" }], stop_reason: null, stop_sequence: null, From c7c843035a051e0ef4cee618de0069826607261c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 16:03:41 -0500 Subject: [PATCH 08/13] test(server): drain the extra catalog tick before asserting usage events #9084 made startSession and sendTurn resolve the model catalog through an Effect, which puts one more scheduler yield between the test fiber and the adapter's message pump. A single Effect.yieldNow no longer reached the point where an emitted SDK message has become a runtime event, so seven of these tests saw no thread.token-usage.updated at all and the mid-thread switch test read the init model after its own setModel. Three yields is what the rest of the file already uses for collect-then- assert tests; two is the current minimum, so this keeps a tick of margin. Every one of these assertions checks the event exists before reading its payload, so a future deepening fails loudly rather than passing vacuously. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a8894b2fbc84..ac5a44dd6a37 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3238,6 +3238,8 @@ describe("ClaudeAdapterLive", () => { uuid: "total-floor-large", } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); @@ -3308,6 +3310,8 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); @@ -3369,6 +3373,8 @@ describe("ClaudeAdapterLive", () => { }, } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); const usageEvents = runtimeEvents.filter( @@ -3435,6 +3441,8 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); @@ -3498,6 +3506,8 @@ describe("ClaudeAdapterLive", () => { uuid: "task-running-total-progress", } as unknown as SDKMessage); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); @@ -3569,6 +3579,8 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); const ev = runtimeEvents.filter((e) => e.type === "thread.token-usage.updated"); @@ -3648,6 +3660,8 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); @@ -3688,6 +3702,8 @@ describe("ClaudeAdapterLive", () => { uuid: "switch-init", } as unknown as SDKMessage); yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; // The user switches to a 200k model partway through the thread. yield* adapter.sendTurn({ @@ -3719,6 +3735,8 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); @@ -3790,6 +3808,8 @@ describe("ClaudeAdapterLive", () => { usage: { input_tokens: 1_000, output_tokens: 10 }, } as unknown as SDKMessage); yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; // The selection has not changed, so the second turn must not call // setModel at all — least of all with the model the API just refused. @@ -4019,6 +4039,8 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); + yield* Effect.yieldNow; + yield* Effect.yieldNow; yield* Effect.yieldNow; yield* Fiber.interrupt(runtimeEventsFiber); const usageEvents = runtimeEvents.filter( From 0ca6331c99a94fde748e99295578c238b51c86fc Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 3 Sep 2026 21:07:05 -0500 Subject: [PATCH 09/13] fix(server): keep total-only results off the active context meter Carried over from #6586 at 35fab5c3e8: a result carrying only cumulative total_tokens is the session's spend, not context occupancy, so building an active snapshot from it rendered a full meter whenever a turn ended without its own usage reading. resultIterationSnapshot is now gated on resultHasActiveUsage; the cumulative figure still lands in the running total. Replaces the clamp test that asserted the old rendering with both of #6586's total-only cases, adapted to the synthetic catalog. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 118 +++++++++++++++--- .../src/provider/Layers/ClaudeAdapter.ts | 17 +-- 2 files changed, 113 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ac5a44dd6a37..d49d9d7d4e13 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3897,15 +3897,15 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("clamps oversized Claude usage to the reported context window", () => { + it.effect("does not treat total-only Claude result usage as active context", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( - Stream.runCollect, - Effect.forkChild, - ); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -3928,7 +3928,7 @@ describe("ClaudeAdapterLive", () => { num_turns: 1, result: "done", stop_reason: "end_turn", - session_id: "sdk-session-result-usage-clamped", + session_id: "sdk-session-result-total-only", usage: { total_tokens: 535000, }, @@ -3943,16 +3943,104 @@ describe("ClaudeAdapterLive", () => { const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); - assert.equal(usageEvent?.type, "thread.token-usage.updated"); - if (usageEvent?.type === "thread.token-usage.updated") { - assert.deepEqual(usageEvent.payload, { - usage: { - usedTokens: 200000, - lastUsedTokens: 200000, - totalProcessedTokens: 535000, - maxTokens: 200000, + // 535,000 is the session's cumulative spend, not context occupancy. With + // no active reading in the turn there is nothing truthful to show, and + // clamping it to the window rendered a full meter. + assert.equal(usageEvent, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps subagent totals out of parent context when the result is total-only", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-total-only", + description: "Thinking through the patch", + usage: { + total_tokens: 190000, + }, + session_id: "sdk-session-total-only-after-progress", + uuid: "task-total-only-progress", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-total-only", + status: "completed", + summary: "Task finished", + usage: { + total_tokens: 250000, + tool_uses: 100, + duration_ms: 900000, + }, + session_id: "sdk-session-total-only-after-progress", + uuid: "task-total-only-completed", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-total-only-after-progress", + usage: { + total_tokens: 535000, + }, + modelUsage: { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { + contextWindow: 200000, + maxOutputTokens: 64000, }, - }); + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + // The parent never reported usage of its own, so neither the children's + // totals nor the cumulative result may fabricate a meter reading. The + // children's numbers still ride on their task events. + assert.deepEqual(usageEvents, []); + const progressEvent = runtimeEvents.find((event) => event.type === "task.progress"); + const completedEvent = runtimeEvents.find((event) => event.type === "task.completed"); + assert.equal(progressEvent?.type, "task.progress"); + assert.equal(completedEvent?.type, "task.completed"); + if (progressEvent?.type === "task.progress") { + assert.equal(progressEvent.payload.typedUsage?.totalTokens, 190000); + } + if (completedEvent?.type === "task.completed") { + assert.equal(completedEvent.payload.typedUsage?.totalTokens, 250000); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 87196251c358..92edf87c1796 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2304,13 +2304,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( resultUsageRecord !== undefined && !resultHasActiveUsage && claudeTotalProcessedTokens(resultUsageRecord) !== undefined; - const resultIterationSnapshot = resultUsageRecord - ? normalizeClaudeActiveTokenUsage( - resultUsageRecord, - maxTokens, - accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, - ) - : undefined; + // A result carrying only cumulative total_tokens is not an active-context + // reading; without this gate it would render as a full meter (#6586). + const resultIterationSnapshot = + resultUsageRecord && resultHasActiveUsage + ? normalizeClaudeActiveTokenUsage( + resultUsageRecord, + maxTokens, + accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, + ) + : undefined; const latestAssistantSnapshot = normalizeClaudeActiveTokenUsage( context.turnState?.latestAssistantUsage, maxTokens, From 57e19da38505876113d3db6f997596b528adcecf Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Mon, 14 Sep 2026 23:47:15 -0500 Subject: [PATCH 10/13] fix(server): keep local fallbacks out of parent model tracking --- .../src/provider/Layers/ClaudeAdapter.test.ts | 33 ++++++++++--------- .../src/provider/Layers/ClaudeAdapter.ts | 6 ++-- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 50506f5d9036..96bb884b992b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5369,14 +5369,18 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("follows a persistent refusal fallback to the model that ran", () => { + it.effect.each([ + { scope: undefined, expectedWindow: 200_000 }, + { scope: "session" as const, expectedWindow: 200_000 }, + { scope: "local" as const, expectedWindow: 1_000_000 }, + ])("uses the parent window after a $scope refusal fallback", ({ scope, expectedWindow }) => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5393,20 +5397,21 @@ describe("ClaudeAdapterLive", () => { harness.query.emit({ type: "system", subtype: "init", - model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + model: `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, session_id: "sdk-session-refusal", uuid: "refusal-init", } as unknown as SDKMessage); - // A refusal retry swaps the model for the rest of the session, so the - // window has to be measured against the model that actually ran. + // Session fallbacks change the parent model; local fallbacks do not. harness.query.emit({ type: "system", subtype: "model_refusal_fallback", trigger: "refusal", direction: "retry", - original_model: SYNTHETIC_CLAUDE_STANDARD_MODEL, - fallback_model: `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + ...(scope !== undefined ? { scope } : {}), + original_model: `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + fallback_model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + content: "Retrying with the fallback model.", request_id: null, session_id: "sdk-session-refusal", uuid: "refusal-swap", @@ -5430,18 +5435,14 @@ describe("ClaudeAdapterLive", () => { } as unknown as SDKMessage); harness.query.finish(); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); - + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); const latest = usageEvents.at(-1); assert.equal(latest?.type, "thread.token-usage.updated"); if (latest?.type === "thread.token-usage.updated") { - assert.equal(latest.payload.usage.maxTokens, 1_000_000); + assert.equal(latest.payload.usage.maxTokens, expectedWindow); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 0225235357b9..2b8428f0f3e8 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3786,11 +3786,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } return; case "model_refusal_fallback": { - // A refusal retry swaps the model for the rest of the session, so the - // window lookup has to follow it. `currentApiModelId` deliberately + // A session refusal retry changes the parent model, so the window + // lookup has to follow it. `currentApiModelId` deliberately // does not: it mirrors the user's selection for `setModel`, and moving // it here would make the next turn re-send the refused model. - if (message.direction === "retry") { + if (message.direction === "retry" && message.scope !== "local") { const fallbackModel = trimmedString(message.fallback_model); if (fallbackModel) { context.observedApiModelId = fallbackModel; From 927fda2ec39e9673af5d9af63c7002e1c6f02fcc Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Tue, 15 Sep 2026 00:11:31 -0500 Subject: [PATCH 11/13] fix(server): reject cumulative-only result iterations --- .../src/provider/Layers/ClaudeAdapter.test.ts | 42 +++++++++++++++---- .../src/provider/Layers/ClaudeAdapter.ts | 11 ++--- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 96bb884b992b..7f9f9ba3e585 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5668,7 +5668,31 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("does not treat total-only Claude result usage as active context", () => { + it.effect.each([ + { + name: "does not treat total-only Claude result usage as active context", + usage: { total_tokens: 535_000 }, + expectedUsedTokens: undefined, + }, + { + name: "does not treat a total-only result iteration as active context", + usage: { total_tokens: 535_000, iterations: [{ total_tokens: 535_000 }] }, + expectedUsedTokens: undefined, + }, + { + name: "does not use aggregate input when the selected iteration is total-only", + usage: { input_tokens: 535_000, iterations: [{ total_tokens: 535_000 }] }, + expectedUsedTokens: undefined, + }, + { + name: "uses active input and output from the selected result iteration", + usage: { + input_tokens: 535_000, + iterations: [{ input_tokens: 4_000, output_tokens: 200 }], + }, + expectedUsedTokens: 4_200, + }, + ])("$name", ({ usage, expectedUsedTokens }) => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -5700,9 +5724,7 @@ describe("ClaudeAdapterLive", () => { result: "done", stop_reason: "end_turn", session_id: "sdk-session-result-total-only", - usage: { - total_tokens: 535000, - }, + usage, modelUsage: { [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, @@ -5714,10 +5736,14 @@ describe("ClaudeAdapterLive", () => { const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); - // 535,000 is the session's cumulative spend, not context occupancy. With - // no active reading in the turn there is nothing truthful to show, and - // clamping it to the window rendered a full meter. - assert.equal(usageEvent, undefined); + if (expectedUsedTokens === undefined) { + assert.equal(usageEvent, undefined); + } else { + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.equal(usageEvent.payload.usage.usedTokens, expectedUsedTokens); + } + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2b8428f0f3e8..2bec6d64997f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2528,12 +2528,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage) ? (result.usage as Record) : undefined; - const hasResultUsageIteration = - resultUsageRecord !== undefined && lastClaudeUsageIteration(resultUsageRecord) !== undefined; + const selectedResultUsage = resultUsageRecord + ? (lastClaudeUsageIteration(resultUsageRecord) ?? resultUsageRecord) + : undefined; const resultHasActiveUsage = - resultUsageRecord !== undefined && - (hasResultUsageIteration || - claudeUsageInputTokens(resultUsageRecord) + claudeUsageOutputTokens(resultUsageRecord) > 0); + selectedResultUsage !== undefined && + claudeUsageInputTokens(selectedResultUsage) + claudeUsageOutputTokens(selectedResultUsage) > + 0; const resultTotalOnly = resultUsageRecord !== undefined && !resultHasActiveUsage && From dce55a5a7cdc38360730435a4cd9548cb21234fc Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Tue, 15 Sep 2026 00:32:13 -0500 Subject: [PATCH 12/13] test(server): wait for Claude completion receipts Co-Authored-By: Claude Code --- .../src/provider/Layers/ClaudeAdapter.test.ts | 161 +++++++++--------- 1 file changed, 76 insertions(+), 85 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7f9f9ba3e585..9a52f9a861af 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -4962,10 +4962,10 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "task.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5007,12 +5007,18 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-total-floor", uuid: "total-floor-large", } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-total-floor", + status: "completed", + summary: "Child finished", + usage: { total_tokens: 5_000 }, + session_id: "sdk-session-total-floor", + uuid: "total-floor-completed", + } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); - + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); @@ -5036,10 +5042,10 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "task.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5078,13 +5084,18 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-child-usage", uuid: "task-child-progress-1", } as unknown as SDKMessage); - harness.query.finish(); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-child-1", + status: "completed", + summary: "Background agent finished", + usage: { total_tokens: 900_000, tool_uses: 40, duration_ms: 1_000 }, + session_id: "sdk-session-child-usage", + uuid: "task-child-completed-1", + } as unknown as SDKMessage); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); @@ -5107,10 +5118,10 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); const session = yield* adapter.startSession({ threadId: THREAD_ID, @@ -5143,10 +5154,7 @@ describe("ClaudeAdapterLive", () => { }, } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); @@ -5166,10 +5174,10 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5209,13 +5217,8 @@ describe("ClaudeAdapterLive", () => { [`${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`]: { contextWindow: 1_000_000 }, }, } as unknown as SDKMessage); - harness.query.finish(); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); @@ -5234,10 +5237,10 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5275,12 +5278,15 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-running-total", uuid: "task-running-total-progress", } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-session-running-total", + uuid: "running-total-result", + } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); - + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); @@ -5300,10 +5306,10 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5347,12 +5353,8 @@ describe("ClaudeAdapterLive", () => { usage: { input_tokens: 4_000, output_tokens: 200 }, modelUsage: { [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200_000 } }, } as unknown as SDKMessage); - harness.query.finish(); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const ev = runtimeEvents.filter((e) => e.type === "thread.token-usage.updated"); const latest = ev.at(-1); assert.equal(latest?.type, "thread.token-usage.updated"); @@ -5454,10 +5456,6 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5465,6 +5463,10 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); + const initEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "session.configured", + ).pipe(Stream.runCollect, Effect.forkChild); harness.query.emit({ type: "system", subtype: "init", @@ -5472,10 +5474,12 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-switch", uuid: "switch-init", } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Fiber.join(initEventsFiber); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); // The user switches to a 200k model partway through the thread. yield* adapter.sendTurn({ threadId: THREAD_ID, @@ -5504,13 +5508,8 @@ describe("ClaudeAdapterLive", () => { [SYNTHETIC_CLAUDE_STANDARD_MODEL]: { contextWindow: 200_000 }, }, } as unknown as SDKMessage); - harness.query.finish(); - - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); @@ -5529,10 +5528,10 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.runForEach( + const firstTurnEventsFiber = yield* Stream.takeUntil( adapter.streamEvents, - () => Effect.void, - ).pipe(Effect.forkChild); + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5578,9 +5577,7 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-refusal-resend", usage: { input_tokens: 1_000, output_tokens: 10 }, } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; + yield* Fiber.join(firstTurnEventsFiber); // The selection has not changed, so the second turn must not call // setModel at all — least of all with the model the API just refused. @@ -5593,8 +5590,6 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(harness.query.setModelCalls, [ `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, ]); - - yield* Fiber.interrupt(runtimeEventsFiber); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -5852,10 +5847,10 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runCollect, Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -5922,12 +5917,8 @@ describe("ClaudeAdapterLive", () => { }, }, } as unknown as SDKMessage); - harness.query.finish(); - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Effect.yieldNow; - yield* Fiber.interrupt(runtimeEventsFiber); + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const usageEvents = runtimeEvents.filter( (event) => event.type === "thread.token-usage.updated", ); From 5a233da9e87f7b3a3de0c658a7d7b3fe79ffb621 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Tue, 15 Sep 2026 00:55:23 -0500 Subject: [PATCH 13/13] fix(server): aggregate Claude task usage by task Co-Authored-By: Claude Code --- .../src/provider/Layers/ClaudeAdapter.test.ts | 88 ++++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 70 +++++++++++---- 2 files changed, 136 insertions(+), 22 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 9a52f9a861af..011d9e476f26 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5038,6 +5038,88 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("adds cumulative usage deltas from separate Claude tasks", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "task.completed", + ).pipe(Stream.runCollect, Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "delegate", attachments: [] }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-session-multiple-task-usage", + uuid: "multiple-task-parent-result", + usage: { input_tokens: 4_000, output_tokens: 200 }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-a", + description: "First child working", + usage: { total_tokens: 100_000, tool_uses: 10, duration_ms: 1_000 }, + session_id: "sdk-session-multiple-task-usage", + uuid: "multiple-task-a-progress-1", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-b", + description: "Second child working", + usage: { total_tokens: 100_000, tool_uses: 5, duration_ms: 500 }, + session_id: "sdk-session-multiple-task-usage", + uuid: "multiple-task-b-progress-1", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-a", + description: "First child still working", + usage: { total_tokens: 120_000, tool_uses: 12, duration_ms: 1_200 }, + session_id: "sdk-session-multiple-task-usage", + uuid: "multiple-task-a-progress-2", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-usage-b", + status: "completed", + summary: "Second child finished", + usage: { total_tokens: 100_000, tool_uses: 5, duration_ms: 500 }, + session_id: "sdk-session-multiple-task-usage", + uuid: "multiple-task-b-completed", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + assert.equal(usageEvents.length, 4); + const latest = usageEvents.at(-1); + assert.equal(latest?.type, "thread.token-usage.updated"); + if (latest?.type === "thread.token-usage.updated") { + assert.equal(latest.payload.usage.usedTokens, 4_200); + assert.equal(latest.payload.usage.totalProcessedTokens, 224_200); + assert.equal(latest.payload.usage.toolUses, 17); + assert.equal(latest.payload.usage.durationMs, 1_700); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps subagent tokens out of the parent context meter (#5942)", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -5102,10 +5184,10 @@ describe("ClaudeAdapterLive", () => { const latest = usageEvents.at(-1); assert.equal(latest?.type, "thread.token-usage.updated"); if (latest?.type === "thread.token-usage.updated") { - // The parent's own 4,200 stands; the child's 900,000 only advances - // the running total, never usedTokens. + // The parent's own 4,200 stands. The child's 900,000 advances only + // the separate running total. assert.equal(latest.payload.usage.usedTokens, 4_200); - assert.equal(latest.payload.usage.totalProcessedTokens, 900_000); + assert.equal(latest.payload.usage.totalProcessedTokens, 904_200); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2bec6d64997f..d8eba6aac8d0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -312,6 +312,12 @@ function rememberPendingTaskModel( } } +interface ClaudeTaskCumulativeUsage { + readonly totalTokens: number; + readonly toolUses: number; + readonly durationMs: number; +} + interface ClaudeSessionContext { session: ProviderSession; startInput: Parameters[0]; @@ -360,6 +366,8 @@ interface ClaudeSessionContext { lastKnownContextWindow: number | undefined; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastKnownTotalProcessedTokens: number | undefined; + readonly taskUsageById: Map; + taskUsageTotals: ClaudeTaskCumulativeUsage; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; /** Limits already announced for the running turn, keyed `window:resetsAt`. */ @@ -841,6 +849,7 @@ function compactBoundaryTokenUsageSnapshot( // A subagent's tokens are spent in its own context window, so they advance the // thread's running total but never the parent's used count (#5942). function normalizeClaudeTaskProgressTokenUsage( + taskId: string, value: unknown, context: ClaudeSessionContext, ): ThreadTokenUsageSnapshot | undefined { @@ -849,33 +858,54 @@ function normalizeClaudeTaskProgressTokenUsage( return undefined; } - const lastKnown = context.lastKnownTokenUsage; - if (!lastKnown) { + const usage = value as Record; + // SDK task usage is cumulative per task. Per-task high-water marks keep + // repeated progress and completion receipts from counting the same work twice. + const previous = context.taskUsageById.get(taskId); + const next: ClaudeTaskCumulativeUsage = { + totalTokens: Math.max(previous?.totalTokens ?? 0, totalTokens), + toolUses: Math.max(previous?.toolUses ?? 0, finiteNonNegativeInteger(usage.tool_uses) ?? 0), + durationMs: Math.max( + previous?.durationMs ?? 0, + finiteNonNegativeInteger(usage.duration_ms) ?? 0, + ), + }; + const tokenDelta = next.totalTokens - (previous?.totalTokens ?? 0); + const toolUseDelta = next.toolUses - (previous?.toolUses ?? 0); + const durationDelta = next.durationMs - (previous?.durationMs ?? 0); + if (tokenDelta === 0 && toolUseDelta === 0 && durationDelta === 0) { return undefined; } - // The running total floors at the largest single contributor rather than - // summing per-task totals: the SDK does not document whether result usage - // already aggregates children, and a floor can only understate. - const totalProcessedTokens = Math.max( - totalTokens, - context.lastKnownTotalProcessedTokens ?? totalTokens, - ); - if (totalProcessedTokens === context.lastKnownTotalProcessedTokens) { + context.taskUsageById.set(taskId, next); + context.taskUsageTotals = { + totalTokens: context.taskUsageTotals.totalTokens + tokenDelta, + toolUses: context.taskUsageTotals.toolUses + toolUseDelta, + durationMs: context.taskUsageTotals.durationMs + durationDelta, + }; + const totalProcessedTokens = (context.lastKnownTotalProcessedTokens ?? 0) + tokenDelta; + context.lastKnownTotalProcessedTokens = totalProcessedTokens; + + const lastKnown = context.lastKnownTokenUsage; + if (!lastKnown) { return undefined; } - // Match makeClaudeTokenUsageSnapshot's invariant: a running total is only - // meaningful once it exceeds the parent's own used count. - if (totalProcessedTokens <= lastKnown.usedTokens) { + + const visibleTotal = + totalProcessedTokens > lastKnown.usedTokens ? totalProcessedTokens : undefined; + const toolUses = context.taskUsageTotals.toolUses || undefined; + const durationMs = context.taskUsageTotals.durationMs || undefined; + if ( + visibleTotal === lastKnown.totalProcessedTokens && + toolUses === lastKnown.toolUses && + durationMs === lastKnown.durationMs + ) { return undefined; } - const usage = value as Record; - const toolUses = finiteNonNegativeInteger(usage.tool_uses); - const durationMs = finiteNonNegativeInteger(usage.duration_ms); return { ...lastKnown, - totalProcessedTokens, + ...(visibleTotal !== undefined ? { totalProcessedTokens: visibleTotal } : {}), ...(toolUses !== undefined ? { toolUses } : {}), ...(durationMs !== undefined ? { durationMs } : {}), }; @@ -3636,7 +3666,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "task_progress": { yield* emitThreadTokenUsage( context, - normalizeClaudeTaskProgressTokenUsage(message.usage, context), + normalizeClaudeTaskProgressTokenUsage(message.task_id, message.usage, context), { rawMethod: "claude/system/task_progress", rawPayload: message, @@ -3703,7 +3733,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.liveTaskIds.delete(message.task_id); yield* emitThreadTokenUsage( context, - normalizeClaudeTaskProgressTokenUsage(message.usage, context), + normalizeClaudeTaskProgressTokenUsage(message.task_id, message.usage, context), { rawMethod: "claude/system/task_notification", rawPayload: message, @@ -4903,6 +4933,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, lastKnownTotalProcessedTokens: undefined, + taskUsageById: new Map(), + taskUsageTotals: { totalTokens: 0, toolUses: 0, durationMs: 0 }, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, announcedUsageLimits: undefined,