diff --git a/src/supervisor/agents/claude/canonicalMapping/dispatch.ts b/src/supervisor/agents/claude/canonicalMapping/dispatch.ts index c99454e8..ee9b7e92 100644 --- a/src/supervisor/agents/claude/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/claude/canonicalMapping/dispatch.ts @@ -2,7 +2,12 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { RuntimeEvent, ToolCallProgress, TurnState } from "@/shared/contracts"; import { readFileChangePath, readStringField } from "../../fileChangeSummary"; import type { ClaudeMapperState } from "../sdkCanonicalMappingState"; -import { applyActiveGoalMessage, completeActiveGoalEvents, isActiveGoalMessage } from "./goal"; +import { + accumulateActiveGoalAssistantSpend, + applyActiveGoalMessage, + completeActiveGoalEvents, + isActiveGoalMessage, +} from "./goal"; import { extractCompletedStringFields, extractText, @@ -376,15 +381,21 @@ function mapClaudeSdkMessageInner( state.usageScope && readClaudeAssistantSpendTokens(message) !== undefined ? createClaudeUsageSpentEvent(state.threadId, message, state.usageScope.sample()) : undefined; + // The active goal's running total shares the exact same per-call spend — + // accumulated here so goal tokens and Profile tokens can never diverge. + const goalSpendEvent = accumulateActiveGoalAssistantSpend(state, message); // Sub-agent (parent-attributed) whole messages must not touch the shared // main-lane per-index maps — index 0 of a sub-agent message would collide // with the main thread's streaming block at index 0. Emit self-contained, // already-complete child items instead (tagParent attaches parentItemId). if (readParentToolUseId(message)) { const subAgentEvents = flushSubAgentAssistantMessage(message, state); - return usageSpentEvent ? [usageSpentEvent, ...subAgentEvents] : subAgentEvents; + return [usageSpentEvent, goalSpendEvent, ...subAgentEvents].filter( + (event): event is RuntimeEvent => event !== undefined, + ); } if (usageSpentEvent) events.push(usageSpentEvent); + if (goalSpendEvent) events.push(goalSpendEvent); const messageId = readClaudeAssistantMessageId(message.message); const skipTextSnapshot = messageId ? state.streamedAssistantMessageIds.has(messageId) : false; const content = (message.message as { content?: unknown }).content; @@ -537,7 +548,7 @@ function mapClaudeSdkMessageInner( const msg = extractResultErrorMessage(message) ?? "Claude turn failed."; events.push({ type: "error", threadId: state.threadId, message: msg }); } - events.push(...completeActiveGoalEvents(state, message, stateValue)); + events.push(...completeActiveGoalEvents(state, stateValue)); if (state.currentTurnId) { events.push({ type: "turn.completed", diff --git a/src/supervisor/agents/claude/canonicalMapping/goal.ts b/src/supervisor/agents/claude/canonicalMapping/goal.ts index f629f095..520ba219 100644 --- a/src/supervisor/agents/claude/canonicalMapping/goal.ts +++ b/src/supervisor/agents/claude/canonicalMapping/goal.ts @@ -1,6 +1,5 @@ -import type { SDKActiveGoalMessage, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { SDKActiveGoalMessage, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import type { RuntimeEvent, TurnState } from "@/shared/contracts"; -import { readNonNegativeInteger } from "../../contextUsage"; import { goalPayloadFromProviderState, startGoalItemEvents, @@ -9,7 +8,7 @@ import { } from "../../goalRuntime"; import type { ClaudeMapperState } from "../sdkCanonicalMappingState"; import { newItemId } from "./helpers"; -import { readClaudeResultUsage } from "./result"; +import { readClaudeAssistantSpendTokens, readClaudeAssistantUsageSampleId } from "./usageSpent"; type ActiveGoalState = ClaudeMapperState & { activeGoalItemId: string; @@ -26,9 +25,8 @@ function hasActiveGoal(state: ClaudeMapperState): state is ActiveGoalState { } export function resetActiveGoalTokenAccounting(state: ClaudeMapperState): void { - delete state.activeGoalCompletedTurnTokensUsed; - delete state.activeGoalLiveApiTokensUsed; - delete state.activeGoalTaskTokensByKey; + delete state.activeGoalTokensUsed; + delete state.activeGoalUsageSampleIds; } export function clearActiveGoal(state: ClaudeMapperState): void { @@ -75,10 +73,10 @@ export function applyActiveGoalMessage( const objective = value.condition.trim(); if (!objective) return []; - state.activeGoalIterations = value.iterations; - if (typeof value.last_reason === "string" && value.last_reason.trim().length > 0) { - state.activeGoalLastReason = value.last_reason.trim(); - } + const lastReason = + typeof value.last_reason === "string" && value.last_reason.trim().length > 0 + ? value.last_reason.trim() + : undefined; if (!hasActiveGoal(state)) { // A goal can be armed natively without a local `/goal` turn — most @@ -89,6 +87,8 @@ export function applyActiveGoalMessage( state.activeGoalObjective = objective; state.activeGoalStartedAtMs = epochSecondsToMs(value.set_at) ?? Date.now(); resetActiveGoalTokenAccounting(state); + state.activeGoalIterations = value.iterations; + if (lastReason) state.activeGoalLastReason = lastReason; if (!hasActiveGoal(state)) return []; // unreachable; re-narrows after mutation return startGoalItemEvents( state.threadId, @@ -97,8 +97,17 @@ export function applyActiveGoalMessage( ); } - // A new `/goal` replacing the old one mid-session also lands here. - state.activeGoalObjective = objective; + // A new `/goal` replacing the old one mid-session also lands here. A changed + // condition is a NEW goal: restart its clock and token accounting so the + // dock never carries the previous goal's spend into the replacement. + if (state.activeGoalObjective !== objective) { + state.activeGoalObjective = objective; + state.activeGoalStartedAtMs = epochSecondsToMs(value.set_at) ?? Date.now(); + resetActiveGoalTokenAccounting(state); + delete state.activeGoalLastReason; + } + state.activeGoalIterations = value.iterations; + if (lastReason) state.activeGoalLastReason = lastReason; return [activeGoalUpdatedEvent(state)]; } @@ -112,22 +121,22 @@ function completeGoalFromEvaluatorVerdict(state: ClaudeMapperState): RuntimeEven export function completeActiveGoalEvents( state: ClaudeMapperState, - message: Extract, turnState: TurnState, ): RuntimeEvent[] { if (!hasActiveGoal(state)) return []; - const usage = readClaudeResultUsage(message); - if (usage !== undefined) { - state.activeGoalCompletedTurnTokensUsed = - (state.activeGoalCompletedTurnTokensUsed ?? 0) + usage; - } - + // Goal token spend is NOT read from the turn `result`: the CLI reports + // `result.usage` as a session-cumulative counter (all models, all + // sidechains, since session start), so adding it per turn would count + // pre-goal spend and multiply-count later turns. Per-call assistant-message + // spend is accumulated live by accumulateActiveGoalAssistantSpend instead; + // here we only roll the aggregate/time snapshot forward. + // // While the native Stop-hook evaluator is live, a turn `result` is not a // goal outcome — the evaluator keeps starting turns until the condition is - // met and reports that via `active_goal: null`. Only roll the usage/time - // counters forward here. Without native goal frames (older CLI), fall back - // to treating a clean turn end as completion so the dock never sticks. + // met and reports that via `active_goal: null`. Without native goal frames + // (older CLI), fall back to treating a clean turn end as completion so the + // dock never sticks. if (turnState === "interrupted" || state.sawActiveGoalMessage) { return [activeGoalUpdatedEvent(state)]; } @@ -178,10 +187,13 @@ function hasLiveSubAgentTaskEntries(state: ClaudeMapperState): boolean { */ function activeGoalProviderState(state: ActiveGoalState): ProviderGoalState { const nowMs = Date.now(); - const tokensUsed = activeGoalAggregateTokens(state); return { objective: state.activeGoalObjective, - ...(tokensUsed !== undefined ? { tokensUsed } : {}), + // Always carry the counter (0 before the first spend): the renderer merges + // goal payloads shallowly, so omitting the field would leave a replaced + // goal's total on the dock after resetActiveGoalTokenAccounting. The dock + // hides a zero total (it only renders tokensUsed > 0). + tokensUsed: state.activeGoalTokensUsed ?? 0, timeUsedSeconds: Math.max(0, Math.round((nowMs - state.activeGoalStartedAtMs) / 1000)), ...(state.activeGoalIterations !== undefined ? { iterations: state.activeGoalIterations } : {}), ...(state.activeGoalLastReason ? { lastReason: state.activeGoalLastReason } : {}), @@ -201,67 +213,39 @@ function activeGoalUpdatedEvent(state: ActiveGoalState): RuntimeEvent { }; } -export function emitActiveGoalTokenUpdate( +/** + * Fold one assistant API message's per-call spend into the active goal's + * running total. Called for every assistant message — main thread and + * subagent sidechain alike — at the same point the Profile usage ledger's + * `usage.spent` event is created, so the goal dock and the Profile token + * stats share one exact spend definition. Emits a dock update on growth. + */ +export function accumulateActiveGoalAssistantSpend( state: ClaudeMapperState, - tokensUsed: number, + message: SDKAssistantMessage, ): RuntimeEvent | undefined { if (!hasActiveGoal(state)) return undefined; - state.activeGoalLiveApiTokensUsed = Math.max(state.activeGoalLiveApiTokensUsed ?? 0, tokensUsed); - return emitActiveGoalAggregateTokenUpdate(state); + const spend = readClaudeAssistantSpendTokens(message); + if (spend === undefined || spend <= 0) return undefined; + const sampleId = readClaudeAssistantUsageSampleId(message); + const sampleIds = (state.activeGoalUsageSampleIds ??= new Set()); + if (sampleIds.has(sampleId)) return undefined; + sampleIds.add(sampleId); + state.activeGoalTokensUsed = (state.activeGoalTokensUsed ?? 0) + spend; + return activeGoalUpdatedEvent(state); } -function emitActiveGoalAggregateTokenUpdate(state: ClaudeMapperState): RuntimeEvent | undefined { +/** + * 15s goal-tracking poll tick: re-emit the current totals so the dock's + * elapsed-time display rolls forward even while no assistant message lands. + */ +export function emitActiveGoalTick(state: ClaudeMapperState): RuntimeEvent | undefined { if (!hasActiveGoal(state)) return undefined; - if (activeGoalAggregateTokens(state) === undefined) return undefined; return activeGoalUpdatedEvent(state); } -function activeGoalAggregateTokens(state: ClaudeMapperState): number | undefined { - const baseTokens = Math.max( - state.activeGoalCompletedTurnTokensUsed ?? 0, - state.activeGoalLiveApiTokensUsed ?? 0, - ); - const taskTokens = sumActiveGoalTaskTokens(state); - const totalTokens = baseTokens + taskTokens; - return totalTokens > 0 ? totalTokens : undefined; -} - -function sumActiveGoalTaskTokens(state: ClaudeMapperState): number { - let total = 0; - for (const tokens of state.activeGoalTaskTokensByKey?.values() ?? []) total += tokens; - return total; -} - function epochSecondsToMs(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; // Guard against the field ever arriving in milliseconds. return value > 1_000_000_000_000 ? value : value * 1000; } - -export function emitActiveGoalTaskUsageUpdate( - state: ClaudeMapperState, - message: { task_id?: unknown; tool_use_id?: unknown }, - usage: { total_tokens?: number; tool_uses?: number; duration_ms?: number } | undefined, -): RuntimeEvent | undefined { - if (!hasActiveGoal(state)) return undefined; - const totalTokens = readNonNegativeInteger(usage?.total_tokens); - if (totalTokens === undefined || totalTokens <= 0) return undefined; - - const key = activeGoalTaskUsageKey(message); - if (!key) return undefined; - - const taskTokens = (state.activeGoalTaskTokensByKey ??= new Map()); - const previous = taskTokens.get(key) ?? 0; - if (totalTokens <= previous) return undefined; - taskTokens.set(key, totalTokens); - return emitActiveGoalAggregateTokenUpdate(state); -} - -function activeGoalTaskUsageKey(message: { - task_id?: unknown; - tool_use_id?: unknown; -}): string | undefined { - const taskId = typeof message.task_id === "string" ? message.task_id : undefined; - const toolUseId = typeof message.tool_use_id === "string" ? message.tool_use_id : undefined; - return taskId ?? toolUseId; -} diff --git a/src/supervisor/agents/claude/canonicalMapping/result.ts b/src/supervisor/agents/claude/canonicalMapping/result.ts index 9cbdd9a9..ea44af99 100644 --- a/src/supervisor/agents/claude/canonicalMapping/result.ts +++ b/src/supervisor/agents/claude/canonicalMapping/result.ts @@ -59,21 +59,12 @@ export function extractResultErrorMessage(message: SDKMessage): string | undefin return undefined; } -export function readClaudeResultUsage( - message: Extract, -): number | undefined { - const usage = (message as { usage?: unknown }).usage; - return readClaudeUsageSpendTokens(usage, { fallbackToTotalTokens: true }); -} - +/** + * One API call's token spend: input + output + cache creation + cache read. + * Used for per-assistant-message usage — never for the turn `result`, whose + * usage the CLI reports as a session-cumulative counter. + */ export function readClaudeApiUsageSpendTokens(usage: unknown): number | undefined { - return readClaudeUsageSpendTokens(usage, { fallbackToTotalTokens: false }); -} - -function readClaudeUsageSpendTokens( - usage: unknown, - options: { fallbackToTotalTokens: boolean }, -): number | undefined { if (!usage || typeof usage !== "object") return undefined; const record = usage as Record; const input = readNonNegativeInteger(record.input_tokens) ?? 0; @@ -81,8 +72,7 @@ function readClaudeUsageSpendTokens( const cacheCreation = readNonNegativeInteger(record.cache_creation_input_tokens) ?? 0; const cacheRead = readNonNegativeInteger(record.cache_read_input_tokens) ?? 0; const sum = input + output + cacheCreation + cacheRead; - if (sum > 0) return sum; - return options.fallbackToTotalTokens ? readNonNegativeInteger(record.total_tokens) : undefined; + return sum > 0 ? sum : undefined; } export function mapResultState(message: Extract): TurnState { diff --git a/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts b/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts index d971f570..43be8c02 100644 --- a/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts +++ b/src/supervisor/agents/claude/canonicalMapping/taskLifecycle.ts @@ -1,7 +1,6 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { RuntimeEvent, ToolCallProgress } from "@/shared/contracts"; import type { ClaudeMapperState, ToolItemState } from "../sdkCanonicalMappingState"; -import { emitActiveGoalTaskUsageUpdate } from "./goal"; import { classifyToolItemType, isSubAgentToolName } from "./toolClassification"; import { syncSubAgentModelProgress } from "./toolItems"; import { toolPayload } from "./toolPayload"; @@ -66,13 +65,16 @@ function mergeTaskProgress( * Absorb a `task_started` / `task_progress` / `task_notification` system * message into the parent Task tool_call's progress field. Lets a collapsed * sub-agent row show its current step without expanding to read the children. + * + * The per-task `usage` is reflected on the tool progress only — it must NOT + * feed goal/session token totals: sidechain spend is already counted exactly + * once from the sub-agent's own assistant messages, and task usage is a + * cumulative-per-task counter that would double-count it. */ export function applyTaskLifecycle(message: SDKMessage, state: ClaudeMapperState): RuntimeEvent[] { const events: RuntimeEvent[] = []; const obj = message as TaskLifecycleMessage; const usage = readTaskUsage(obj); - const goalUsage = emitActiveGoalTaskUsageUpdate(state, obj, usage); - if (goalUsage) events.push(goalUsage); const toolUseId = typeof obj.tool_use_id === "string" ? obj.tool_use_id : undefined; if (!toolUseId) return events; @@ -180,8 +182,6 @@ export function applyTaskNotification( const events: RuntimeEvent[] = []; const usage = readTaskUsage(obj); - const goalUsage = emitActiveGoalTaskUsageUpdate(state, obj, usage); - if (goalUsage) events.push(goalUsage); const tool = state.toolItemsById.get(registeredToolUseId); if (!tool) { diff --git a/src/supervisor/agents/claude/canonicalMapping/usageSpent.ts b/src/supervisor/agents/claude/canonicalMapping/usageSpent.ts index dd5d599b..0459ddf1 100644 --- a/src/supervisor/agents/claude/canonicalMapping/usageSpent.ts +++ b/src/supervisor/agents/claude/canonicalMapping/usageSpent.ts @@ -53,25 +53,29 @@ export function readClaudeAssistantSpendTokens(message: SDKAssistantMessage): nu return readClaudeApiUsageSpendTokens(message.message?.usage); } -export function createClaudeUsageSpentEvent( - threadId: string, - message: SDKAssistantMessage, - meta: { scopeId: string; epoch: number; fresh?: boolean }, -): RuntimeEvent | undefined { - const counter = readClaudeAssistantSpendTokens(message); - if (counter === undefined) return undefined; +export function readClaudeAssistantUsageSampleId(message: SDKAssistantMessage): string { const apiMessageId = readClaudeAssistantMessageId(message.message); const requestId = typeof message.request_id === "string" && message.request_id.length > 0 ? message.request_id : undefined; - // Stable per API message (`msg_…:req_…`) so replays dedup exactly once in - // the ledger; fall back to the envelope uuid when the payload has no id. - const sampleId = apiMessageId + return apiMessageId ? requestId ? `${apiMessageId}:${requestId}` : apiMessageId : `uuid:${message.uuid}`; +} + +export function createClaudeUsageSpentEvent( + threadId: string, + message: SDKAssistantMessage, + meta: { scopeId: string; epoch: number; fresh?: boolean }, +): RuntimeEvent | undefined { + const counter = readClaudeAssistantSpendTokens(message); + if (counter === undefined) return undefined; + // Stable per API message (`msg_…:req_…`) so replays dedup exactly once in + // the ledger; fall back to the envelope uuid when the payload has no id. + const sampleId = readClaudeAssistantUsageSampleId(message); const model = typeof message.message?.model === "string" ? message.message.model : undefined; return { type: "usage.spent", diff --git a/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts b/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts index ba570489..d3cd2945 100644 --- a/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts +++ b/src/supervisor/agents/claude/sdkCanonicalMapping.test.ts @@ -1,10 +1,16 @@ -import type { SDKControlGetContextUsageResponse, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { + SDKAssistantMessage, + SDKControlGetContextUsageResponse, + SDKMessage, +} from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it, vi } from "vitest"; +import type { RuntimeEvent } from "@/shared/contracts"; import { + accumulateActiveGoalAssistantSpend, buildClaudeQuestionAnswerEvents, ClaudeUsageScopeTracker, createClaudeMapperState, - emitActiveGoalTokenUpdate, + emitActiveGoalTick, mapClaudeContextUsageResponse, mapClaudePermissionRequest, mapClaudeQuestionRequest, @@ -17,6 +23,32 @@ function streamEvent(event: Record): SDKMessage { return { type: "stream_event", session_id: "claude-session", event } as unknown as SDKMessage; } +/** Whole assistant message carrying per-call API usage (main lane or sidechain). */ +function assistantUsageMessage( + id: string, + usage: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }, + parentToolUseId?: string, +): SDKAssistantMessage { + return { + type: "assistant", + session_id: "claude-session", + uuid: `uuid-${id}`, + parent_tool_use_id: parentToolUseId ?? null, + message: { + id, + role: "assistant", + model: "claude-opus-4-8", + content: [{ type: "text", text: "working" }], + usage, + }, + } as unknown as SDKAssistantMessage; +} + function activeGoalMessage( value: { condition: string; @@ -137,11 +169,23 @@ describe("sdkCanonicalMapping — prompt content", () => { ); vi.setSystemTime(new Date("2026-05-12T10:02:05Z")); + // Goal spend accumulates from per-call assistant-message usage… + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { + input_tokens: 60_000, + output_tokens: 8_000, + cache_read_input_tokens: 1_000, + cache_creation_input_tokens: 500, + }), + state, + ); const resultEvents = mapClaudeSdkMessage( { type: "result", subtype: "success", session_id: "claude-session", + // …never from the result: the CLI reports this as a session-cumulative + // counter, so it must be ignored for goal totals. usage: { input_tokens: 60_000, output_tokens: 8_000, @@ -204,6 +248,10 @@ describe("sdkCanonicalMapping — prompt content", () => { ); vi.setSystemTime(new Date("2026-05-12T10:01:00Z")); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { input_tokens: 30_000, output_tokens: 4_000 }), + state, + ); const interruptedResult = mapClaudeSdkMessage( { type: "result", @@ -211,6 +259,7 @@ describe("sdkCanonicalMapping — prompt content", () => { is_error: true, errors: ["[ede_diagnostic] turn interrupted before assistant content"], session_id: "claude-session", + // Session-cumulative — ignored for goal totals. usage: { input_tokens: 30_000, output_tokens: 4_000, total_tokens: 34_000 }, } as unknown as SDKMessage, state, @@ -232,15 +281,19 @@ describe("sdkCanonicalMapping — prompt content", () => { }); expect(state.activeGoalItemId).toBe("goal-turn-goal"); - expect(state.activeGoalCompletedTurnTokensUsed).toBe(34_000); + expect(state.activeGoalTokensUsed).toBe(34_000); vi.setSystemTime(new Date("2026-05-12T10:03:00Z")); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-2", { input_tokens: 50_000, output_tokens: 8_000 }), + state, + ); const successResult = mapClaudeSdkMessage( { type: "result", subtype: "success", session_id: "claude-session", - usage: { input_tokens: 50_000, output_tokens: 8_000, total_tokens: 58_000 }, + usage: { input_tokens: 92_000, output_tokens: 12_000, total_tokens: 104_000 }, } as unknown as SDKMessage, state, ); @@ -269,6 +322,10 @@ describe("sdkCanonicalMapping — prompt content", () => { startClaudeTurn(state, "turn-goal", "/goal fix the bug", undefined, "user-goal"); vi.setSystemTime(new Date("2026-05-12T10:00:30Z")); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { input_tokens: 8_000, output_tokens: 2_000 }), + state, + ); mapClaudeSdkMessage( { type: "result", @@ -286,7 +343,7 @@ describe("sdkCanonicalMapping — prompt content", () => { expect(state.activeGoalItemId).toBe("goal-turn-goal"); expect(state.activeGoalObjective).toBe("fix the bug"); - expect(state.activeGoalCompletedTurnTokensUsed).toBe(10_000); + expect(state.activeGoalTokensUsed).toBe(10_000); } finally { vi.useRealTimers(); } @@ -300,6 +357,10 @@ describe("sdkCanonicalMapping — prompt content", () => { startClaudeTurn(state, "turn-goal-1", "/goal old objective", undefined); vi.setSystemTime(new Date("2026-05-12T10:00:30Z")); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { input_tokens: 4_000, output_tokens: 1_000 }), + state, + ); mapClaudeSdkMessage( { type: "result", @@ -318,7 +379,7 @@ describe("sdkCanonicalMapping — prompt content", () => { expect(state.activeGoalItemId).toBe("goal-turn-goal-2"); expect(state.activeGoalObjective).toBe("new objective"); expect(state.activeGoalStartedAtMs).toBe(Date.now()); - expect(state.activeGoalCompletedTurnTokensUsed).toBeUndefined(); + expect(state.activeGoalTokensUsed).toBeUndefined(); } finally { vi.useRealTimers(); } @@ -361,7 +422,7 @@ describe("sdkCanonicalMapping — prompt content", () => { expect(state.activeGoalItemId).toBeUndefined(); }); - it("does not lower goal token usage when a final result reports fewer tokens than live spend", () => { + it("ignores session-cumulative result usage for goal token totals", () => { const state = createClaudeMapperState("thread-1"); vi.useFakeTimers(); try { @@ -369,7 +430,10 @@ describe("sdkCanonicalMapping — prompt content", () => { startClaudeTurn(state, "turn-goal", "/goal ship it", undefined); vi.setSystemTime(new Date("2026-05-12T10:00:45Z")); - emitActiveGoalTokenUpdate(state, 42_000); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { input_tokens: 40_000, output_tokens: 2_000 }), + state, + ); vi.setSystemTime(new Date("2026-05-12T10:01:00Z")); const resultEvents = mapClaudeSdkMessage( @@ -377,7 +441,9 @@ describe("sdkCanonicalMapping — prompt content", () => { type: "result", subtype: "success", session_id: "claude-session", - usage: { total_tokens: 4_000 }, + // The CLI's result usage is session-cumulative (includes pre-goal and + // sidechain spend): counting it would corrupt the goal total. + usage: { total_tokens: 4_000_000 }, } as unknown as SDKMessage, state, ); @@ -539,6 +605,10 @@ describe("sdkCanonicalMapping — prompt content", () => { ); vi.setSystemTime(new Date("2026-05-12T10:01:00Z")); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { input_tokens: 15_000, output_tokens: 5_000 }), + state, + ); const resultEvents = mapClaudeSdkMessage( { type: "result", @@ -582,6 +652,10 @@ describe("sdkCanonicalMapping — prompt content", () => { }), state, ); + mapClaudeSdkMessage( + assistantUsageMessage("msg-goal-1", { input_tokens: 35_000, output_tokens: 5_000 }), + state, + ); mapClaudeSdkMessage( { type: "result", @@ -2315,15 +2389,17 @@ describe("sdkCanonicalMapping — task progress", () => { expect(events).toEqual([]); }); - it("adds deduped task usage to active goal token totals", () => { + it("does not count task_progress/task_notification usage toward goal tokens", () => { const state = createClaudeMapperState("thread-1"); vi.useFakeTimers(); try { vi.setSystemTime(new Date("2026-05-12T10:00:00Z")); startClaudeTurn(state, "turn-goal", "/goal count subagent tokens", undefined); - vi.setSystemTime(new Date("2026-05-12T10:00:20Z")); - const firstProgress = mapClaudeSdkMessage( + // Task usage is a cumulative-per-task counter over the same sidechain + // calls whose assistant messages already fed the goal total — counting + // it here would double-count subagent spend. + const progress = mapClaudeSdkMessage( { type: "system", subtype: "task_progress", @@ -2335,39 +2411,14 @@ describe("sdkCanonicalMapping — task progress", () => { } as unknown as SDKMessage, state, ); - expect(firstProgress).toContainEqual( - expect.objectContaining({ - type: "item.updated", - itemId: "goal-turn-goal", - payload: expect.objectContaining({ - status: "active", - tokensUsed: 4_200, - }), - }), - ); - - vi.setSystemTime(new Date("2026-05-12T10:00:30Z")); - const secondProgress = mapClaudeSdkMessage( - { - type: "system", - subtype: "task_progress", - session_id: "claude-session", - task_id: "task-1", - tool_use_id: "toolu_T1", - description: "Reading", - usage: { total_tokens: 5_000, tool_uses: 4, duration_ms: 2_000 }, - } as unknown as SDKMessage, - state, - ); - expect(secondProgress).toContainEqual( - expect.objectContaining({ - type: "item.updated", - itemId: "goal-turn-goal", - payload: expect.objectContaining({ tokensUsed: 5_000 }), - }), - ); + expect( + progress.some( + (event) => event.type === "item.updated" && event.itemId === "goal-turn-goal", + ), + ).toBe(false); + expect(state.activeGoalTokensUsed).toBeUndefined(); - const lowerDuplicate = mapClaudeSdkMessage( + const notification = mapClaudeSdkMessage( { type: "system", subtype: "task_notification", @@ -2380,10 +2431,51 @@ describe("sdkCanonicalMapping — task progress", () => { state, ); expect( - lowerDuplicate.some( + notification.some( (event) => event.type === "item.updated" && event.itemId === "goal-turn-goal", ), ).toBe(false); + expect(state.activeGoalTokensUsed).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it("counts subagent sidechain assistant spend in goal token totals", () => { + const state = createClaudeMapperState("thread-1"); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-12T10:00:00Z")); + startClaudeTurn(state, "turn-goal", "/goal count subagent tokens", undefined); + + vi.setSystemTime(new Date("2026-05-12T10:00:20Z")); + const subAgentEvents = mapClaudeSdkMessage( + assistantUsageMessage( + "msg-sub-1", + { input_tokens: 4_000, output_tokens: 200, cache_read_input_tokens: 3_000 }, + "toolu_T1", + ), + state, + ); + expect(subAgentEvents).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: "goal-turn-goal", + payload: expect.objectContaining({ status: "active", tokensUsed: 7_200 }), + }), + ); + + const mainEvents = mapClaudeSdkMessage( + assistantUsageMessage("msg-main-1", { input_tokens: 10, output_tokens: 5 }), + state, + ); + expect(mainEvents).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: "goal-turn-goal", + payload: expect.objectContaining({ status: "active", tokensUsed: 7_215 }), + }), + ); vi.setSystemTime(new Date("2026-05-12T10:01:00Z")); const resultEvents = mapClaudeSdkMessage( @@ -2391,7 +2483,7 @@ describe("sdkCanonicalMapping — task progress", () => { type: "result", subtype: "success", session_id: "claude-session", - usage: { input_tokens: 10, output_tokens: 5 }, + usage: { total_tokens: 7_215 }, } as unknown as SDKMessage, state, ); @@ -2399,10 +2491,7 @@ describe("sdkCanonicalMapping — task progress", () => { expect.objectContaining({ type: "item.updated", itemId: "goal-turn-goal", - payload: expect.objectContaining({ - status: "complete", - tokensUsed: 5_015, - }), + payload: expect.objectContaining({ status: "complete", tokensUsed: 7_215 }), }), ); } finally { @@ -3625,8 +3714,8 @@ describe("sdkCanonicalMapping — requests", () => { }); }); -describe("sdkCanonicalMapping — emitActiveGoalTokenUpdate", () => { - it("emits a goal item.updated with spend tokens when a goal is active", () => { +describe("sdkCanonicalMapping — goal token accumulation", () => { + it("accumulates per-call assistant spend and emits a goal update on growth", () => { const state = createClaudeMapperState("thread-1"); vi.useFakeTimers(); try { @@ -3634,7 +3723,11 @@ describe("sdkCanonicalMapping — emitActiveGoalTokenUpdate", () => { startClaudeTurn(state, "turn-goal", "/goal ship it", undefined); vi.setSystemTime(new Date("2026-05-12T10:00:45Z")); - const event = emitActiveGoalTokenUpdate(state, 42_000); + const message = assistantUsageMessage("msg-1", { + input_tokens: 40_000, + output_tokens: 2_000, + }); + const event = accumulateActiveGoalAssistantSpend(state, message); expect(event).toMatchObject({ type: "item.updated", @@ -3647,14 +3740,166 @@ describe("sdkCanonicalMapping — emitActiveGoalTokenUpdate", () => { timeUsedSeconds: 45, }, }); + + const second = accumulateActiveGoalAssistantSpend( + state, + assistantUsageMessage("msg-2", { input_tokens: 1_000, output_tokens: 0 }), + ); + expect(second).toMatchObject({ payload: { tokensUsed: 43_000 } }); + expect(accumulateActiveGoalAssistantSpend(state, message)).toBeUndefined(); + expect(state.activeGoalTokensUsed).toBe(43_000); + } finally { + vi.useRealTimers(); + } + }); + + it("skips assistant messages without usage and runs without an active goal", () => { + const state = createClaudeMapperState("thread-1"); + startClaudeTurn(state, "turn-goal", "/goal ship it", undefined); + const noUsage = { + type: "assistant", + session_id: "claude-session", + uuid: "uuid-no-usage", + parent_tool_use_id: null, + message: { + id: "msg-no-usage", + role: "assistant", + model: "claude-opus-4-8", + content: [{ type: "text", text: "hi" }], + }, + } as unknown as SDKAssistantMessage; + expect(accumulateActiveGoalAssistantSpend(state, noUsage)).toBeUndefined(); + + const idleState = createClaudeMapperState("thread-2"); + expect( + accumulateActiveGoalAssistantSpend( + idleState, + assistantUsageMessage("msg-3", { input_tokens: 1_000, output_tokens: 0 }), + ), + ).toBeUndefined(); + }); + + it("emits a tick with current totals only while a goal is active", () => { + const state = createClaudeMapperState("thread-1"); + expect(emitActiveGoalTick(state)).toBeUndefined(); + + startClaudeTurn(state, "turn-goal", "/goal ship it", undefined); + accumulateActiveGoalAssistantSpend( + state, + assistantUsageMessage("msg-1", { input_tokens: 1_000, output_tokens: 0 }), + ); + const tick = emitActiveGoalTick(state); + expect(tick).toMatchObject({ + type: "item.updated", + itemId: "goal-turn-goal", + payload: { status: "active", tokensUsed: 1_000 }, + }); + }); + + it("resets goal tokens and clock when a native verdict replaces the objective", () => { + const state = createClaudeMapperState("thread-1"); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-05-12T10:00:00Z")); + startClaudeTurn(state, "turn-goal", "/goal old objective", undefined); + accumulateActiveGoalAssistantSpend( + state, + assistantUsageMessage("msg-1", { input_tokens: 9_000, output_tokens: 1_000 }), + ); + mapClaudeSdkMessage( + activeGoalMessage({ + condition: "old objective", + iterations: 2, + set_at: Date.parse("2026-05-12T10:00:00Z") / 1000, + tokens_at_start: 0, + last_reason: "old reason", + }), + state, + ); + + vi.setSystemTime(new Date("2026-05-12T10:10:00Z")); + const events = mapClaudeSdkMessage( + activeGoalMessage({ + condition: "new objective", + iterations: 1, + set_at: Date.parse("2026-05-12T10:10:00Z") / 1000, + tokens_at_start: 0, + }), + state, + ); + + expect(state.activeGoalObjective).toBe("new objective"); + expect(state.activeGoalTokensUsed).toBeUndefined(); + expect(state.activeGoalLastReason).toBeUndefined(); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item.updated", + itemId: "goal-turn-goal", + payload: expect.objectContaining({ + objective: "new objective", + timeUsedSeconds: 0, + }), + }), + ); + // tokensUsed resets to 0 rather than disappearing: the renderer merges + // goal payloads shallowly, so omitting it would keep the replaced + // goal's total on the dock until new spend accumulates. + const goalUpdate = events.find( + (event): event is Extract => + event.type === "item.updated" && event.itemId === "goal-turn-goal", + ); + expect(goalUpdate?.payload).toMatchObject({ tokensUsed: 0 }); } finally { vi.useRealTimers(); } }); - it("returns undefined when no goal is active", () => { + it("keeps goal tokens identical to the Profile usage.spent ledger sum", () => { const state = createClaudeMapperState("thread-1"); - expect(emitActiveGoalTokenUpdate(state, 1_000)).toBeUndefined(); + state.usageScope = new ClaudeUsageScopeTracker("claude-session", true); + startClaudeTurn(state, "turn-goal", "/goal consistent totals", undefined); + + const stream: SDKMessage[] = [ + assistantUsageMessage("msg-1", { input_tokens: 10_000, output_tokens: 500 }), + // Subagent sidechain call — counted by both. + assistantUsageMessage( + "msg-2", + { input_tokens: 4_000, output_tokens: 200, cache_read_input_tokens: 900 }, + "toolu_T1", + ), + // Cumulative-per-task counter — excluded from both. + { + type: "system", + subtype: "task_notification", + session_id: "claude-session", + task_id: "task-1", + status: "completed", + summary: "Done", + usage: { total_tokens: 5_100, tool_uses: 4, duration_ms: 2_100 }, + } as unknown as SDKMessage, + assistantUsageMessage("msg-3", { input_tokens: 2_000, output_tokens: 100 }), + // Session-cumulative — excluded from both. + { + type: "result", + subtype: "success", + session_id: "claude-session", + usage: { total_tokens: 999_999 }, + } as unknown as SDKMessage, + ]; + + let spentSum = 0; + let completedTokens: number | undefined; + for (const message of stream) { + for (const event of mapClaudeSdkMessage(message, state)) { + if (event.type === "usage.spent") spentSum += event.usage.counter; + if (event.type === "item.updated" && event.itemId === "goal-turn-goal") { + const payload = event.payload as { status?: string; tokensUsed?: number }; + if (payload.status === "complete") completedTokens = payload.tokensUsed; + } + } + } + expect(spentSum).toBe(17_700); + expect(completedTokens).toBe(spentSum); }); }); diff --git a/src/supervisor/agents/claude/sdkCanonicalMapping.ts b/src/supervisor/agents/claude/sdkCanonicalMapping.ts index f8d57941..20c66fd9 100644 --- a/src/supervisor/agents/claude/sdkCanonicalMapping.ts +++ b/src/supervisor/agents/claude/sdkCanonicalMapping.ts @@ -15,15 +15,15 @@ export { type ClaudeQuestion, } from "./canonicalMapping/questions"; export { + accumulateActiveGoalAssistantSpend, completeActiveGoalOnTaskDrainEvents, - emitActiveGoalTokenUpdate, + emitActiveGoalTick, } from "./canonicalMapping/goal"; export { extractResultErrorMessage, isApiErrorResult, mapClaudeContextUsageResponse, nonDiagnosticErrors, - readClaudeApiUsageSpendTokens, } from "./canonicalMapping/result"; export { mapClaudeSdkMessage, readParentToolUseId } from "./canonicalMapping/dispatch"; export { diff --git a/src/supervisor/agents/claude/sdkCanonicalMappingState.ts b/src/supervisor/agents/claude/sdkCanonicalMappingState.ts index d9e0a6c5..d9fd1d43 100644 --- a/src/supervisor/agents/claude/sdkCanonicalMappingState.ts +++ b/src/supervisor/agents/claude/sdkCanonicalMappingState.ts @@ -93,9 +93,18 @@ export interface ClaudeMapperState { * starts first. */ pendingGoalCompletionOnTaskDrain?: boolean; - activeGoalCompletedTurnTokensUsed?: number; - activeGoalLiveApiTokensUsed?: number; - activeGoalTaskTokensByKey?: Map; + /** + * Exact token spend accumulated while the goal is active: input + output + + * cache creation + cache read of every assistant API message (main thread + * and subagent sidechains alike) observed since the goal was armed. This is + * the same per-call definition the Profile token ledger sums from + * `usage.spent` events — never derived from the turn `result.usage` (which + * the CLI reports as a session-cumulative counter, including pre-goal and + * sidechain spend). + */ + activeGoalTokensUsed?: number; + /** Per-call sample ids already folded into {@link activeGoalTokensUsed}. */ + activeGoalUsageSampleIds?: Set; planAggregator?: PlanAggregatorState; /** * Live background subagent tasks, keyed by the SDK `task_id`, mapping to the diff --git a/src/supervisor/agents/claude/sdkSession.test.ts b/src/supervisor/agents/claude/sdkSession.test.ts index a8617388..3447eaf7 100644 --- a/src/supervisor/agents/claude/sdkSession.test.ts +++ b/src/supervisor/agents/claude/sdkSession.test.ts @@ -1025,7 +1025,7 @@ describe("ClaudeSdkSession", () => { await session.dispose(); }); - it("updates active goal tokens from live SDK api usage including cache", async () => { + it("ticks the active goal on context polls without deriving tokens from apiUsage", async () => { vi.useFakeTimers(); try { const fake = createFakeQuery(); @@ -1076,23 +1076,18 @@ describe("ClaudeSdkSession", () => { breakdown: [{ id: "messages-0", label: "Messages", tokens: 238_000 }], }, }); - expect(runtimeEvents).toContainEqual( - expect.objectContaining({ - type: "item.updated", - threadId: "thread-claude-goal-spend", - payload: expect.objectContaining({ - objective: "fix live token count", - status: "active", - tokensUsed: 237_000, - }), - }), - ); - expect(runtimeEvents).not.toContainEqual( - expect.objectContaining({ - type: "item.updated", - payload: expect.objectContaining({ tokensUsed: 238_000 }), - }), + // The goal tick re-emits the dock state (objective/status/time)… + const goalTick = runtimeEvents.find( + (event): event is Extract => + event.type === "item.updated" && + (event.payload as { objective?: unknown } | undefined)?.objective === + "fix live token count", ); + expect(goalTick).toBeDefined(); + // …but the last-call `apiUsage` snapshot is NOT token spend: nothing may + // be derived from it (that would be 237_000 here). Goal tokens accumulate + // only from per-call assistant-message usage, so the tick still reports 0. + expect(goalTick?.payload).toMatchObject({ tokensUsed: 0 }); await session.dispose(); } finally { @@ -1176,6 +1171,21 @@ describe("ClaudeSdkSession", () => { )?.itemId; expect(goalItemId).toBeDefined(); + // Goal spend comes from the per-call assistant message, not the result's + // session-cumulative usage counter. + fake.emitMessage({ + type: "assistant", + session_id: openedSessionId, + uuid: "uuid-msg-goal-steer", + parent_tool_use_id: null, + message: { + id: "msg-goal-steer", + role: "assistant", + model: "claude-opus-4-8", + content: [{ type: "text", text: "partial" }], + usage: { input_tokens: 10_000, output_tokens: 2_000 }, + }, + } as unknown as SDKMessage); fake.emitMessage({ type: "result", subtype: "error_during_execution", diff --git a/src/supervisor/agents/claude/sdkSession.ts b/src/supervisor/agents/claude/sdkSession.ts index 0b6c0d65..1f453d0a 100644 --- a/src/supervisor/agents/claude/sdkSession.ts +++ b/src/supervisor/agents/claude/sdkSession.ts @@ -49,7 +49,7 @@ import { closeClaudeOpenItems, completeActiveGoalOnTaskDrainEvents, createClaudeMapperState, - emitActiveGoalTokenUpdate, + emitActiveGoalTick, extractResultErrorMessage, isApiErrorResult, mapClaudePermissionRequest, @@ -58,7 +58,6 @@ import { mapClaudeSdkMessage, nonDiagnosticErrors, parseClaudeQuestions, - readClaudeApiUsageSpendTokens, readParentToolUseId, startClaudeTurn, type ClaudeMapperState, @@ -1043,17 +1042,17 @@ export class ClaudeSdkSession implements StructuredSessionHandle { if (this.disposed) return; const event = mapClaudeContextUsageResponse(this.input.threadId, usage); if (event) this.emitRuntimeEvents([event]); + // Goal token spend accumulates from per-call assistant-message usage + // (see accumulateActiveGoalAssistantSpend); the `apiUsage` snapshot on + // this response is the LAST call's usage, not spend, so it must not feed + // the goal total. The tick just rolls the dock's elapsed time forward. if (this.mapperState.activeGoalItemId) { - const spendTokens = readClaudeApiUsageSpendTokens(usage.apiUsage); - const goalUpdate = - spendTokens !== undefined - ? emitActiveGoalTokenUpdate(this.mapperState, spendTokens) - : undefined; - if (goalUpdate) this.emitRuntimeEvents([goalUpdate]); + const tick = emitActiveGoalTick(this.mapperState); + if (tick) this.emitRuntimeEvents([tick]); } } catch { // Older transports can reject this control call. In that case, keep the - // existing context and goal-spend state until a result message arrives. + // existing context snapshot; assistant messages still update goal spend. } }