diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index fcdd63ffd..362a73a54 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -352,6 +352,304 @@ describe("latestTokenStats", () => { const stats = latestTokenStats(events); expect(stats.cacheReadTokens).toBe(350); }); + + it.each(["claude", "codex", "opencode", "cursor", "droid"])( + "clears stale %s usage at a completed compaction boundary", + (provider) => { + const events = [ + envelope(1, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 }, + }), + envelope(2, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(3, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 }, + }), + ]; + const before = latestTokenStats(events.slice(0, 1), 100_000); + expect(before.inputTokens).toBe(100_000); + expect(before.percent).toBe(100); + expect(latestTokenStats(events, 100_000).percent).toBeNull(); + }, + ); + + it("uses postTokens from a completed compaction and ignores stale same-turn totals", () => { + const events = [ + envelope(1, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 }, + }), + envelope(2, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "claude", + turnId: "turn-1", + postTokens: 18_000, + } as AgentChatEventEnvelope["event"]), + envelope(3, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 }, + }), + ]; + const stats = latestTokenStats(events, 100_000); + expect(stats.inputTokens).toBe(18_000); + expect(stats.contextWindow).toBe(100_000); + expect(stats.percent).toBe(18); + }); + + it("accepts an exact Codex usage update from the compaction turn", () => { + const events = [ + envelope(1, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "codex", + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "codex_token_usage", + usage: { last: { inputTokens: 21_000 }, modelContextWindow: 100_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + ]; + const stats = latestTokenStats(events, 100_000); + expect(stats.inputTokens).toBe(21_000); + expect(stats.contextWindow).toBe(100_000); + expect(stats.percent).toBe(21); + }); + + it("ignores metadata-only Codex usage after compaction but accepts an explicit zero", () => { + const metadataOnlyEvents = [ + envelope(1, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "codex", + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "codex_token_usage", + usage: { modelContextWindow: 100_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(3, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 }, + }), + ]; + const metadataOnlyStats = latestTokenStats(metadataOnlyEvents, 200_000); + expect(metadataOnlyStats.inputTokens).toBeNull(); + expect(metadataOnlyStats.contextWindow).toBe(100_000); + expect(metadataOnlyStats.percent).toBeNull(); + + const explicitZeroStats = latestTokenStats([ + envelope(1, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "codex", + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "codex_token_usage", + usage: { last: { inputTokens: 0 }, modelContextWindow: 100_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + ], 200_000); + expect(explicitZeroStats.inputTokens).toBe(0); + expect(explicitZeroStats.contextWindow).toBe(100_000); + expect(explicitZeroStats.percent).toBe(0); + }); + + it("protects an exact Codex refill across legacy compaction until a later turn", () => { + const events = [ + envelope(1, { + type: "codex_token_usage", + usage: { last: { inputTokens: 190_000 }, modelContextWindow: 200_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "codex_context_compaction", + trigger: "auto", + state: "completed", + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(3, { + type: "codex_token_usage", + usage: { last: { inputTokens: 26_000 }, modelContextWindow: 200_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(4, { + type: "tokens", + turnId: "turn-1", + inputTokens: 190_000, + outputTokens: 1_000, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + envelope(5, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 190_000, outputTokens: 1_000, contextWindow: 200_000 }, + }), + envelope(6, { + type: "tokens", + turnId: "turn-old", + inputTokens: 180_000, + outputTokens: 500, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + envelope(7, { + type: "status", + turnStatus: "started", + turnId: "turn-2", + } as AgentChatEventEnvelope["event"]), + envelope(8, { + type: "tokens", + turnId: "turn-2", + inputTokens: 32_000, + outputTokens: 500, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + ]; + expect(latestTokenStats(events.slice(0, 2), 200_000).percent).toBeNull(); + + const exactRefill = latestTokenStats(events.slice(0, 6), 200_000); + expect(exactRefill.inputTokens).toBe(26_000); + expect(exactRefill.percent).toBe(13); + + const laterTurn = latestTokenStats(events, 200_000); + expect(laterTurn.inputTokens).toBe(32_000); + expect(laterTurn.percent).toBe(16); + }); + + it("protects an exact Claude snapshot until a later turn", () => { + const events = [ + envelope(1, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "claude", + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "context_usage", + usage: { totalTokens: 24_000, maxTokens: 200_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(3, { + type: "tokens", + turnId: "turn-1", + inputTokens: 190_000, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + envelope(4, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 190_000, contextWindow: 200_000 }, + }), + envelope(5, { + type: "tokens", + turnId: "turn-old", + inputTokens: 180_000, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + envelope(6, { + type: "status", + turnStatus: "started", + turnId: "turn-2", + } as AgentChatEventEnvelope["event"]), + envelope(7, { + type: "tokens", + turnId: "turn-2", + inputTokens: 30_000, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + ]; + const exactSnapshot = latestTokenStats(events.slice(0, 5), 200_000); + expect(exactSnapshot.inputTokens).toBe(24_000); + expect(exactSnapshot.percent).toBe(12); + + const laterTurn = latestTokenStats(events, 200_000); + expect(laterTurn.inputTokens).toBe(30_000); + expect(laterTurn.percent).toBe(15); + }); + + it("protects a compaction without a turn id until a later turn starts", () => { + const events = [ + envelope(1, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "claude", + postTokens: 24_000, + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "done", + turnId: "turn-old", + status: "completed", + usage: { inputTokens: 190_000, contextWindow: 200_000 }, + }), + envelope(3, { + type: "status", + turnStatus: "started", + turnId: "turn-2", + } as AgentChatEventEnvelope["event"]), + envelope(4, { + type: "tokens", + turnId: "turn-2", + inputTokens: 30_000, + contextWindow: 200_000, + } as AgentChatEventEnvelope["event"]), + ]; + const protectedSnapshot = latestTokenStats(events.slice(0, 2), 200_000); + expect(protectedSnapshot.inputTokens).toBe(24_000); + expect(protectedSnapshot.percent).toBe(12); + + const laterTurn = latestTokenStats(events, 200_000); + expect(laterTurn.inputTokens).toBe(30_000); + expect(laterTurn.percent).toBe(15); + }); + + it("accepts an exact Claude context snapshot from the compaction turn", () => { + const events = [ + envelope(1, { + type: "context_compact", + trigger: "auto", + state: "completed", + provider: "claude", + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + envelope(2, { + type: "context_usage", + usage: { totalTokens: 24_000, maxTokens: 100_000 }, + turnId: "turn-1", + } as AgentChatEventEnvelope["event"]), + ]; + const stats = latestTokenStats(events, 200_000); + expect(stats.inputTokens).toBe(24_000); + expect(stats.contextWindow).toBe(100_000); + expect(stats.percent).toBe(24); + }); }); describe("latestGoal", () => { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 83d4a7f1d..80adbeadb 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -867,6 +867,8 @@ export function latestTokenStats( let cacheCreationTokens: number | null = null; let costUsd: number | null = null; let eventLimit: number | null = null; + let compactionProtected = false; + let protectedCompactionTurnId: string | null = null; let rateLimit: TokenStats["rateLimit"] = null; const readCacheReadTokens = (bucket: Record | null): number | null => { if (!bucket) return null; @@ -878,9 +880,17 @@ export function latestTokenStats( }; for (const envelope of events) { const event = envelope.event as Record; - if (event.type === "status" && event.turnStatus === "started") streaming = true; + if (event.type === "status" && event.turnStatus === "started") { + streaming = true; + if (compactionProtected && typeof event.turnId === "string" + && (!protectedCompactionTurnId || event.turnId !== protectedCompactionTurnId)) { + compactionProtected = false; + protectedCompactionTurnId = null; + } + } if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) streaming = false; if (event.type === "tokens") { + if (compactionProtected) continue; inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; cacheReadTokens = readCacheReadTokens(event) ?? cacheReadTokens; @@ -891,6 +901,9 @@ export function latestTokenStats( const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; const total = usage?.total && typeof usage.total === "object" ? usage.total as Record : null; const last = usage?.last && typeof usage.last === "object" ? usage.last as Record : null; + const hasContextOccupancy = typeof last?.inputTokens === "number" || typeof total?.inputTokens === "number"; + if (typeof usage?.modelContextWindow === "number") eventLimit = usage.modelContextWindow; + if (!hasContextOccupancy) continue; inputTokens = typeof last?.inputTokens === "number" ? last.inputTokens : typeof total?.inputTokens === "number" ? total.inputTokens : inputTokens; @@ -901,9 +914,17 @@ export function latestTokenStats( // cachedInputTokens (snake-cased upstream variant aliased through). Prefer // last-turn reading over total. cacheReadTokens = readCacheReadTokens(last) ?? readCacheReadTokens(total) ?? cacheReadTokens; - if (typeof usage?.modelContextWindow === "number") eventLimit = usage.modelContextWindow; + } + if (event.type === "context_usage") { + const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; + inputTokens = typeof usage?.totalTokens === "number" ? usage.totalTokens : inputTokens; + outputTokens = null; + cacheReadTokens = null; + cacheCreationTokens = null; + if (typeof usage?.maxTokens === "number") eventLimit = usage.maxTokens; } if (event.type === "done") { + if (compactionProtected) continue; const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; inputTokens = typeof usage?.inputTokens === "number" ? usage.inputTokens : inputTokens; outputTokens = typeof usage?.outputTokens === "number" ? usage.outputTokens : outputTokens; @@ -915,6 +936,16 @@ export function latestTokenStats( // dial when present (runtime-reported window beats the registry fallback). if (typeof usage?.contextWindow === "number") eventLimit = usage.contextWindow; } + const completedCompaction = (event.type === "context_compact" && event.state !== "started") + || (event.type === "codex_context_compaction" && event.state === "completed"); + if (completedCompaction) { + compactionProtected = true; + protectedCompactionTurnId = typeof event.turnId === "string" ? event.turnId : null; + inputTokens = event.type === "context_compact" && typeof event.postTokens === "number" ? event.postTokens : null; + outputTokens = null; + cacheReadTokens = null; + cacheCreationTokens = null; + } if (event.type === "system_notice" && event.noticeKind === "rate_limit") { const detail = typeof event.detail === "string" ? event.detail : ""; const pct = detail.match(/(\d+(?:\.\d+)?)%\s+utilized/i); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 6eece571b..d351dafd6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -6425,7 +6425,12 @@ describe("createAgentChatService", () => { type: "system", subtype: "compact_boundary", session_id: "sdk-session-compacting", - compact_metadata: { trigger: "manual", pre_tokens: 120_000 }, + compact_metadata: { + trigger: "manual", + pre_tokens: 120_000, + post_tokens: 18_000, + duration_ms: 1_250, + }, }; yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; })()); @@ -6450,7 +6455,14 @@ describe("createAgentChatService", () => { // A live begin, then a completed end — no longer a plain gray "Compacting..." notice. expect(compactEvents).toEqual([ expect.objectContaining({ type: "context_compact", state: "started" }), - expect.objectContaining({ type: "context_compact", state: "completed", trigger: "manual", preTokens: 120_000 }), + expect.objectContaining({ + type: "context_compact", + state: "completed", + trigger: "manual", + preTokens: 120_000, + postTokens: 18_000, + durationMs: 1_250, + }), ]); const compactingNotices = onEvent.mock.calls .map((call) => call[0]) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 19835bdaa..812e84919 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -14946,6 +14946,8 @@ export function createAgentChatService(args: { type: "context_compact", trigger: compactMsg.compact_metadata?.trigger === "manual" ? "manual" : "auto", preTokens: typeof compactMsg.compact_metadata?.pre_tokens === "number" ? compactMsg.compact_metadata.pre_tokens : undefined, + postTokens: typeof compactMsg.compact_metadata?.post_tokens === "number" ? compactMsg.compact_metadata.post_tokens : undefined, + durationMs: typeof compactMsg.compact_metadata?.duration_ms === "number" ? compactMsg.compact_metadata.duration_ms : undefined, state: "completed", turnId, }); diff --git a/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts b/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts index d2593840e..a26c19d07 100644 --- a/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts +++ b/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts @@ -45,6 +45,7 @@ export function buildContextCompactEvent( preTokens?: number; postTokens?: number; tokensRemoved?: number; + durationMs?: number; completedAtMs?: number; }, ): ContextCompactEvent { @@ -68,7 +69,7 @@ export function buildContextCompactEvent( const startedAt = state.startedAtByKey.get(mergeKey); state.startedAtByKey.delete(mergeKey); - const durationMs = startedAt != null && now > startedAt ? now - startedAt : undefined; + const durationMs = input.durationMs ?? (startedAt != null && now > startedAt ? now - startedAt : undefined); state.sessionCompactionCount += 1; return { @@ -101,6 +102,7 @@ export function mapLegacyCompactionEvent( preTokens: event.preTokens, postTokens: event.postTokens, tokensRemoved: event.tokensRemoved, + durationMs: event.durationMs, }); } if (event.type === "codex_context_compaction") { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index be33f71ce..64cc0d408 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -37,7 +37,6 @@ import { type ChatSurfacePresentation, type AgentChatSessionSummary, type CodexThreadGoal, - type CodexThreadTokenUsage, type BuiltInBrowserContextItem, type ComputerUseOwnerSnapshot, type AppControlContextItem, @@ -90,7 +89,7 @@ import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; -import { toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; +import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache"; import { familiesFromStatus } from "../shared/ModelPicker/useProviderAuthStatus"; import { @@ -3671,18 +3670,6 @@ export function AgentChatPane({ } return sawGoalEvent ? goalFromEvents : (selectedSession?.codexGoal ?? null); }, [selectedEventsForDisplay, selectedSession?.codexGoal]); - const selectedCodexTokenUsage = useMemo(() => { - let usageFromEvents: CodexThreadTokenUsage | null = null; - let sawUsageEvent = false; - for (const envelope of selectedEventsForDisplay) { - const event = envelope.event; - if (event.type === "codex_token_usage") { - usageFromEvents = event.usage; - sawUsageEvent = true; - } - } - return sawUsageEvent ? usageFromEvents : (selectedSession?.codexTokenUsage ?? null); - }, [selectedEventsForDisplay, selectedSession?.codexTokenUsage]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); const selectedScheduledWorkSnapshots = useMemo(() => deriveScheduledWorkSnapshots(selectedEvents), [selectedEvents]); // Partition scheduled work into schedule kinds (wakeup/cron/loop/remote_trigger) @@ -4443,53 +4430,18 @@ export function AgentChatPane({ turnActive, ]); // Provider-agnostic context-usage for the composer dial. Codex pushes a live - // CodexThreadTokenUsage (with modelContextWindow); the other runtimes report a - // 4-field breakdown on the terminal `done`/`tokens` events. We take the - // freshest signal, fall back to the active model's registry context window - // when the runtime doesn't report one, and flatten everything into one VM so a - // single dial renders for every provider. + // Reduce provider telemetry across compaction boundaries before flattening it + // into the shared dial view-model. Exact post-compaction snapshots win; + // stale same-turn cumulative counters are ignored. const selectedUsageViewModel = useMemo(() => { - let genericUsage: - | { inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; cacheWriteTokens?: number | null; reasoningTokens?: number | null; contextWindow?: number | null } - | null = null; - for (const envelope of selectedEventsForDisplay) { - const event = envelope.event; - if (event.type === "done" && event.usage) { - const u = event.usage; - genericUsage = { - inputTokens: u.inputTokens ?? null, - outputTokens: u.outputTokens ?? null, - cacheReadTokens: u.cacheReadTokens ?? null, - cacheWriteTokens: u.cacheCreationTokens ?? null, - reasoningTokens: u.reasoningTokens ?? null, - contextWindow: u.contextWindow ?? null, - }; - } else if (event.type === "tokens") { - genericUsage = { - inputTokens: event.inputTokens ?? null, - outputTokens: event.outputTokens ?? null, - cacheReadTokens: event.cacheReadTokens ?? null, - cacheWriteTokens: event.cacheWriteTokens ?? null, - contextWindow: event.contextWindow ?? null, - }; - } - } const provider = sessionProvider ?? selectedSession?.provider ?? ""; const descriptor = modelId ? (resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId)) : null; const fallbackWindow = descriptor?.contextWindow ?? null; - - if (selectedCodexTokenUsage && (provider === "codex" || !genericUsage)) { - return toUsageViewModel( - { kind: "codex", provider: provider || "codex", usage: selectedCodexTokenUsage }, - fallbackWindow, - ); - } - if (genericUsage) { - const { contextWindow, ...rest } = genericUsage; - return toUsageViewModel({ kind: "generic", provider, usage: rest, contextWindow }, fallbackWindow); - } - return null; - }, [selectedEventsForDisplay, selectedCodexTokenUsage, selectedSession?.provider, sessionProvider, modelId]); + return toUsageViewModel( + latestContextUsageInput(selectedEventsForDisplay, provider, selectedSession?.codexTokenUsage), + fallbackWindow, + ); + }, [selectedEventsForDisplay, selectedSession?.codexTokenUsage, selectedSession?.provider, sessionProvider, modelId]); const [contextCompactionPulse, setContextCompactionPulse] = useState(false); const compactionPulseTimerRef = useRef | null>(null); diff --git a/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts b/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts index 00810d76c..211e2db22 100644 --- a/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts +++ b/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { toUsageViewModel, formatContextTokens } from "./contextUsageModel"; +import { toUsageViewModel, formatContextTokens, latestContextUsageInput } from "./contextUsageModel"; + +const envelope = (sequence: number, event: any) => ({ + sessionId: "session-1", + timestamp: `2026-01-01T00:00:0${sequence}.000Z`, + sequence, + event, +}); describe("toUsageViewModel", () => { it("returns null for null input", () => { @@ -86,6 +93,147 @@ describe("toUsageViewModel", () => { }); expect(vm!.reasoningTokens).toBe(4_100); }); + + it("uses an exact zero-token compaction snapshot", () => { + const vm = toUsageViewModel({ + kind: "generic", + provider: "claude", + usage: { usedTokens: 0 }, + contextWindow: 200_000, + }); + expect(vm!.usedTokens).toBe(0); + expect(vm!.ratio).toBe(0); + expect(vm!.contextWindow).toBe(200_000); + }); +}); + +describe("latestContextUsageInput", () => { + it.each(["claude", "opencode", "cursor", "droid"])( + "invalidates stale same-turn %s usage after compaction", + (provider) => { + const events = [ + envelope(1, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 200_000, contextWindow: 200_000 } }), + envelope(2, { type: "context_compact", trigger: "auto", state: "completed", turnId: "turn-1" }), + envelope(3, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 200_000, contextWindow: 200_000 } }), + ] as any; + const before = toUsageViewModel(latestContextUsageInput(events.slice(0, 1), provider)); + expect(before?.usedTokens).toBe(200_000); + expect(before?.ratio).toBe(1); + expect(latestContextUsageInput(events, provider)).toBeNull(); + }, + ); + + it("uses Claude postTokens at the compaction boundary", () => { + const events = [ + envelope(1, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 190_000, contextWindow: 200_000 } }), + envelope(2, { type: "context_compact", trigger: "auto", state: "completed", turnId: "turn-1", postTokens: 24_000 }), + envelope(3, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 210_000, contextWindow: 200_000 } }), + ] as any; + const input = latestContextUsageInput(events, "claude"); + const viewModel = toUsageViewModel(input, 200_000); + expect(viewModel?.usedTokens).toBe(24_000); + expect(viewModel?.contextWindow).toBe(200_000); + expect(viewModel?.ratio).toBe(0.12); + }); + + it("allows an exact Codex usage update from the compaction turn", () => { + const events = [ + envelope(1, { type: "codex_token_usage", usage: { last: { inputTokens: 190_000 }, modelContextWindow: 200_000 }, turnId: "turn-1" }), + envelope(2, { type: "context_compact", trigger: "auto", state: "completed", turnId: "turn-1" }), + envelope(3, { type: "codex_token_usage", usage: { last: { inputTokens: 26_000 }, modelContextWindow: 200_000 }, turnId: "turn-1" }), + ] as any; + const input = latestContextUsageInput(events, "codex"); + const viewModel = toUsageViewModel(input, 200_000); + expect(viewModel?.usedTokens).toBe(26_000); + expect(viewModel?.contextWindow).toBe(200_000); + expect(viewModel?.ratio).toBe(0.13); + }); + + it("ignores metadata-only Codex usage after compaction but accepts an explicit zero", () => { + const metadataOnlyEvents = [ + envelope(1, { type: "codex_token_usage", usage: { last: { inputTokens: 190_000 }, modelContextWindow: 200_000 }, turnId: "turn-1" }), + envelope(2, { type: "context_compact", trigger: "auto", state: "completed", turnId: "turn-1" }), + envelope(3, { type: "codex_token_usage", usage: { modelContextWindow: 200_000 }, turnId: "turn-1" }), + envelope(4, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 190_000, contextWindow: 200_000 } }), + ] as any; + expect(latestContextUsageInput(metadataOnlyEvents, "codex")).toBeNull(); + + const explicitZero = latestContextUsageInput([ + envelope(1, { type: "context_compact", trigger: "auto", state: "completed", turnId: "turn-1" }), + envelope(2, { type: "codex_token_usage", usage: { last: { inputTokens: 0 }, modelContextWindow: 200_000 }, turnId: "turn-1" }), + ] as any, "codex"); + const viewModel = toUsageViewModel(explicitZero); + expect(viewModel?.usedTokens).toBe(0); + expect(viewModel?.contextWindow).toBe(200_000); + expect(viewModel?.ratio).toBe(0); + }); + + it("protects an exact Codex refill across legacy compaction until a later turn", () => { + const events = [ + envelope(1, { type: "codex_token_usage", usage: { last: { inputTokens: 190_000 }, modelContextWindow: 200_000 }, turnId: "turn-1" }), + envelope(2, { type: "codex_context_compaction", trigger: "auto", state: "completed", turnId: "turn-1" }), + envelope(3, { type: "codex_token_usage", usage: { last: { inputTokens: 26_000 }, modelContextWindow: 200_000 }, turnId: "turn-1" }), + envelope(4, { type: "tokens", turnId: "turn-1", inputTokens: 190_000, outputTokens: 1_000, contextWindow: 200_000 }), + envelope(5, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 190_000, contextWindow: 200_000 } }), + envelope(6, { type: "tokens", turnId: "turn-old", inputTokens: 180_000, outputTokens: 500, contextWindow: 200_000 }), + envelope(7, { type: "status", turnStatus: "started", turnId: "turn-2" }), + envelope(8, { type: "tokens", turnId: "turn-2", inputTokens: 32_000, outputTokens: 500, contextWindow: 200_000 }), + ] as any; + expect(latestContextUsageInput(events.slice(0, 2), "codex")).toBeNull(); + + const exactRefill = toUsageViewModel(latestContextUsageInput(events.slice(0, 6), "codex")); + expect(exactRefill?.usedTokens).toBe(26_000); + expect(exactRefill?.ratio).toBe(0.13); + + const laterTurn = toUsageViewModel(latestContextUsageInput(events, "codex")); + expect(laterTurn?.usedTokens).toBe(32_000); + expect(laterTurn?.ratio).toBe(0.16); + }); + + it("protects an exact Claude snapshot until a later turn", () => { + const events = [ + envelope(1, { type: "context_compact", trigger: "auto", state: "completed", turnId: "turn-1" }), + envelope(2, { type: "context_usage", usage: { totalTokens: 24_000, maxTokens: 200_000 }, turnId: "turn-1" }), + envelope(3, { type: "tokens", turnId: "turn-1", inputTokens: 190_000, contextWindow: 200_000 }), + envelope(4, { type: "done", turnId: "turn-1", status: "completed", usage: { inputTokens: 190_000, contextWindow: 200_000 } }), + envelope(5, { type: "tokens", turnId: "turn-old", inputTokens: 180_000, contextWindow: 200_000 }), + envelope(6, { type: "status", turnStatus: "started", turnId: "turn-2" }), + envelope(7, { type: "tokens", turnId: "turn-2", inputTokens: 30_000, contextWindow: 200_000 }), + ] as any; + const exactSnapshot = toUsageViewModel(latestContextUsageInput(events.slice(0, 5), "claude")); + expect(exactSnapshot?.usedTokens).toBe(24_000); + expect(exactSnapshot?.ratio).toBe(0.12); + + const laterTurn = toUsageViewModel(latestContextUsageInput(events, "claude")); + expect(laterTurn?.usedTokens).toBe(30_000); + expect(laterTurn?.ratio).toBe(0.15); + }); + + it("protects a compaction without a turn id until a later turn starts", () => { + const events = [ + envelope(1, { type: "context_compact", trigger: "auto", state: "completed", postTokens: 24_000 }), + envelope(2, { type: "done", turnId: "turn-old", status: "completed", usage: { inputTokens: 190_000, contextWindow: 200_000 } }), + envelope(3, { type: "status", turnStatus: "started", turnId: "turn-2" }), + envelope(4, { type: "tokens", turnId: "turn-2", inputTokens: 30_000, contextWindow: 200_000 }), + ] as any; + const protectedSnapshot = toUsageViewModel(latestContextUsageInput(events.slice(0, 2), "claude"), 200_000); + expect(protectedSnapshot?.usedTokens).toBe(24_000); + expect(protectedSnapshot?.ratio).toBe(0.12); + + const laterTurn = toUsageViewModel(latestContextUsageInput(events, "claude"), 200_000); + expect(laterTurn?.usedTokens).toBe(30_000); + expect(laterTurn?.ratio).toBe(0.15); + }); + + it("prefers Claude's exact context_usage snapshot", () => { + const input = latestContextUsageInput([ + envelope(1, { type: "context_usage", usage: { categories: [], totalTokens: 31_000, maxTokens: 200_000, percentage: 15.5 } }), + ] as any, "claude"); + const viewModel = toUsageViewModel(input); + expect(viewModel?.usedTokens).toBe(31_000); + expect(viewModel?.contextWindow).toBe(200_000); + expect(viewModel?.ratio).toBeCloseTo(0.155, 5); + }); }); describe("formatContextTokens", () => { @@ -96,7 +244,7 @@ describe("formatContextTokens", () => { }); it("returns null for non-positive / missing values", () => { - expect(formatContextTokens(0)).toBeNull(); + expect(formatContextTokens(0)).toBe("0"); expect(formatContextTokens(null)).toBeNull(); expect(formatContextTokens(undefined)).toBeNull(); expect(formatContextTokens(-5)).toBeNull(); diff --git a/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts b/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts index 83a401030..ed771d4a5 100644 --- a/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts +++ b/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts @@ -1,4 +1,4 @@ -import type { CodexThreadTokenUsage } from "../../../../shared/types"; +import type { AgentChatEventEnvelope, CodexThreadTokenUsage } from "../../../../shared/types"; /** * Provider-agnostic context-usage view-model consumed by `ContextUsageDial`. @@ -30,6 +30,8 @@ export type ContextUsageViewModel = { }; export type GenericUsageInput = { + /** Exact context occupancy snapshot (for example Claude post-compaction usage). */ + usedTokens?: number | null; inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; @@ -46,13 +48,17 @@ function positive(value: number | null | undefined): number | null { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; } +function nonNegative(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + function clamp01(value: number): number { return Math.max(0, Math.min(1, value)); } -/** Compact token count: `1.2M`, `42.7k`, or the integer. Returns null for non-positive. */ +/** Compact token count: `1.2M`, `42.7k`, or the integer. Returns null for negative/missing. */ export function formatContextTokens(value: number | null | undefined): string | null { - const n = positive(value); + const n = nonNegative(value); if (n == null) return null; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; @@ -91,6 +97,7 @@ export function toUsageViewModel( if (input.kind === "codex") { const last = input.usage.last ?? {}; const total = input.usage.total ?? {}; + const exactInputTokens = nonNegative(last.inputTokens) ?? nonNegative(total.inputTokens); inputTokens = positive(last.inputTokens) ?? positive(total.inputTokens); outputTokens = positive(last.outputTokens) ?? positive(total.outputTokens); cacheReadTokens = positive(last.cacheReadTokens) ?? positive(total.cacheReadTokens); @@ -100,7 +107,7 @@ export function toUsageViewModel( runtimeWindow = positive(input.usage.modelContextWindow); // Codex inputTokens already includes the cached portion → it is the occupancy. usedTokens = - inputTokens + exactInputTokens ?? (positive(last.outputTokens) != null ? (last.inputTokens ?? 0) + (last.outputTokens ?? 0) || null : null) ?? totalTokens; } else { @@ -112,9 +119,12 @@ export function toUsageViewModel( reasoningTokens = positive(u.reasoningTokens); totalTokens = positive(u.totalTokens); runtimeWindow = positive(input.contextWindow); - // Non-codex providers report cache separately from input → sum for occupancy. + // Exact snapshots beat the provider-specific fallback math. This is used at + // compaction boundaries, where per-turn counters can still describe the + // pre-compaction request even though the active context was replaced. + const exactOccupancy = nonNegative(u.usedTokens); const occupancy = (inputTokens ?? 0) + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0); - usedTokens = occupancy > 0 ? occupancy : totalTokens; + usedTokens = exactOccupancy ?? (occupancy > 0 ? occupancy : totalTokens); } const contextWindow = runtimeWindow ?? positive(fallbackContextWindow); @@ -142,3 +152,106 @@ export function toUsageViewModel( windowSource, }; } + +/** + * Reduce the event stream to the newest trustworthy context-occupancy signal. + * + * A completed compaction invalidates all earlier usage. Generic SDK `done` / + * `tokens` events are deliberately ignored until an explicit later turn starts: Cursor, + * OpenCode, Droid, and Claude can report per-turn or cumulative counters after + * the history has already been replaced. Claude's `postTokens` boundary and + * `context_usage`, plus Codex's live thread usage notification, are exact + * snapshots and may update the meter immediately. + */ +export function latestContextUsageInput( + events: AgentChatEventEnvelope[], + provider: string, + fallbackCodexUsage?: CodexThreadTokenUsage | null, +): ContextUsageInput | null { + let current: ContextUsageInput | null = + fallbackCodexUsage && provider === "codex" + ? { kind: "codex", provider, usage: fallbackCodexUsage } + : null; + let lastRuntimeWindow = positive(fallbackCodexUsage?.modelContextWindow); + let compactionProtected = false; + let protectedCompactionTurnId: string | null = null; + + const acceptGeneric = ( + usage: GenericUsageInput, + contextWindow?: number | null, + ): void => { + if (compactionProtected) return; + const runtimeWindow = positive(contextWindow); + if (runtimeWindow != null) lastRuntimeWindow = runtimeWindow; + current = { kind: "generic", provider, usage, contextWindow }; + }; + + for (const envelope of events) { + const event = envelope.event; + if (event.type === "status" && event.turnStatus === "started" + && compactionProtected && event.turnId + && (!protectedCompactionTurnId || event.turnId !== protectedCompactionTurnId)) { + compactionProtected = false; + protectedCompactionTurnId = null; + } + if (event.type === "codex_token_usage") { + if (positive(event.usage.modelContextWindow) != null) { + lastRuntimeWindow = positive(event.usage.modelContextWindow); + } + const hasContextOccupancy = typeof event.usage.last?.inputTokens === "number" + || typeof event.usage.total?.inputTokens === "number"; + if (!hasContextOccupancy) continue; + current = { kind: "codex", provider: provider || "codex", usage: event.usage }; + continue; + } + if (event.type === "context_usage") { + lastRuntimeWindow = positive(event.usage.maxTokens) ?? lastRuntimeWindow; + current = { + kind: "generic", + provider, + usage: { + usedTokens: event.usage.totalTokens, + inputTokens: event.usage.totalTokens, + totalTokens: event.usage.totalTokens, + }, + contextWindow: event.usage.maxTokens, + }; + continue; + } + const completedCompaction = (event.type === "context_compact" && event.state !== "started") + || (event.type === "codex_context_compaction" && event.state === "completed"); + if (completedCompaction) { + compactionProtected = true; + protectedCompactionTurnId = event.turnId ?? null; + current = event.type === "context_compact" && event.postTokens != null + ? { + kind: "generic", + provider, + usage: { usedTokens: event.postTokens, inputTokens: event.postTokens }, + contextWindow: lastRuntimeWindow, + } + : null; + continue; + } + if (event.type === "done" && event.usage) { + acceptGeneric({ + inputTokens: event.usage.inputTokens, + outputTokens: event.usage.outputTokens, + cacheReadTokens: event.usage.cacheReadTokens, + cacheWriteTokens: event.usage.cacheCreationTokens, + reasoningTokens: event.usage.reasoningTokens, + }, event.usage.contextWindow); + continue; + } + if (event.type === "tokens") { + acceptGeneric({ + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + cacheReadTokens: event.cacheReadTokens, + cacheWriteTokens: event.cacheWriteTokens, + }, event.contextWindow); + } + } + + return current; +} diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 6ad2da901..5dbcc5747 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1720,6 +1720,43 @@ struct AgentChatCodexThreadTokenUsage: Codable, Equatable { var modelContextWindow: Int? } +struct AgentChatContextUsageCategory: Codable, Equatable { + var name: String + var tokens: Int + var percentage: Double + var color: String? + var isDeferred: Bool? +} + +struct AgentChatContextUsage: Codable, Equatable { + var categories: [AgentChatContextUsageCategory] + var totalTokens: Int + var maxTokens: Int + var rawMaxTokens: Int? + var percentage: Double + var model: String? + + private enum CodingKeys: String, CodingKey { + case categories + case totalTokens + case maxTokens + case rawMaxTokens + case percentage + case model + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + totalTokens = try container.decode(Int.self, forKey: .totalTokens) + maxTokens = try container.decode(Int.self, forKey: .maxTokens) + categories = try container.decodeIfPresent([AgentChatContextUsageCategory].self, forKey: .categories) ?? [] + rawMaxTokens = try container.decodeIfPresent(Int.self, forKey: .rawMaxTokens) + percentage = try container.decodeIfPresent(Double.self, forKey: .percentage) + ?? (maxTokens > 0 ? Double(totalTokens) / Double(maxTokens) * 100 : 0) + model = try container.decodeIfPresent(String.self, forKey: .model) + } +} + struct CodexWebSearchAction: Codable, Equatable { var type: String var status: String? @@ -1929,6 +1966,7 @@ enum AgentChatEvent: Decodable, Equatable { case done(turnId: String, status: AgentChatTurnStatus, model: String?, modelId: String?, usage: AgentChatTurnUsage?, costUsd: Double?) case tokens(turnId: String, itemId: String?, inputTokens: Int?, outputTokens: Int?, cacheReadTokens: Int?, cacheWriteTokens: Int?, contextWindow: Int?) case codexTokenUsage(usage: AgentChatCodexThreadTokenUsage, turnId: String?) + case contextUsage(usage: AgentChatContextUsage, turnId: String?) case activity(activity: AgentChatActivityKind, detail: String?, turnId: String?) case stepBoundary(stepNumber: Int, turnId: String?) case todoUpdate(items: [AgentChatTodoItem], turnId: String?) @@ -2230,6 +2268,11 @@ extension AgentChatEvent { usage: try container.decode(AgentChatCodexThreadTokenUsage.self, forKey: .usage), turnId: try container.decodeIfPresent(String.self, forKey: .turnId) ) + case "context_usage": + self = .contextUsage( + usage: try container.decode(AgentChatContextUsage.self, forKey: .usage), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId) + ) case "activity": self = .activity( activity: try container.decode(AgentChatActivityKind.self, forKey: .activity), @@ -2527,6 +2570,7 @@ extension AgentChatEvent { case .done: return "done" case .tokens: return "tokens" case .codexTokenUsage: return "codex_token_usage" + case .contextUsage: return "context_usage" case .activity: return "activity" case .stepBoundary: return "step_boundary" case .todoUpdate: return "todo_update" diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 0f8479f7d..10aec715a 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1799,8 +1799,8 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { return ["tokens", turnId, itemId ?? "", workUsageSummaryMergeKey(usage)].joined(separator: "|") case .promptSuggestion(let text, let turnId): return ["prompt_suggestion", turnId ?? "", text].joined(separator: "|") - case .contextCompact(let summary, let isInProgress, let turnId, let compactionId): - return ["context_compact", compactionId ?? turnId ?? "", isInProgress ? "started" : "completed", summary].joined(separator: "|") + case .contextCompact(let summary, let isInProgress, let postTokens, let turnId, let compactionId): + return ["context_compact", compactionId ?? turnId ?? "", isInProgress ? "started" : "completed", postTokens.map(String.init) ?? "", summary].joined(separator: "|") case .autoApprovalReview(let summary, let turnId): return ["auto_approval_review", turnId ?? "", summary].joined(separator: "|") case .webSearch(let query, let action, let actions, let status, let itemId, let turnId): @@ -1851,6 +1851,7 @@ func workUsageSummaryMergeKey(_ usage: WorkUsageSummary?) -> String { String(usage.totalTokens), usage.contextWindow.map(String.init) ?? "", String(usage.costUsd), + String(usage.isContextSnapshot), ].joined(separator: "|") } diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 1c94c8384..16927848d 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -371,6 +371,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { case .codexTokenUsage(let usage, let turnId): let last = usage.last let total = usage.total + let hasContextOccupancy = last?.inputTokens != nil || total?.inputTokens != nil return .tokens( usage: makeWorkUsageSummary( inputTokens: last?.inputTokens ?? total?.inputTokens, @@ -380,7 +381,8 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { reasoningTokens: last?.reasoningTokens ?? total?.reasoningTokens, totalTokens: total?.totalTokens ?? last?.totalTokens, contextWindow: usage.modelContextWindow, - costUsd: nil + costUsd: nil, + isContextSnapshot: hasContextOccupancy ) ?? WorkUsageSummary( turnCount: 1, inputTokens: 0, @@ -389,11 +391,28 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { cacheCreationTokens: 0, totalTokens: total?.totalTokens ?? last?.totalTokens ?? 0, contextWindow: usage.modelContextWindow, - costUsd: 0 + costUsd: 0, + isContextSnapshot: hasContextOccupancy ), turnId: turnId ?? usage.turnId ?? "", itemId: nil ) + case .contextUsage(let usage, let turnId): + return .tokens( + usage: WorkUsageSummary( + turnCount: 1, + inputTokens: usage.totalTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: usage.totalTokens, + contextWindow: usage.maxTokens, + costUsd: 0, + isContextSnapshot: true + ), + turnId: turnId ?? "", + itemId: nil + ) case .promptSuggestion(let suggestion, let turnId): return .promptSuggestion(text: suggestion, turnId: turnId) case .contextCompact( @@ -419,6 +438,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { return .contextCompact( summary: summary, isInProgress: isInProgress, + postTokens: postTokens, turnId: turnId, compactionId: compactionId ?? turnId ) @@ -427,6 +447,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { return .contextCompact( summary: summary, isInProgress: state == .started, + postTokens: nil, turnId: turnId, compactionId: compactionId ?? turnId ) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 3523f3d2a..11cf8cb6c 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -326,6 +326,8 @@ struct WorkUsageSummary: Equatable { var totalTokens: Int = 0 var contextWindow: Int? = nil var costUsd: Double + /// True for provider-reported current-context snapshots (not per-turn totals). + var isContextSnapshot: Bool = false } struct WorkContextUsageViewModel: Equatable { @@ -908,7 +910,7 @@ enum WorkChatEvent: Equatable { case done(status: String, summary: String, usage: WorkUsageSummary?, turnId: String, model: String?, modelId: String?) case tokens(usage: WorkUsageSummary, turnId: String, itemId: String?) case promptSuggestion(text: String, turnId: String?) - case contextCompact(summary: String, isInProgress: Bool, turnId: String?, compactionId: String?) + case contextCompact(summary: String, isInProgress: Bool, postTokens: Int?, turnId: String?, compactionId: String?) case autoApprovalReview(summary: String, turnId: String?) case webSearch(query: String, action: String?, actions: [CodexWebSearchAction]?, status: WorkToolCardStatus, itemId: String, turnId: String?) case codexState(title: String, message: String, icon: String, turnId: String?) diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 71babcf1f..478063b8b 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -279,9 +279,10 @@ private func combineWorkChatEventSignature(_ event: WorkChatEvent, into hasher: case .promptSuggestion(let text, let turnId): combineLongTextSignature(text, into: &hasher) combineOptional(turnId, into: &hasher) - case .contextCompact(let summary, let isInProgress, let turnId, let compactionId): + case .contextCompact(let summary, let isInProgress, let postTokens, let turnId, let compactionId): combineLongTextSignature(summary, into: &hasher) hasher.combine(isInProgress) + combineOptional(postTokens, into: &hasher) combineOptional(turnId, into: &hasher) combineOptional(compactionId, into: &hasher) case .autoApprovalReview(let summary, let turnId): @@ -412,6 +413,7 @@ private func combineUsageSummary(_ usage: WorkUsageSummary?, into hasher: inout hasher.combine(usage.totalTokens) combineOptional(usage.contextWindow, into: &hasher) hasher.combine(usage.costUsd) + hasher.combine(usage.isContextSnapshot) } private func combineCompletionArtifacts(_ artifacts: [WorkCompletionArtifactModel], into hasher: inout Hasher) { @@ -2647,7 +2649,7 @@ private func eventCard( bullets: [], metadata: [] ) - case .contextCompact(let summary, let isInProgress, let turnId, let compactionId): + case .contextCompact(let summary, let isInProgress, _, let turnId, let compactionId): return WorkEventCardModel( // Prefer compactionId so started/completed pairs merge even when Codex // finishes on a different turn. Falls back to turnId, then envelope id. @@ -2966,7 +2968,7 @@ private func workTurnId(for event: WorkChatEvent) -> String? { .systemNotice(_, _, _, let turnId, _), .error(_, _, _, let turnId), .promptSuggestion(_, let turnId), - .contextCompact(_, _, let turnId, _), + .contextCompact(_, _, _, let turnId, _), .autoApprovalReview(_, let turnId), .webSearch(_, _, _, _, _, let turnId), .codexState(_, _, _, let turnId), @@ -3093,7 +3095,8 @@ func makeWorkUsageSummary( reasoningTokens: Int? = nil, totalTokens: Int? = nil, contextWindow: Int? = nil, - costUsd: Double? + costUsd: Double?, + isContextSnapshot: Bool = false ) -> WorkUsageSummary? { guard inputTokens != nil || outputTokens != nil @@ -3116,7 +3119,8 @@ func makeWorkUsageSummary( reasoningTokens: reasoningTokens ?? 0, totalTokens: totalTokens ?? 0, contextWindow: contextWindow, - costUsd: costUsd ?? 0 + costUsd: costUsd ?? 0, + isContextSnapshot: isContextSnapshot ) } @@ -3167,28 +3171,54 @@ func workContextUsageViewModel( fallbackContextWindow: Int? ) -> WorkContextUsageViewModel? { - for envelope in sortedWorkChatEnvelopes(transcript).reversed() { + var latestUsage: WorkUsageSummary? + var compactionProtected = false + var protectedCompactionTurnId: String? + + for envelope in sortedWorkChatEnvelopes(transcript) { switch envelope.event { + case .contextCompact(_, let isInProgress, let postTokens, let turnId, _): + guard !isInProgress else { continue } + compactionProtected = true + protectedCompactionTurnId = turnId + latestUsage = postTokens.map { + WorkUsageSummary( + turnCount: 1, + inputTokens: $0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: $0, + contextWindow: latestUsage?.contextWindow, + costUsd: 0, + isContextSnapshot: true + ) + } case .tokens(let usage, _, _): - return makeWorkContextUsageViewModel( - usage: usage, - provider: provider, - fallbackContextWindow: fallbackContextWindow - ) + if compactionProtected && !usage.isContextSnapshot { continue } + latestUsage = usage case .done(_, _, let usage, _, _, _): if let usage { - return makeWorkContextUsageViewModel( - usage: usage, - provider: provider, - fallbackContextWindow: fallbackContextWindow - ) + if compactionProtected && !usage.isContextSnapshot { continue } + latestUsage = usage + } + case .status(let turnStatus, _, let turnId): + if turnStatus == "started", compactionProtected, let turnId, + protectedCompactionTurnId == nil || turnId != protectedCompactionTurnId { + compactionProtected = false + protectedCompactionTurnId = nil } default: continue } } - return nil + guard let latestUsage else { return nil } + return makeWorkContextUsageViewModel( + usage: latestUsage, + provider: provider, + fallbackContextWindow: fallbackContextWindow + ) } private func makeWorkContextUsageViewModel( @@ -3205,6 +3235,9 @@ private func makeWorkContextUsageViewModel( let runtimeWindow = positiveWorkTokenCount(usage.contextWindow) let contextWindow = runtimeWindow ?? positiveWorkTokenCount(fallbackContextWindow) let usedTokens: Int? = { + if usage.isContextSnapshot { + return max(0, usage.inputTokens) + } if workProviderUsesCodexTokenOccupancy(provider) { return inputTokens ?? positiveWorkTokenCount(usage.inputTokens + usage.outputTokens) ?? totalTokens } diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 9b356530e..1da9f948b 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -415,6 +415,8 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { let lastUsage = usageDict["last"] as? [String: Any] let totalUsage = usageDict["total"] as? [String: Any] let contextWindow = optionalWorkInt(usageDict["modelContextWindow"]) + let hasContextOccupancy = optionalWorkInt(lastUsage?["inputTokens"]) != nil + || optionalWorkInt(totalUsage?["inputTokens"]) != nil event = .tokens( usage: makeWorkUsageSummary( inputTokens: optionalWorkInt(lastUsage?["inputTokens"]) ?? optionalWorkInt(totalUsage?["inputTokens"]), @@ -424,7 +426,8 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { reasoningTokens: optionalWorkInt(lastUsage?["reasoningTokens"]) ?? optionalWorkInt(totalUsage?["reasoningTokens"]), totalTokens: optionalWorkInt(totalUsage?["totalTokens"]) ?? optionalWorkInt(lastUsage?["totalTokens"]), contextWindow: contextWindow, - costUsd: nil + costUsd: nil, + isContextSnapshot: hasContextOccupancy ) ?? WorkUsageSummary( turnCount: 1, inputTokens: 0, @@ -433,11 +436,30 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { cacheCreationTokens: 0, totalTokens: optionalWorkInt(totalUsage?["totalTokens"]) ?? optionalWorkInt(lastUsage?["totalTokens"]) ?? 0, contextWindow: contextWindow, - costUsd: 0 + costUsd: 0, + isContextSnapshot: hasContextOccupancy ), turnId: turnId ?? optionalString(usageDict["turnId"]) ?? "", itemId: nil ) + case "context_usage": + let usageDict = eventDict["usage"] as? [String: Any] ?? [:] + let totalTokens = optionalWorkInt(usageDict["totalTokens"]) ?? 0 + event = .tokens( + usage: WorkUsageSummary( + turnCount: 1, + inputTokens: totalTokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: totalTokens, + contextWindow: optionalWorkInt(usageDict["maxTokens"]), + costUsd: 0, + isContextSnapshot: true + ), + turnId: turnId ?? "", + itemId: nil + ) case "codex_turn_stalled": event = .codexTurnStalled( message: stringValue(eventDict["message"]), @@ -468,6 +490,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { event = .contextCompact( summary: workContextCompactSummary(from: eventDict), isInProgress: isInProgress, + postTokens: optionalWorkInt(eventDict["postTokens"]), turnId: turnId, compactionId: workContextCompactMergeId(from: eventDict, turnId: turnId) ) @@ -476,6 +499,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { event = .contextCompact( summary: workContextCompactSummary(from: eventDict), isInProgress: isInProgress, + postTokens: nil, turnId: turnId, compactionId: workContextCompactMergeId(from: eventDict, turnId: turnId) ) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index f5ba1867f..d90ccd176 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3128,6 +3128,119 @@ final class ADETests: XCTestCase { XCTAssertEqual(viewModel?.ratio ?? 0, Double(169600) / Double(258400), accuracy: 0.0001) } + func testCodexMetadataOnlyUsageIsNotAnExactZeroSnapshot() throws { + let metadataOnlyJSON = """ + { + "sessionId": "session-usage", + "timestamp": "2026-03-17T00:00:00.000Z", + "sequence": 15, + "event": { + "type": "codex_token_usage", + "turnId": "turn-usage", + "usage": { "modelContextWindow": 258400 } + } + } + """ + + let envelope = try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(metadataOnlyJSON.utf8)) + guard case .tokens(let liveUsage, _, _) = makeWorkChatEvent(from: envelope.event) else { + return XCTFail("Expected live Codex usage to normalize to a tokens event.") + } + XCTAssertFalse(liveUsage.isContextSnapshot) + + let fallbackTranscript = parseWorkChatTranscript(""" + {"sessionId":"chat-1","timestamp":"2026-03-17T00:00:00.000Z","sequence":1,"event":{"type":"context_compact","state":"completed","turnId":"turn-usage"}} + {"sessionId":"chat-1","timestamp":"2026-03-17T00:00:01.000Z","sequence":2,"event":{"type":"codex_token_usage","turnId":"turn-usage","usage":{"modelContextWindow":258400}}} + """) + guard case .tokens(let fallbackUsage, _, _) = fallbackTranscript.last?.event else { + return XCTFail("Expected fallback Codex usage to normalize to a tokens event.") + } + XCTAssertFalse(fallbackUsage.isContextSnapshot) + XCTAssertNil(workContextUsageViewModel(transcript: fallbackTranscript, provider: "codex", fallbackContextWindow: 258_400)) + + let explicitZeroTranscript = parseWorkChatTranscript(""" + {"sessionId":"chat-1","timestamp":"2026-03-17T00:00:00.000Z","sequence":1,"event":{"type":"context_compact","state":"completed","turnId":"turn-usage"}} + {"sessionId":"chat-1","timestamp":"2026-03-17T00:00:01.000Z","sequence":2,"event":{"type":"codex_token_usage","turnId":"turn-usage","usage":{"modelContextWindow":258400,"last":{"inputTokens":0}}}} + """) + guard case .tokens(let explicitZeroUsage, _, _) = explicitZeroTranscript.last?.event else { + return XCTFail("Expected explicit-zero Codex usage to normalize to a tokens event.") + } + XCTAssertTrue(explicitZeroUsage.isContextSnapshot) + let zeroViewModel = workContextUsageViewModel( + transcript: explicitZeroTranscript, + provider: "codex", + fallbackContextWindow: 258_400 + ) + XCTAssertEqual(zeroViewModel?.usedTokens, 0) + XCTAssertEqual(zeroViewModel?.ratio, 0) + } + + func testAgentChatEventEnvelopeDecodesMinimalClaudeContextUsageSnapshotAcrossCompaction() throws { + let json = """ + { + "sessionId": "session-usage", + "timestamp": "2026-03-17T00:00:01.000Z", + "sequence": 16, + "event": { + "type": "context_usage", + "turnId": "turn-usage", + "usage": { + "totalTokens": 31000, + "maxTokens": 200000 + } + } + } + """ + + let envelope = try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(json.utf8)) + guard case .contextUsage(let decodedUsage, let decodedTurnId) = envelope.event else { + return XCTFail("Expected a context usage event.") + } + XCTAssertTrue(decodedUsage.categories.isEmpty) + XCTAssertEqual(decodedUsage.percentage, 15.5, accuracy: 0.0001) + XCTAssertNil(decodedUsage.rawMaxTokens) + XCTAssertNil(decodedUsage.model) + XCTAssertEqual(decodedTurnId, "turn-usage") + + let event = makeWorkChatEvent(from: envelope.event) + guard case .tokens(let usage, let turnId, let itemId) = event else { + return XCTFail("Expected Claude context usage to normalize to a tokens event.") + } + XCTAssertTrue(usage.isContextSnapshot) + XCTAssertEqual(usage.inputTokens, 31_000) + XCTAssertEqual(usage.contextWindow, 200_000) + XCTAssertEqual(turnId, "turn-usage") + XCTAssertNil(itemId) + + let viewModel = workContextUsageViewModel( + transcript: [ + WorkChatEnvelope( + sessionId: envelope.sessionId, + timestamp: "2026-03-17T00:00:00.000Z", + sequence: 15, + event: .contextCompact( + summary: "Context compacted", + isInProgress: false, + postTokens: nil, + turnId: "turn-usage", + compactionId: "compact-1" + ) + ), + WorkChatEnvelope( + sessionId: envelope.sessionId, + timestamp: envelope.timestamp, + sequence: envelope.sequence, + event: event + ), + ], + provider: "claude", + fallbackContextWindow: nil + ) + XCTAssertEqual(viewModel?.usedTokens, 31_000) + XCTAssertEqual(viewModel?.contextWindow, 200_000) + XCTAssertEqual(viewModel?.ratio ?? 0, 0.155, accuracy: 0.0001) + } + @MainActor func testChatSubscriptionStateSurvivesDisconnectAndReplaysPayloads() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -11089,11 +11202,12 @@ final class ADETests: XCTestCase { {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":4,"event":{"type":"done","turnId":"turn-1","status":"completed","model":"claude-sonnet-4","usage":{"inputTokens":120,"outputTokens":45,"cacheReadTokens":12,"cacheCreationTokens":3,"reasoningTokens":7,"contextWindow":200000},"costUsd":1.23}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":5,"event":{"type":"tokens","turnId":"turn-1","itemId":"tok-1","inputTokens":169600,"outputTokens":701,"cacheReadTokens":168300,"cacheWriteTokens":1200,"contextWindow":258400}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:05.000Z","sequence":6,"event":{"type":"codex_token_usage","turnId":"turn-2","usage":{"threadId":"thread-1","turnId":"turn-2","modelContextWindow":258400,"last":{"inputTokens":170000,"outputTokens":800,"cacheReadTokens":168500,"cacheWriteTokens":1300,"reasoningTokens":21},"total":{"totalTokens":170800}}}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:06.000Z","sequence":7,"event":{"type":"context_usage","turnId":"turn-3","usage":{"categories":[],"totalTokens":31000,"maxTokens":200000,"percentage":15.5}}} """ let transcript = parseWorkChatTranscript(raw) - XCTAssertEqual(transcript.count, 6) + XCTAssertEqual(transcript.count, 7) guard case .command(let command, let cwd, let output, let status, let itemId, let exitCode, let durationMs, let turnId) = transcript[0].event else { return XCTFail("Expected command event.") @@ -11163,9 +11277,20 @@ final class ADETests: XCTestCase { XCTAssertEqual(codexUsage.reasoningTokens, 21) XCTAssertEqual(codexUsage.totalTokens, 170800) XCTAssertEqual(codexUsage.contextWindow, 258400) + XCTAssertTrue(codexUsage.isContextSnapshot) XCTAssertEqual(codexTurnId, "turn-2") XCTAssertEqual(codexItemId, nil) + guard case .tokens(let contextUsage, let contextTurnId, let contextItemId) = transcript[6].event else { + return XCTFail("Expected Claude context usage to normalize to a tokens event.") + } + XCTAssertTrue(contextUsage.isContextSnapshot) + XCTAssertEqual(contextUsage.inputTokens, 31_000) + XCTAssertEqual(contextUsage.totalTokens, 31_000) + XCTAssertEqual(contextUsage.contextWindow, 200_000) + XCTAssertEqual(contextTurnId, "turn-3") + XCTAssertNil(contextItemId) + let sessionUsage = summarizeWorkSessionUsage(from: transcript) XCTAssertEqual(sessionUsage?.turnCount, 1) XCTAssertEqual(sessionUsage?.inputTokens, 120) @@ -11283,6 +11408,189 @@ final class ADETests: XCTestCase { XCTAssertEqual(viewModel?.ratio ?? 0, 0.35, accuracy: 0.0001) } + func testWorkContextUsageViewModelInvalidatesStaleUsageAcrossAllProviderCompactions() { + for provider in ["claude", "codex", "opencode", "cursor", "droid"] { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .done( + status: "completed", + summary: "Full", + usage: WorkUsageSummary( + turnCount: 1, + inputTokens: 100_000, + outputTokens: 1_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + contextWindow: 100_000, + costUsd: 0 + ), + turnId: "turn-1", + model: nil, + modelId: nil + ) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:02.000Z", + sequence: 2, + event: .contextCompact( + summary: "Context compacted", + isInProgress: false, + postTokens: nil, + turnId: "turn-1", + compactionId: "compact-1" + ) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:03.000Z", + sequence: 3, + event: .done( + status: "completed", + summary: "Stale turn total", + usage: WorkUsageSummary( + turnCount: 1, + inputTokens: 100_000, + outputTokens: 1_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + contextWindow: 100_000, + costUsd: 0 + ), + turnId: "turn-1", + model: nil, + modelId: nil + ) + ), + ] + + XCTAssertNil( + workContextUsageViewModel( + transcript: transcript, + provider: provider, + fallbackContextWindow: 100_000 + ), + "Expected stale usage to be cleared for \(provider)" + ) + } + } + + func testWorkContextUsageViewModelUsesPostCompactionTokensAndIgnoresSameTurnTotals() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .contextCompact( + summary: "Context compacted", + isInProgress: false, + postTokens: 18_000, + turnId: "turn-1", + compactionId: "compact-1" + ) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:02.000Z", + sequence: 2, + event: .done( + status: "completed", + summary: "Stale turn total", + usage: WorkUsageSummary( + turnCount: 1, + inputTokens: 100_000, + outputTokens: 1_000, + cacheReadTokens: 0, + cacheCreationTokens: 0, + contextWindow: 100_000, + costUsd: 0 + ), + turnId: "turn-1", + model: nil, + modelId: nil + ) + ), + ] + + let viewModel = workContextUsageViewModel( + transcript: transcript, + provider: "claude", + fallbackContextWindow: 100_000 + ) + XCTAssertEqual(viewModel?.usedTokens, 18_000) + XCTAssertEqual(viewModel?.contextWindow, 100_000) + XCTAssertEqual(viewModel?.ratio ?? 0, 0.18, accuracy: 0.0001) + } + + func testWorkContextUsageViewModelProtectsExactSnapshotsUntilLaterTurn() { + let cases = [ + ( + provider: "claude", + boundary: #"{"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"context_compact","trigger":"auto","state":"completed","turnId":"turn-1"}}"#, + snapshot: #"{"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"context_usage","turnId":"turn-1","usage":{"totalTokens":24000,"maxTokens":100000}}}"#, + exactTokens: 24_000 + ), + ( + provider: "codex", + boundary: #"{"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"codex_context_compaction","trigger":"auto","state":"completed","turnId":"turn-1"}}"#, + snapshot: #"{"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"codex_token_usage","turnId":"turn-1","usage":{"modelContextWindow":100000,"last":{"inputTokens":21000}}}}"#, + exactTokens: 21_000 + ), + ] + + for testCase in cases { + let transcript = parseWorkChatTranscript(""" + \(testCase.boundary) + \(testCase.snapshot) + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":3,"event":{"type":"done","turnId":"turn-1","status":"completed","usage":{"inputTokens":100000,"contextWindow":100000}}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":4,"event":{"type":"tokens","turnId":"turn-old","inputTokens":90000,"contextWindow":100000}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:05.000Z","sequence":5,"event":{"type":"status","turnStatus":"started","turnId":"turn-2"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:06.000Z","sequence":6,"event":{"type":"tokens","turnId":"turn-2","inputTokens":30000,"contextWindow":100000}} + """) + + let exactViewModel = workContextUsageViewModel( + transcript: Array(transcript.prefix(4)), + provider: testCase.provider, + fallbackContextWindow: 100_000 + ) + XCTAssertEqual(exactViewModel?.usedTokens, testCase.exactTokens, "Expected protected \(testCase.provider) snapshot") + XCTAssertEqual(exactViewModel?.contextWindow, 100_000) + + let laterTurnViewModel = workContextUsageViewModel( + transcript: transcript, + provider: testCase.provider, + fallbackContextWindow: 100_000 + ) + XCTAssertEqual(laterTurnViewModel?.usedTokens, 30_000, "Expected later \(testCase.provider) turn to replace snapshot") + XCTAssertEqual(laterTurnViewModel?.ratio ?? 0, 0.3, accuracy: 0.0001) + } + + let noTurnIdTranscript = parseWorkChatTranscript(""" + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"context_compact","trigger":"auto","state":"completed","postTokens":24000}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"done","turnId":"turn-old","status":"completed","usage":{"inputTokens":90000,"contextWindow":100000}}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":3,"event":{"type":"status","turnStatus":"started","turnId":"turn-2"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":4,"event":{"type":"tokens","turnId":"turn-2","inputTokens":30000,"contextWindow":100000}} + """) + let protectedSnapshot = workContextUsageViewModel( + transcript: Array(noTurnIdTranscript.prefix(2)), + provider: "claude", + fallbackContextWindow: 100_000 + ) + XCTAssertEqual(protectedSnapshot?.usedTokens, 24_000) + XCTAssertEqual(protectedSnapshot?.ratio ?? 0, 0.24, accuracy: 0.0001) + + let laterTurn = workContextUsageViewModel( + transcript: noTurnIdTranscript, + provider: "claude", + fallbackContextWindow: 100_000 + ) + XCTAssertEqual(laterTurn?.usedTokens, 30_000) + XCTAssertEqual(laterTurn?.ratio ?? 0, 0.3, accuracy: 0.0001) + } + func testWorkChatStatusNormalizationPrefersAwaitingInputAndIdle() { let waitingSummary = makeAgentChatSessionSummary(status: "active", awaitingInput: true) XCTAssertEqual(normalizedWorkChatSessionStatus(session: nil, summary: waitingSummary), "awaiting-input") @@ -15909,13 +16217,13 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-06-15T00:00:01.000Z", sequence: 1, - event: .contextCompact(summary: "Manual", isInProgress: true, turnId: "turn-compact", compactionId: "turn-compact") + event: .contextCompact(summary: "Manual", isInProgress: true, postTokens: nil, turnId: "turn-compact", compactionId: "turn-compact") ), WorkChatEnvelope( sessionId: "chat-1", timestamp: "2026-06-15T00:00:02.000Z", sequence: 2, - event: .contextCompact(summary: "Manual\nPre-compact tokens: 12000", isInProgress: false, turnId: "turn-compact", compactionId: "turn-compact") + event: .contextCompact(summary: "Manual\nPre-compact tokens: 12000", isInProgress: false, postTokens: nil, turnId: "turn-compact", compactionId: "turn-compact") ), ] @@ -15934,7 +16242,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-06-15T00:00:01.000Z", sequence: 1, - event: .contextCompact(summary: "Auto", isInProgress: true, turnId: "turn-1", compactionId: "item-1") + event: .contextCompact(summary: "Auto", isInProgress: true, postTokens: nil, turnId: "turn-1", compactionId: "item-1") ), WorkChatEnvelope( sessionId: "chat-1", @@ -15943,6 +16251,7 @@ final class ADETests: XCTestCase { event: .contextCompact( summary: "Auto\nprovider:codex\n142k → 38k\nduration:12000ms", isInProgress: false, + postTokens: 38_000, turnId: "turn-2", compactionId: "item-1" ) @@ -15987,10 +16296,11 @@ final class ADETests: XCTestCase { XCTAssertEqual(turnId, "turn-2") let mapped = makeWorkChatEvent(from: event) - guard case let .contextCompact(summary, isInProgress, mappedTurnId, mappedCompactionId) = mapped else { + guard case let .contextCompact(summary, isInProgress, mappedPostTokens, mappedTurnId, mappedCompactionId) = mapped else { return XCTFail("Expected mapped contextCompact event") } XCTAssertFalse(isInProgress) + XCTAssertEqual(mappedPostTokens, 38_000) XCTAssertEqual(mappedTurnId, "turn-2") XCTAssertEqual(mappedCompactionId, "item-1") XCTAssertTrue(summary.contains("provider:codex")) diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 11a9bb92d..a5120bf0a 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -154,7 +154,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt Enter (or `o`) and keeps only Copy alternatives, so the original is not resumed twice. Import results install the returned persisted summary before refresh and open the session under its actual lane. -- **FooterControls** — two-row footer. The top row (mode bar, only present when there's content) shows provider glyph + label, model display, fast-mode badge, reasoning effort, permission summary, pending steer count, a 10-cell token usage bar (`TokenBar`) that recolors at 50 / 80 / 95 %, and the cached context-percent / token summary. The bottom row shows pane toggles (`^o` lanes, `^p` pane, `^a` chat info) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). The `⊚ chat info` chip shows the live subagent count when greater than zero. `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. +- **FooterControls** — two-row footer. The top row (mode bar, only present when there's content) shows provider glyph + label, model display, fast-mode badge, reasoning effort, permission summary, pending steer count, a compact context-usage dial, and the cached token summary. Completed compaction boundaries clear pre-compaction usage for all five SDK providers; Claude post-compaction tokens and Codex live thread usage can refill it immediately, while per-turn counters from the compacted turn are ignored. The bottom row shows pane toggles (`^o` lanes, `^p` pane, `^a` chat info) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). The `⊚ chat info` chip shows the live subagent count when greater than zero. `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. - **Provider CLI terminal control** — when the active session is a running provider CLI terminal (Claude, Codex, Cursor, Droid, or OpenCode), `Ctrl+T` moves keyboard input from ADE into that terminal. diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 9a1164141..d4ccff980 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -25,6 +25,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable scheduler for Claude `ScheduleWakeup`, `CronCreate`, and `/loop`. Persists versioned schedule records and per-chat pause state in the project SQLite `kv` store, restores and re-arms them on service start, coalesces overdue work to one late fire, advances recurring cron work to its next normal occurrence, cancels schedules whose session is missing or archived, and reports transitions back to `agentChatService`. Uses injected time/timer/persistence adapters so restart, pause, collision, and catch-up behavior can be tested without Electron. | | `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex thread-turn history into ADE `AgentChatEventEnvelope` rows. It reads at most the last 32 MB of source transcript bytes, keeps the newest 2,000 imported content events, emits system notices for provenance/truncation, drops metadata-only/provider-wrapper user rows without stripping user-authored JSX/XML, preserves failed Claude tool-result status, maps user/assistant text plus tool calls/results/file changes/commands/search/image events where available, and derives a fallback imported-chat title from the first user or assistant text. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | +| `apps/desktop/src/main/services/chat/contextCompactionEmitter.ts` | Normalizes Claude, Codex, OpenCode, Cursor, and Droid compaction lifecycle events into provider-tagged `context_compact` rows. It pairs started/completed boundaries, preserves provider-reported pre/post token counts and duration, and maintains the per-session compaction count used by transcript surfaces. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/shared/modelRegistry.ts`, `apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts` | Shared static model descriptors plus renderer merge of host-advertised catalogs. GPT-5.6 Sol/Terra/Luna stay first, Sol remains the Codex default, and runtime reasoning ladders pass through in provider order: Max precedes Ultra for Sol/Terra, while Luna ends at Max. | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns persistent project-profile partitions derived from the active project root (fallback `persist:ade-browser`) and one window/project browser service per ADE `BrowserWindow`, so each project keeps isolated cookies/storage while its tabs share that project's authenticated browser profile. Each window service manages multiple `WebContentsView` tabs (cap 10), active tab, per-tab lane/chat owner and lease metadata, lightweight browser agent sessions, bounds, visibility, inspect state, targeted status events, screenshot capture, scratch browser-agent observations, diagnostics, per-tab action traces, and emission of `BuiltInBrowserContextItem`s for selected page elements. Observe/click/type/key/scroll/fill/clear/wait/screenshot/select/reload/back/forward/stop can target a hidden or non-active tab by `tabId` or `sessionId`; inspect mode remains a visible-tab interaction. Sessions bind an agent workflow to one tab, remember owner plus last observation/trace ids, and have `ade browser session ` CLI aliases. Scratch observations live under `.ade/cache/browser-observations/`, include a bounded DOM element list plus console/network diagnostics by default, can render a numbered element-map screenshot with `includeElementMap`, and prune to the latest 3 observations per tab by default; click/fill/clear/press/wait can target viewport coordinates or resolve `selector`/`text`/`testId`/`elementIndex`/saved observation `handle` before dispatching CDP input. Waits wake from browser/network/page events with a timeout fallback, and `network-idle` requires complete ready state, no pending browser requests, and a configurable idle window. Handles preserve same-origin iframe/open-shadow-root context when available, and tab traces record action target metadata, duration, before/after URL, session id, observation id, and errors without storing typed fill/type text. `ade browser proof` promotes a fresh scratch observation to durable proof through the proof broker. Window-open requests from a page are handled via `setWindowOpenHandler` returning `action: "allow"` + a `createWindow` factory: a new internal tab is created and its `webContents` is returned to Chromium so the popup keeps its real `window.opener` relationship with the opener tab (important for OAuth flows that postMessage back to the parent). Download requests are saved through the browser session with sanitized, unique filenames in the user's Downloads folder instead of falling through to Chromium defaults. Navigation normalization/protocol policy lives in `builtInBrowserNavigation.ts`; Google sign-in permission policy lives in `builtInBrowserPermissions.ts`. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | @@ -70,6 +71,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `AgentChatSetScheduledWorkPaused*`, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `AgentChatScheduledWakeMetadata` marks synthetic unattended user turns with schedule id, kind, fire time, reason, and late state. `scheduled_work_update` captures Claude wake/cron/background lifecycle including `paused`, `firedAt`, and `late`; `AgentChatSessionSummary.nextWakeAt` and `scheduledWorkPaused` project durable scheduler state into session lists. `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may also carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | +| `apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts` | Provider-neutral context meter reducer. A completed `context_compact` boundary invalidates older usage for Claude, Codex, OpenCode, Cursor, and Droid; generic same-turn counters are ignored because those SDKs can report pre-compaction per-turn/cumulative totals after the history was replaced. Claude `postTokens` / exact `context_usage` and Codex `thread/tokenUsage/updated` snapshots can repopulate the meter immediately. Desktop, ADE Code, and iOS mirror this boundary rule. | | `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Reusable activity, token, code-movement, and client-mix module. `AgentChatPane` mounts `WorkActivityModule` directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter, defaults to all-time activity, and preserves explicit tab/range choices locally. | | `apps/desktop/src/renderer/lib/agentChatSessionListCache.ts` | Short-lived renderer cache for `ade.agentChat.list`, keyed by active project root, lane, automation, and archive flags. Normal reads coalesce; forced reads bypass an older in-flight promise, and promise-identity checks prevent the superseded response from repopulating the cache. Mutations invalidate by project/lane so remote Work panes do not fan out repeated list calls while still refreshing immediately after create/archive/delete. | | `apps/desktop/src/renderer/lib/chatSessionEvents.ts` | Renderer-local chat-session lifecycle helpers. `announceWorkChatSessionCreated` invalidates both Work session-list caches and publishes the durable session; Work and Lanes subscribers seed an optimistic row before their background refresh. `shouldRefreshSessionListForChatEvent` separately gates list refreshes for streamed chat events. | diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index c5140917e..1a34ea8f0 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -96,7 +96,7 @@ Two helpers summarise a parsed stream: | `completion_report` | Structured closeout produced by the `reportCompletion` workflow tool. | | `turn_diff_summary` | Git-level before/after SHA + per-file stats for a completed turn. | | `delegation_state` | Delegated worker state updates. | -| `context_compact` | Emitted before the provider compacts context (manual or auto). | +| `context_compact` | Provider-neutral manual/automatic compaction lifecycle. `state: "started"` begins the boundary and `state: "completed"` may carry `preTokens`, `postTokens`, `tokensRemoved`, `durationMs`, provider, and per-session count. A completed boundary invalidates older context-meter usage on desktop, ADE Code, and iOS; exact post-compaction snapshots may refill the meter immediately, while stale same-turn aggregate counters are ignored. | | `web_search` | Provider-neutral web-search/fetch lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. Actions can carry `query`, `queries`, `title`, `url`, and `snippet`; desktop and iOS render URL actions as in-app-browser result chips, while the TUI keeps a concise one-line action summary. Codex emits native web-search items; `claudeStructuredActivity.ts` maps Claude server-tool blocks into the same event. | | `codex_image_generation` / `codex_image_view` | Compact generated/viewed-image lifecycle used across providers despite the legacy type prefix. Codex emits native image items, Cursor maps `generateImage`, OpenCode maps image `file` parts, and Droid maps assistant image blocks. Large stored data URIs are removed with original/omitted byte metadata. | | `codex_safety_buffering` / `codex_moderation_metadata` / `codex_sleep` / `codex_thread_deleted` / `codex_turn_stalled` | Codex app-server runtime state. Safety buffering, moderation metadata, and sleep are compact status rows; `codex_thread_deleted` clears the stored upstream thread; `codex_turn_stalled` is the structured recovery event shown when a turn produced no useful output after app-server reconciliation. Its actions are `wait`, `steer`, `interrupt_retry_same_thread`, and `restart_resume_thread`. | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index b999f5189..da5e05951 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -284,9 +284,11 @@ The Work model/activity parity path is concentrated in these files: - `ADE/Views/Work/WorkModelCatalog.swift` and `WorkModelPickerSheet.swift` — host-first model catalog merge, GPT-5.6 ordering/defaults/visible tiers, Fast, and the Ultra usage warning. -- `ADE/Views/Work/WorkEventMapping.swift`, `WorkTranscriptParser.swift`, and +- `ADE/Views/Work/WorkEventMapping.swift`, `WorkTranscriptParser.swift`, + `WorkModels.swift`, `WorkTimelineHelpers.swift`, and `WorkStatusAndFormattingHelpers.swift` — compact web/MCP/image mapping for - both live Codable events and persisted JSONL fallback. + both live Codable events and persisted JSONL fallback, plus the Work context + meter's provider-neutral usage and compaction-boundary reduction. - `ADE/Services/SyncService.swift` and `ADE/Views/Work/WorkSessionDestinationView+Actions.swift` — host-advertised `chat.recoverCodexTurn` dispatch for stalled-turn buttons. @@ -1488,6 +1490,15 @@ different machine's cached limits. Codex and other adapters reuse compact tool cards; data URIs are never printed into the timeline, and stored/mobile compaction byte counts become a short "preview omitted" detail. +- **The Work context meter treats completed compaction as a usage boundary.** + `RemoteModels.swift`, `WorkEventMapping.swift`, and the persisted JSONL parser + retain `context_compact.postTokens`. `workContextUsageViewModel` in + `WorkTimelineHelpers.swift` walks the ordered event stream, clears usage from + before a completed compaction for Claude, Codex, OpenCode, Cursor, and Droid, + and rejects generic `tokens` / `done` counters from the compacted turn. An + exact post-compaction token count or Codex context snapshot can refill the + meter immediately; otherwise it stays empty until a trustworthy later usage + event arrives. - **Subagent lifecycle is rendered as chat structure, not event spam.** `RemoteModels.swift` accepts both legacy `subagent_started` / `subagent_progress` / `subagent_result` events and the canonical dotted