diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b6b48c7ed..027f537f9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1070,6 +1070,48 @@ describe("ProviderRuntimeIngestion", () => { expect(resolvedPayload?.requestType).toBe("command_execution_approval"); }); + it("normalizes Codex token_count last_token_usage payload into context usage activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "thread.token-usage.updated", + eventId: asEventId("evt-thread-token-usage-codex-shape"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + usage: { + last_token_usage: { + total_tokens: 51800, + }, + model_context_window: 258400, + }, + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-thread-token-usage-codex-shape" && + activity.kind === "thread.context.usage.updated", + ), + ); + + const usageActivity = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => + activity.id === "evt-thread-token-usage-codex-shape", + ); + const usagePayload = + usageActivity?.payload && typeof usageActivity.payload === "object" + ? (usageActivity.payload as Record) + : undefined; + + expect(usagePayload?.usedTokens).toBe(51800); + expect(usagePayload?.maxTokens).toBe(258400); + expect(usagePayload?.percentUsed).toBeCloseTo((51800 / 258400) * 100, 6); + }); + it("maps runtime.error into errored session state", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 417e93c8d..da64c128d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -98,6 +98,57 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function asNonNegativeNumber(value: unknown): number | undefined { + const parsed = + typeof value === "number" && Number.isFinite(value) + ? value + : typeof value === "string" + ? Number.parseFloat(value.trim()) + : Number.NaN; + if (!Number.isFinite(parsed) || parsed < 0) { + return undefined; + } + return parsed; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function normalizeThreadTokenUsagePayload(usage: unknown): { + usedTokens: number | null; + maxTokens: number | null; + percentUsed: number | null; + sourceUsage: unknown; +} { + const usageRecord = asRecord(usage); + const lastTokenUsage = usageRecord + ? (asRecord(usageRecord.last_token_usage) ?? asRecord(usageRecord.lastTokenUsage)) + : undefined; + // Codex token_count info shape: + // usage: { last_token_usage: { total_tokens }, model_context_window } + const usedTokens = asNonNegativeNumber( + lastTokenUsage?.total_tokens ?? lastTokenUsage?.totalTokens, + ); + const maxTokens = asNonNegativeNumber( + usageRecord?.model_context_window ?? usageRecord?.modelContextWindow, + ); + const percentUsedFromUsage = + usedTokens !== undefined && maxTokens !== undefined && maxTokens > 0 + ? Math.min((usedTokens / maxTokens) * 100, 100) + : undefined; + + return { + usedTokens: usedTokens ?? null, + maxTokens: maxTokens ?? null, + percentUsed: percentUsedFromUsage ?? null, + sourceUsage: usage, + }; +} + function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { const payload = (event as { payload?: unknown }).payload; if (!payload || typeof payload !== "object") { @@ -184,6 +235,43 @@ function runtimeEventToActivities( : {}; })(); switch (event.type) { + case "thread.token-usage.updated": { + const normalizedUsage = normalizeThreadTokenUsagePayload(event.payload.usage); + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "thread.context.usage.updated", + summary: "Context usage updated", + payload: normalizedUsage, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + + case "thread.state.changed": { + if (event.payload.state !== "compacted") { + return []; + } + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "thread.context.compacted", + summary: "Context compacted", + payload: { + state: event.payload.state, + ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "request.opened": { if (event.payload.requestType === "tool_user_input") { return []; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3ad206d0b..b43cf6c5f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -407,6 +407,53 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect( + "maps codex/event/token_count into thread.token-usage.updated using last_token_usage info", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-token-count"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "codex/event/token_count", + payload: { + id: "evt-token-count", + msg: { + type: "token_count", + info: { + last_token_usage: { + total_tokens: 87429, + }, + model_context_window: 258400, + }, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "thread.token-usage.updated"); + if (firstEvent.value.type !== "thread.token-usage.updated") { + return; + } + assert.deepEqual(firstEvent.value.payload.usage, { + last_token_usage: { + total_tokens: 87429, + }, + model_context_window: 258400, + }); + }), + ); + it.effect("maps retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 1e4b80ae9..c0f77c797 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -700,15 +700,7 @@ function mapToRuntimeEvents( } if (event.method === "thread/tokenUsage/updated") { - return [ - { - type: "thread.token-usage.updated", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - usage: event.payload ?? {}, - }, - }, - ]; + return []; } if (event.method === "turn/started") { @@ -945,6 +937,23 @@ function mapToRuntimeEvents( ]; } + if (event.method === "codex/event/token_count") { + const msg = codexEventMessage(payload); + const info = asObject(msg?.info); + if (!info) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "thread.token-usage.updated", + payload: { + usage: info, + }, + }, + ]; + } + if (event.method === "codex/event/task_started") { const msg = codexEventMessage(payload); const taskId = asString(payload?.id) ?? asString(msg?.turn_id); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 64c15ba2f..198538c95 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,6 +53,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, + deriveThreadContextUsageSnapshot, findLatestProposedPlan, deriveWorkLogEntries, hasToolActivityForTurn, @@ -139,6 +140,7 @@ import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommand import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; +import { ComposerContextUsageIndicator } from "./chat/ComposerContextUsageIndicator"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; @@ -581,6 +583,13 @@ export default function ChatView({ threadId }: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); + const threadContextUsageSnapshot = useMemo( + () => deriveThreadContextUsageSnapshot(threadActivities, nowIso), + [nowIso, threadActivities], + ); + useEffect(() => { + setNowTick(Date.now()); + }, [threadActivities]); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => @@ -1889,14 +1898,14 @@ export default function ChatView({ threadId }: ChatViewProps) { : "local"; useEffect(() => { - if (phase !== "running") return; + if (phase !== "running" && !threadContextUsageSnapshot.recentlyCompacted) return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; - }, [phase]); + }, [phase, threadContextUsageSnapshot.recentlyCompacted]); const beginSendPhase = useCallback((nextPhase: Exclude) => { setSendStartedAt((current) => current ?? new Date().toISOString()); @@ -3510,6 +3519,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider={selectedProvider} selectedCodexFastModeEnabled={selectedCodexFastModeEnabled} reasoningOptions={reasoningOptions} + contextUsageSnapshot={threadContextUsageSnapshot} onEffortSelect={onEffortSelect} onCodexFastModeChange={onCodexFastModeChange} onToggleInteractionMode={toggleInteractionMode} @@ -3608,6 +3618,12 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : null} + + + )} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0af50ff01..e84950cd4 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -7,6 +7,7 @@ import { import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import type { ThreadContextUsageSnapshot } from "../../session-logic"; import { Button } from "../ui/button"; import { Menu, @@ -18,6 +19,7 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { buildComposerContextUsageIndicatorViewModel } from "./ComposerContextUsageIndicator.logic"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; @@ -28,6 +30,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls selectedProvider: ProviderKind; selectedCodexFastModeEnabled: boolean; reasoningOptions: ReadonlyArray; + contextUsageSnapshot: ThreadContextUsageSnapshot | null; onEffortSelect: (effort: CodexReasoningEffort) => void; onCodexFastModeChange: (enabled: boolean) => void; onToggleInteractionMode: () => void; @@ -35,6 +38,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onToggleRuntimeMode: () => void; }) { const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const contextViewModel = buildComposerContextUsageIndicatorViewModel(props.contextUsageSnapshot); const reasoningLabelByOption: Record = { low: "Low", medium: "Medium", @@ -121,6 +125,21 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Full access + + +
Context window:
+
+ {contextViewModel.summaryLine} +
+
+ {contextViewModel.tokensLine} +
+ {contextViewModel.showCompactionNotice ? ( +
+ Context was compacted recently. +
+ ) : null} +
{props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/ComposerContextUsageIndicator.logic.ts b/apps/web/src/components/chat/ComposerContextUsageIndicator.logic.ts new file mode 100644 index 000000000..d67673b3a --- /dev/null +++ b/apps/web/src/components/chat/ComposerContextUsageIndicator.logic.ts @@ -0,0 +1,65 @@ +import { + deriveContextUsageSeverity, + type ThreadContextUsageSeverity, + type ThreadContextUsageSnapshot, +} from "../../session-logic"; + +export interface ComposerContextUsageIndicatorViewModel { + severity: ThreadContextUsageSeverity; + progressPercent: number | null; + summaryLine: string; + tokensLine: string; + showCompactionNotice: boolean; + ariaLabel: string; +} + +function formatPercent(value: number): string { + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)}%`; +} + +function formatCompactTokenCount(value: number): string { + return new Intl.NumberFormat(undefined, { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, + }) + .format(value) + .toLowerCase(); +} + +export function buildComposerContextUsageIndicatorViewModel( + snapshot: ThreadContextUsageSnapshot | null, +): ComposerContextUsageIndicatorViewModel { + const usedTokens = snapshot?.usedTokens ?? null; + const maxTokens = snapshot?.maxTokens ?? null; + const percentUsed = snapshot?.percentUsed ?? null; + const showCompactionNotice = snapshot?.recentlyCompacted ?? false; + const hasUsage = usedTokens !== null || maxTokens !== null; + const progressPercent = maxTokens !== null && percentUsed !== null ? percentUsed : null; + const severity = deriveContextUsageSeverity(progressPercent); + const summaryLine = + progressPercent !== null + ? `${formatPercent(progressPercent)} used (${formatPercent(Math.max(0, 100 - progressPercent))} left)` + : usedTokens !== null + ? `${formatCompactTokenCount(usedTokens)} used (max unknown)` + : "Context usage unavailable"; + const tokensLine = !hasUsage + ? "Usage not yet available" + : maxTokens !== null + ? `${formatCompactTokenCount(usedTokens ?? 0)} / ${formatCompactTokenCount(maxTokens)} tokens used` + : `${formatCompactTokenCount(usedTokens ?? 0)} tokens used`; + + const ariaLabelParts = [summaryLine, tokensLine]; + if (showCompactionNotice) { + ariaLabelParts.push("Context was compacted recently."); + } + + return { + severity, + progressPercent, + summaryLine, + tokensLine, + showCompactionNotice, + ariaLabel: ariaLabelParts.join(" "), + }; +} diff --git a/apps/web/src/components/chat/ComposerContextUsageIndicator.tsx b/apps/web/src/components/chat/ComposerContextUsageIndicator.tsx new file mode 100644 index 000000000..65b84df85 --- /dev/null +++ b/apps/web/src/components/chat/ComposerContextUsageIndicator.tsx @@ -0,0 +1,100 @@ +import { memo } from "react"; + +import { cn } from "~/lib/utils"; + +import type { ThreadContextUsageSnapshot } from "../../session-logic"; +import { Button } from "../ui/button"; +import { Menu, MenuGroup, MenuPopup, MenuSeparator as MenuDivider, MenuTrigger } from "../ui/menu"; +import { buildComposerContextUsageIndicatorViewModel } from "./ComposerContextUsageIndicator.logic"; + +const RING_RADIUS = 6; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +export const ComposerContextUsageIndicator = memo(function ComposerContextUsageIndicator({ + snapshot, +}: { + snapshot: ThreadContextUsageSnapshot | null; +}) { + const viewModel = buildComposerContextUsageIndicatorViewModel(snapshot); + const ringToneClass = + viewModel.severity === "danger" + ? "stroke-rose-500" + : viewModel.severity === "warning" + ? "stroke-amber-500" + : "stroke-muted-foreground/80"; + const progressPercent = viewModel.progressPercent; + const dashOffset = + progressPercent === null + ? RING_CIRCUMFERENCE + : RING_CIRCUMFERENCE * (1 - Math.max(0, Math.min(progressPercent, 100)) / 100); + + return ( + + + } + > + + {viewModel.showCompactionNotice ? ( + + + +
Context window:
+
+ {viewModel.summaryLine} +
+
+ {viewModel.tokensLine} +
+ {viewModel.showCompactionNotice ? ( + <> + +
+ Context was compacted recently. +
+ + ) : null} +
+
+
+ ); +}); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 74ba3a814..ce48ed6d9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -7,11 +7,13 @@ import { PROVIDER_OPTIONS, derivePendingApprovals, derivePendingUserInputs, + deriveThreadContextUsageSnapshot, deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, + THREAD_CONTEXT_COMPACTION_RECENT_WINDOW_MS, } from "./session-logic"; function makeActivity(overrides: { @@ -222,6 +224,114 @@ describe("derivePendingUserInputs", () => { }); }); +describe("deriveThreadContextUsageSnapshot", () => { + it("uses the newest context usage activity values and nulls percent when max is unknown", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "context-usage-older", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "thread.context.usage.updated", + summary: "Context usage updated", + tone: "info", + payload: { + usedTokens: 100_000, + maxTokens: 200_000, + percentUsed: 50, + sourceUsage: { older: true }, + }, + }), + makeActivity({ + id: "context-usage-newer", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "thread.context.usage.updated", + summary: "Context usage updated", + tone: "info", + payload: { + usedTokens: 87_000, + maxTokens: null, + percentUsed: 70, + sourceUsage: { newer: true }, + }, + }), + ]; + + expect(deriveThreadContextUsageSnapshot(activities, "2026-02-23T00:00:05.000Z")).toMatchObject({ + usedTokens: 87_000, + maxTokens: null, + percentUsed: null, + sourceUsage: { newer: true }, + updatedAt: "2026-02-23T00:00:02.000Z", + compactedAt: null, + recentlyCompacted: false, + }); + }); + + it("marks compaction recency only within the recency window", () => { + const compactedAt = "2026-02-23T00:00:00.000Z"; + const withinWindowIso = "2026-02-23T00:01:00.000Z"; + const outsideWindowIso = new Date( + Date.parse(compactedAt) + THREAD_CONTEXT_COMPACTION_RECENT_WINDOW_MS + 1, + ).toISOString(); + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "context-compacted", + createdAt: compactedAt, + kind: "thread.context.compacted", + summary: "Context compacted", + tone: "info", + payload: { + state: "compacted", + }, + }), + ]; + + expect(deriveThreadContextUsageSnapshot(activities, withinWindowIso).recentlyCompacted).toBe( + true, + ); + expect(deriveThreadContextUsageSnapshot(activities, outsideWindowIso).recentlyCompacted).toBe( + false, + ); + }); + + it("clears stale usage when compaction is newer than the latest usage update", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "context-usage", + createdAt: "2026-02-23T00:00:00.000Z", + kind: "thread.context.usage.updated", + summary: "Context usage updated", + tone: "info", + payload: { + usedTokens: 120_000, + maxTokens: 258_400, + percentUsed: (120_000 / 258_400) * 100, + sourceUsage: { last_token_usage: { total_tokens: 120_000 } }, + }, + }), + makeActivity({ + id: "context-compacted", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "thread.context.compacted", + summary: "Context compacted", + tone: "info", + payload: { + state: "compacted", + }, + }), + ]; + + expect(deriveThreadContextUsageSnapshot(activities, "2026-02-23T00:00:30.000Z")).toMatchObject({ + usedTokens: null, + maxTokens: null, + percentUsed: null, + sourceUsage: null, + updatedAt: null, + compactedAt: "2026-02-23T00:00:01.000Z", + recentlyCompacted: true, + }); + }); +}); + describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e389f10e2..96d58f4ab 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -56,6 +56,20 @@ export interface PendingUserInput { questions: ReadonlyArray; } +export const THREAD_CONTEXT_COMPACTION_RECENT_WINDOW_MS = 2 * 60 * 1000; + +export interface ThreadContextUsageSnapshot { + usedTokens: number | null; + maxTokens: number | null; + percentUsed: number | null; + sourceUsage: unknown; + updatedAt: string | null; + compactedAt: string | null; + recentlyCompacted: boolean; +} + +export type ThreadContextUsageSeverity = "neutral" | "warning" | "danger"; + export interface ActivePlanState { createdAt: string; turnId: TurnId | null; @@ -300,6 +314,125 @@ export function derivePendingUserInputs( ); } +function asNonNegativeNumber(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + return value; +} + +function normalizePercentUsed(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null; + } + return value <= 1 ? value * 100 : value; +} + +function normalizeThreadContextUsageActivityPayload(payload: Record | null): { + usedTokens: number | null; + maxTokens: number | null; + percentUsed: number | null; + sourceUsage: unknown; +} { + const usedTokens = asNonNegativeNumber(payload?.usedTokens); + const maxTokens = asNonNegativeNumber(payload?.maxTokens); + const percentFromPayload = normalizePercentUsed(payload?.percentUsed); + const percentUsed = + usedTokens !== null && maxTokens !== null && maxTokens > 0 + ? Math.min((usedTokens / maxTokens) * 100, 100) + : maxTokens === null + ? null + : percentFromPayload; + + return { + usedTokens, + maxTokens, + percentUsed, + sourceUsage: payload?.sourceUsage ?? null, + }; +} + +export function deriveContextUsageSeverity(percentUsed: number | null): ThreadContextUsageSeverity { + if (percentUsed === null || !Number.isFinite(percentUsed)) { + return "neutral"; + } + if (percentUsed > 85) { + return "danger"; + } + if (percentUsed >= 70) { + return "warning"; + } + return "neutral"; +} + +export function deriveThreadContextUsageSnapshot( + activities: ReadonlyArray, + nowIso: string, +): ThreadContextUsageSnapshot { + let latestUsageAt: string | null = null; + let usedTokens: number | null = null; + let maxTokens: number | null = null; + let percentUsed: number | null = null; + let sourceUsage: unknown = null; + let compactedAt: string | null = null; + + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity) { + continue; + } + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + + if (latestUsageAt === null && activity.kind === "thread.context.usage.updated") { + const normalized = normalizeThreadContextUsageActivityPayload(payload); + latestUsageAt = activity.createdAt; + usedTokens = normalized.usedTokens; + maxTokens = normalized.maxTokens; + percentUsed = normalized.percentUsed; + sourceUsage = normalized.sourceUsage; + } + + if (compactedAt === null && activity.kind === "thread.context.compacted") { + compactedAt = activity.createdAt; + } + + if (latestUsageAt !== null && compactedAt !== null) { + break; + } + } + + const nowMs = Date.parse(nowIso); + const usageAtMs = latestUsageAt ? Date.parse(latestUsageAt) : Number.NaN; + const compactedAtMs = compactedAt ? Date.parse(compactedAt) : Number.NaN; + const compactionInvalidatesUsage = + Number.isFinite(usageAtMs) && Number.isFinite(compactedAtMs) && compactedAtMs > usageAtMs; + if (compactionInvalidatesUsage) { + latestUsageAt = null; + usedTokens = null; + maxTokens = null; + percentUsed = null; + sourceUsage = null; + } + const recentlyCompacted = + Number.isFinite(nowMs) && + Number.isFinite(compactedAtMs) && + nowMs >= compactedAtMs && + nowMs - compactedAtMs <= THREAD_CONTEXT_COMPACTION_RECENT_WINDOW_MS; + + return { + usedTokens, + maxTokens, + percentUsed, + sourceUsage, + updatedAt: latestUsageAt, + compactedAt, + recentlyCompacted, + }; +} + export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined,