authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 12:36:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 12:36:51-07:00
log8f3515572de09d41cbfe586a94696277ef000bb9
treefedb221dbc0ca8ea760ce6bf3d1dd17512c58ee0
parent529315b4c8ec274980b06c41170f48b01fed6646
signature Signed by PGP key B5690EEEBB952194

Surface context window usage in the UI (#1351)


15 files changed, 1040 insertions(+), 3 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+179
......@@ -1898,6 +1898,185 @@ describe("ProviderRuntimeIngestion", () => {
18981898 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");
18991899 });
19001900
1901 it("projects context window updates into normalized thread activities", async () => {
1902 const harness = await createHarness();
1903 const now = new Date().toISOString();
1904
1905 harness.emit({
1906 type: "thread.token-usage.updated",
1907 eventId: asEventId("evt-thread-token-usage-updated"),
1908 provider: "codex",
1909 createdAt: now,
1910 threadId: asThreadId("thread-1"),
1911 payload: {
1912 usage: {
1913 usedTokens: 1075,
1914 totalProcessedTokens: 10_200,
1915 maxTokens: 128_000,
1916 inputTokens: 1000,
1917 cachedInputTokens: 500,
1918 outputTokens: 50,
1919 reasoningOutputTokens: 25,
1920 lastUsedTokens: 1075,
1921 lastInputTokens: 1000,
1922 lastCachedInputTokens: 500,
1923 lastOutputTokens: 50,
1924 lastReasoningOutputTokens: 25,
1925 compactsAutomatically: true,
1926 },
1927 },
1928 });
1929
1930 const thread = await waitForThread(harness.engine, (entry) =>
1931 entry.activities.some(
1932 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
1933 ),
1934 );
1935
1936 const usageActivity = thread.activities.find(
1937 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
1938 );
1939 expect(usageActivity).toBeDefined();
1940 expect(usageActivity?.payload).toMatchObject({
1941 usedTokens: 1075,
1942 totalProcessedTokens: 10_200,
1943 maxTokens: 128_000,
1944 inputTokens: 1000,
1945 cachedInputTokens: 500,
1946 outputTokens: 50,
1947 reasoningOutputTokens: 25,
1948 lastUsedTokens: 1075,
1949 compactsAutomatically: true,
1950 });
1951 });
1952
1953 it("projects Codex camelCase token usage payloads into normalized thread activities", async () => {
1954 const harness = await createHarness();
1955 const now = new Date().toISOString();
1956
1957 harness.emit({
1958 type: "thread.token-usage.updated",
1959 eventId: asEventId("evt-thread-token-usage-updated-camel"),
1960 provider: "codex",
1961 createdAt: now,
1962 threadId: asThreadId("thread-1"),
1963 payload: {
1964 usage: {
1965 usedTokens: 126,
1966 totalProcessedTokens: 11_839,
1967 maxTokens: 258_400,
1968 inputTokens: 120,
1969 cachedInputTokens: 0,
1970 outputTokens: 6,
1971 reasoningOutputTokens: 0,
1972 lastUsedTokens: 126,
1973 lastInputTokens: 120,
1974 lastCachedInputTokens: 0,
1975 lastOutputTokens: 6,
1976 lastReasoningOutputTokens: 0,
1977 compactsAutomatically: true,
1978 },
1979 },
1980 });
1981
1982 const thread = await waitForThread(harness.engine, (entry) =>
1983 entry.activities.some(
1984 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
1985 ),
1986 );
1987
1988 const usageActivity = thread.activities.find(
1989 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
1990 );
1991 expect(usageActivity?.payload).toMatchObject({
1992 usedTokens: 126,
1993 totalProcessedTokens: 11_839,
1994 maxTokens: 258_400,
1995 inputTokens: 120,
1996 cachedInputTokens: 0,
1997 outputTokens: 6,
1998 reasoningOutputTokens: 0,
1999 lastUsedTokens: 126,
2000 lastInputTokens: 120,
2001 lastOutputTokens: 6,
2002 compactsAutomatically: true,
2003 });
2004 });
2005
2006 it("projects Claude usage snapshots with context window into normalized thread activities", async () => {
2007 const harness = await createHarness();
2008 const now = new Date().toISOString();
2009
2010 harness.emit({
2011 type: "thread.token-usage.updated",
2012 eventId: asEventId("evt-thread-token-usage-updated-claude-window"),
2013 provider: "claudeAgent",
2014 createdAt: now,
2015 threadId: asThreadId("thread-1"),
2016 payload: {
2017 usage: {
2018 usedTokens: 31_251,
2019 lastUsedTokens: 31_251,
2020 maxTokens: 200_000,
2021 toolUses: 25,
2022 durationMs: 43_567,
2023 },
2024 },
2025 raw: {
2026 source: "claude.sdk.message",
2027 method: "claude/result/success",
2028 payload: {},
2029 },
2030 });
2031
2032 const thread = await waitForThread(harness.engine, (entry) =>
2033 entry.activities.some(
2034 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
2035 ),
2036 );
2037
2038 const usageActivity = thread.activities.find(
2039 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
2040 );
2041 expect(usageActivity?.payload).toMatchObject({
2042 usedTokens: 31_251,
2043 lastUsedTokens: 31_251,
2044 maxTokens: 200_000,
2045 toolUses: 25,
2046 durationMs: 43_567,
2047 });
2048 });
2049
2050 it("projects compacted thread state into context compaction activities", async () => {
2051 const harness = await createHarness();
2052 const now = new Date().toISOString();
2053
2054 harness.emit({
2055 type: "thread.state.changed",
2056 eventId: asEventId("evt-thread-compacted"),
2057 provider: "codex",
2058 createdAt: now,
2059 threadId: asThreadId("thread-1"),
2060 turnId: asTurnId("turn-1"),
2061 payload: {
2062 state: "compacted",
2063 detail: { source: "provider" },
2064 },
2065 });
2066
2067 const thread = await waitForThread(harness.engine, (entry) =>
2068 entry.activities.some(
2069 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction",
2070 ),
2071 );
2072
2073 const activity = thread.activities.find(
2074 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",
2075 );
2076 expect(activity?.summary).toBe("Context compacted");
2077 expect(activity?.tone).toBe("info");
2078 });
2079
19012080 it("projects Codex task lifecycle chunks into thread activities", async () => {
19022081 const harness = await createHarness();
19032082 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+52
......@@ -8,6 +8,7 @@ import {
88 CheckpointRef,
99 isToolLifecycleItemType,
1010 ThreadId,
11 type ThreadTokenUsageSnapshot,
1112 TurnId,
1213 type OrchestrationThreadActivity,
1314 type ProviderRuntimeEvent,
......@@ -101,6 +102,15 @@ function asString(value: unknown): string | undefined {
101102 return typeof value === "string" ? value : undefined;
102103}
103104
105function buildContextWindowActivityPayload(
106 event: ProviderRuntimeEvent,
107): ThreadTokenUsageSnapshot | undefined {
108 if (event.type !== "thread.token-usage.updated" || event.payload.usage.usedTokens <= 0) {
109 return undefined;
110 }
111 return event.payload.usage;
112}
113
104114function runtimePayloadRecord(event: ProviderRuntimeEvent): Record<string, unknown> | undefined {
105115 const payload = (event as { payload?: unknown }).payload;
106116 if (!payload || typeof payload !== "object") {
......@@ -409,6 +419,48 @@ function runtimeEventToActivities(
409419 ];
410420 }
411421
422 case "thread.state.changed": {
423 if (event.payload.state !== "compacted") {
424 return [];
425 }
426
427 return [
428 {
429 id: event.eventId,
430 createdAt: event.createdAt,
431 tone: "info",
432 kind: "context-compaction",
433 summary: "Context compacted",
434 payload: {
435 state: event.payload.state,
436 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
437 },
438 turnId: toTurnId(event.turnId) ?? null,
439 ...maybeSequence,
440 },
441 ];
442 }
443
444 case "thread.token-usage.updated": {
445 const payload = buildContextWindowActivityPayload(event);
446 if (!payload) {
447 return [];
448 }
449
450 return [
451 {
452 id: event.eventId,
453 createdAt: event.createdAt,
454 tone: "info",
455 kind: "context-window.updated",
456 summary: "Context window updated",
457 payload,
458 turnId: toTurnId(event.turnId) ?? null,
459 ...maybeSequence,
460 },
461 ];
462 }
463
412464 case "item.updated": {
413465 if (!isToolLifecycleItemType(event.payload.itemType)) {
414466 return [];
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+122-1
......@@ -1225,7 +1225,7 @@ describe("ClaudeAdapterLive", () => {
12251225 return Effect.gen(function* () {
12261226 const adapter = yield* ClaudeAdapter;
12271227
1228 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe(
1228 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(
12291229 Stream.runCollect,
12301230 Effect.forkChild,
12311231 );
......@@ -1267,6 +1267,127 @@ describe("ClaudeAdapterLive", () => {
12671267 );
12681268 });
12691269
1270 it.effect("emits thread token usage updates from Claude task progress", () => {
1271 const harness = makeHarness();
1272 return Effect.gen(function* () {
1273 const adapter = yield* ClaudeAdapter;
1274
1275 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(
1276 Stream.runCollect,
1277 Effect.forkChild,
1278 );
1279
1280 yield* adapter.startSession({
1281 threadId: THREAD_ID,
1282 provider: "claudeAgent",
1283 runtimeMode: "full-access",
1284 });
1285
1286 harness.query.emit({
1287 type: "system",
1288 subtype: "task_progress",
1289 task_id: "task-usage-1",
1290 description: "Thinking through the patch",
1291 usage: {
1292 total_tokens: 321,
1293 tool_uses: 2,
1294 duration_ms: 654,
1295 },
1296 session_id: "sdk-session-task-usage",
1297 uuid: "task-usage-progress-1",
1298 } as unknown as SDKMessage);
1299
1300 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1301 const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated");
1302 const progressEvent = runtimeEvents.find((event) => event.type === "task.progress");
1303 assert.equal(usageEvent?.type, "thread.token-usage.updated");
1304 if (usageEvent?.type === "thread.token-usage.updated") {
1305 assert.deepEqual(usageEvent.payload, {
1306 usage: {
1307 usedTokens: 321,
1308 lastUsedTokens: 321,
1309 toolUses: 2,
1310 durationMs: 654,
1311 },
1312 });
1313 }
1314 assert.equal(progressEvent?.type, "task.progress");
1315 if (usageEvent && progressEvent) {
1316 assert.notStrictEqual(usageEvent.eventId, progressEvent.eventId);
1317 }
1318 }).pipe(
1319 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1320 Effect.provide(harness.layer),
1321 );
1322 });
1323
1324 it.effect("emits Claude context window on result completion usage snapshots", () => {
1325 const harness = makeHarness();
1326 return Effect.gen(function* () {
1327 const adapter = yield* ClaudeAdapter;
1328
1329 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe(
1330 Stream.runCollect,
1331 Effect.forkChild,
1332 );
1333
1334 yield* adapter.startSession({
1335 threadId: THREAD_ID,
1336 provider: "claudeAgent",
1337 runtimeMode: "full-access",
1338 });
1339
1340 yield* adapter.sendTurn({
1341 threadId: THREAD_ID,
1342 input: "hello",
1343 attachments: [],
1344 });
1345
1346 harness.query.emit({
1347 type: "result",
1348 subtype: "success",
1349 is_error: false,
1350 duration_ms: 1234,
1351 duration_api_ms: 1200,
1352 num_turns: 1,
1353 result: "done",
1354 stop_reason: "end_turn",
1355 session_id: "sdk-session-result-usage",
1356 usage: {
1357 input_tokens: 4,
1358 cache_creation_input_tokens: 2715,
1359 cache_read_input_tokens: 21144,
1360 output_tokens: 679,
1361 },
1362 modelUsage: {
1363 "claude-opus-4-6": {
1364 contextWindow: 200000,
1365 maxOutputTokens: 64000,
1366 },
1367 },
1368 } as unknown as SDKMessage);
1369 harness.query.finish();
1370
1371 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1372 const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated");
1373 assert.equal(usageEvent?.type, "thread.token-usage.updated");
1374 if (usageEvent?.type === "thread.token-usage.updated") {
1375 assert.deepEqual(usageEvent.payload, {
1376 usage: {
1377 usedTokens: 24542,
1378 lastUsedTokens: 24542,
1379 inputTokens: 23863,
1380 outputTokens: 679,
1381 maxTokens: 200000,
1382 },
1383 });
1384 }
1385 }).pipe(
1386 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1387 Effect.provide(harness.layer),
1388 );
1389 });
1390
12701391 it.effect(
12711392 "emits completion only after turn result when assistant frames arrive before deltas",
12721393 () => {
apps/server/src/provider/Layers/ClaudeAdapter.ts+157
......@@ -29,6 +29,7 @@ import {
2929 type ProviderRuntimeTurnStatus,
3030 type ProviderSendTurnInput,
3131 type ProviderSession,
32 type ThreadTokenUsageSnapshot,
3233 type ProviderUserInputAnswers,
3334 type RuntimeContentStreamKind,
3435 RuntimeItemId,
......@@ -156,6 +157,7 @@ interface ClaudeSessionContext {
156157 }>;
157158 readonly inFlightTools: Map<number, ToolInFlight>;
158159 turnState: ClaudeTurnState | undefined;
160 lastKnownContextWindow: number | undefined;
159161 lastAssistantUuid: string | undefined;
160162 lastThreadStartedId: string | undefined;
161163 stopped: boolean;
......@@ -259,6 +261,82 @@ function asRuntimeItemId(value: string): RuntimeItemId {
259261 return RuntimeItemId.makeUnsafe(value);
260262}
261263
264function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | undefined {
265 if (!modelUsage || typeof modelUsage !== "object") {
266 return undefined;
267 }
268
269 let maxContextWindow: number | undefined;
270 for (const value of Object.values(modelUsage as Record<string, unknown>)) {
271 if (!value || typeof value !== "object") {
272 continue;
273 }
274 const contextWindow = (value as { contextWindow?: unknown }).contextWindow;
275 if (
276 typeof contextWindow !== "number" ||
277 !Number.isFinite(contextWindow) ||
278 contextWindow <= 0
279 ) {
280 continue;
281 }
282 maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
283 }
284
285 return maxContextWindow;
286}
287
288function normalizeClaudeTokenUsage(
289 usage: unknown,
290 contextWindow?: number,
291): ThreadTokenUsageSnapshot | undefined {
292 if (!usage || typeof usage !== "object") {
293 return undefined;
294 }
295
296 const record = usage as Record<string, unknown>;
297 const directUsedTokens =
298 typeof record.total_tokens === "number" && Number.isFinite(record.total_tokens)
299 ? record.total_tokens
300 : undefined;
301 const inputTokens =
302 (typeof record.input_tokens === "number" && Number.isFinite(record.input_tokens)
303 ? record.input_tokens
304 : 0) +
305 (typeof record.cache_creation_input_tokens === "number" &&
306 Number.isFinite(record.cache_creation_input_tokens)
307 ? record.cache_creation_input_tokens
308 : 0) +
309 (typeof record.cache_read_input_tokens === "number" &&
310 Number.isFinite(record.cache_read_input_tokens)
311 ? record.cache_read_input_tokens
312 : 0);
313 const outputTokens =
314 typeof record.output_tokens === "number" && Number.isFinite(record.output_tokens)
315 ? record.output_tokens
316 : 0;
317 const derivedUsedTokens = inputTokens + outputTokens;
318 const usedTokens = directUsedTokens ?? (derivedUsedTokens > 0 ? derivedUsedTokens : undefined);
319 if (usedTokens === undefined || usedTokens <= 0) {
320 return undefined;
321 }
322
323 return {
324 usedTokens,
325 lastUsedTokens: usedTokens,
326 ...(inputTokens > 0 ? { inputTokens } : {}),
327 ...(outputTokens > 0 ? { outputTokens } : {}),
328 ...(typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
329 ? { maxTokens: contextWindow }
330 : {}),
331 ...(typeof record.tool_uses === "number" && Number.isFinite(record.tool_uses)
332 ? { toolUses: record.tool_uses }
333 : {}),
334 ...(typeof record.duration_ms === "number" && Number.isFinite(record.duration_ms)
335 ? { durationMs: record.duration_ms }
336 : {}),
337 };
338}
339
262340function asCanonicalTurnId(value: TurnId): TurnId {
263341 return value;
264342}
......@@ -1287,8 +1365,34 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
12871365 result?: SDKResultMessage,
12881366 ): Effect.Effect<void> =>
12891367 Effect.gen(function* () {
1368 const resultUsage =
1369 result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined;
1370 const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage);
1371 const usageSnapshot = normalizeClaudeTokenUsage(
1372 resultUsage,
1373 resultContextWindow ?? context.lastKnownContextWindow,
1374 );
1375 if (resultContextWindow !== undefined) {
1376 context.lastKnownContextWindow = resultContextWindow;
1377 }
1378
12901379 const turnState = context.turnState;
12911380 if (!turnState) {
1381 if (usageSnapshot) {
1382 const usageStamp = yield* makeEventStamp();
1383 yield* offerRuntimeEvent({
1384 type: "thread.token-usage.updated",
1385 eventId: usageStamp.eventId,
1386 provider: PROVIDER,
1387 createdAt: usageStamp.createdAt,
1388 threadId: context.session.threadId,
1389 payload: {
1390 usage: usageSnapshot,
1391 },
1392 providerRefs: {},
1393 });
1394 }
1395
12921396 const stamp = yield* makeEventStamp();
12931397 yield* offerRuntimeEvent({
12941398 type: "turn.completed",
......@@ -1356,6 +1460,22 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
13561460 items: [...turnState.items],
13571461 });
13581462
1463 if (usageSnapshot) {
1464 const usageStamp = yield* makeEventStamp();
1465 yield* offerRuntimeEvent({
1466 type: "thread.token-usage.updated",
1467 eventId: usageStamp.eventId,
1468 provider: PROVIDER,
1469 createdAt: usageStamp.createdAt,
1470 threadId: context.session.threadId,
1471 turnId: turnState.turnId,
1472 payload: {
1473 usage: usageSnapshot,
1474 },
1475 providerRefs: nativeProviderRefs(context),
1476 });
1477 }
1478
13591479 const stamp = yield* makeEventStamp();
13601480 yield* offerRuntimeEvent({
13611481 type: "turn.completed",
......@@ -1925,6 +2045,24 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
19252045 });
19262046 return;
19272047 case "task_progress":
2048 if (message.usage) {
2049 const normalizedUsage = normalizeClaudeTokenUsage(
2050 message.usage,
2051 context.lastKnownContextWindow,
2052 );
2053 if (normalizedUsage) {
2054 const usageStamp = yield* makeEventStamp();
2055 yield* offerRuntimeEvent({
2056 ...base,
2057 eventId: usageStamp.eventId,
2058 createdAt: usageStamp.createdAt,
2059 type: "thread.token-usage.updated",
2060 payload: {
2061 usage: normalizedUsage,
2062 },
2063 });
2064 }
2065 }
19282066 yield* offerRuntimeEvent({
19292067 ...base,
19302068 type: "task.progress",
......@@ -1938,6 +2076,24 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
19382076 });
19392077 return;
19402078 case "task_notification":
2079 if (message.usage) {
2080 const normalizedUsage = normalizeClaudeTokenUsage(
2081 message.usage,
2082 context.lastKnownContextWindow,
2083 );
2084 if (normalizedUsage) {
2085 const usageStamp = yield* makeEventStamp();
2086 yield* offerRuntimeEvent({
2087 ...base,
2088 eventId: usageStamp.eventId,
2089 createdAt: usageStamp.createdAt,
2090 type: "thread.token-usage.updated",
2091 payload: {
2092 usage: normalizedUsage,
2093 },
2094 });
2095 }
2096 }
19412097 yield* offerRuntimeEvent({
19422098 ...base,
19432099 type: "task.completed",
......@@ -2637,6 +2793,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
26372793 turns: [],
26382794 inFlightTools,
26392795 turnState: undefined,
2796 lastKnownContextWindow: undefined,
26402797 lastAssistantUuid: resumeState?.resumeSessionAt,
26412798 lastThreadStartedId: undefined,
26422799 stopped: false,
apps/server/src/provider/Layers/CodexAdapter.test.ts+64
......@@ -839,6 +839,70 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
839839 assert.equal(firstEvent.value.payload.taskId, "turn-child");
840840 }),
841841 );
842
843 it.effect("unwraps Codex token usage payloads for context window events", () =>
844 Effect.gen(function* () {
845 const adapter = yield* CodexAdapter;
846 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
847
848 lifecycleManager.emit("event", {
849 id: asEventId("evt-codex-thread-token-usage-updated"),
850 kind: "notification",
851 provider: "codex",
852 threadId: asThreadId("thread-1"),
853 turnId: asTurnId("turn-1"),
854 createdAt: new Date().toISOString(),
855 method: "thread/tokenUsage/updated",
856 payload: {
857 threadId: "thread-1",
858 turnId: "turn-1",
859 tokenUsage: {
860 total: {
861 inputTokens: 11_833,
862 cachedInputTokens: 3456,
863 outputTokens: 6,
864 reasoningOutputTokens: 0,
865 totalTokens: 11_839,
866 },
867 last: {
868 inputTokens: 120,
869 cachedInputTokens: 0,
870 outputTokens: 6,
871 reasoningOutputTokens: 0,
872 totalTokens: 126,
873 },
874 modelContextWindow: 258_400,
875 },
876 },
877 } satisfies ProviderEvent);
878
879 const firstEvent = yield* Fiber.join(firstEventFiber);
880 assert.equal(firstEvent._tag, "Some");
881 if (firstEvent._tag !== "Some") {
882 return;
883 }
884 assert.equal(firstEvent.value.type, "thread.token-usage.updated");
885 if (firstEvent.value.type !== "thread.token-usage.updated") {
886 return;
887 }
888
889 assert.deepEqual(firstEvent.value.payload.usage, {
890 usedTokens: 126,
891 totalProcessedTokens: 11_839,
892 maxTokens: 258_400,
893 inputTokens: 120,
894 cachedInputTokens: 0,
895 outputTokens: 6,
896 reasoningOutputTokens: 0,
897 lastUsedTokens: 126,
898 lastInputTokens: 120,
899 lastCachedInputTokens: 0,
900 lastOutputTokens: 6,
901 lastReasoningOutputTokens: 0,
902 compactsAutomatically: true,
903 });
904 }),
905 );
842906});
843907
844908afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+49-1
......@@ -11,6 +11,7 @@ import {
1111 type CanonicalRequestType,
1212 type ProviderEvent,
1313 type ProviderRuntimeEvent,
14 type ThreadTokenUsageSnapshot,
1415 type ProviderUserInputAnswers,
1516 RuntimeItemId,
1617 RuntimeRequestId,
......@@ -109,6 +110,48 @@ function asNumber(value: unknown): number | undefined {
109110 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
110111}
111112
113function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | undefined {
114 const usage = asObject(value);
115 const totalUsage = asObject(usage?.total_token_usage ?? usage?.total);
116 const lastUsage = asObject(usage?.last_token_usage ?? usage?.last);
117
118 const totalProcessedTokens =
119 asNumber(totalUsage?.total_tokens) ?? asNumber(totalUsage?.totalTokens);
120 const usedTokens =
121 asNumber(lastUsage?.total_tokens) ?? asNumber(lastUsage?.totalTokens) ?? totalProcessedTokens;
122 if (usedTokens === undefined || usedTokens <= 0) {
123 return undefined;
124 }
125
126 const maxTokens = asNumber(usage?.model_context_window) ?? asNumber(usage?.modelContextWindow);
127 const inputTokens = asNumber(lastUsage?.input_tokens) ?? asNumber(lastUsage?.inputTokens);
128 const cachedInputTokens =
129 asNumber(lastUsage?.cached_input_tokens) ?? asNumber(lastUsage?.cachedInputTokens);
130 const outputTokens = asNumber(lastUsage?.output_tokens) ?? asNumber(lastUsage?.outputTokens);
131 const reasoningOutputTokens =
132 asNumber(lastUsage?.reasoning_output_tokens) ?? asNumber(lastUsage?.reasoningOutputTokens);
133
134 return {
135 usedTokens,
136 ...(totalProcessedTokens !== undefined && totalProcessedTokens > usedTokens
137 ? { totalProcessedTokens }
138 : {}),
139 ...(maxTokens !== undefined ? { maxTokens } : {}),
140 ...(inputTokens !== undefined ? { inputTokens } : {}),
141 ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
142 ...(outputTokens !== undefined ? { outputTokens } : {}),
143 ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
144 ...(usedTokens !== undefined ? { lastUsedTokens: usedTokens } : {}),
145 ...(inputTokens !== undefined ? { lastInputTokens: inputTokens } : {}),
146 ...(cachedInputTokens !== undefined ? { lastCachedInputTokens: cachedInputTokens } : {}),
147 ...(outputTokens !== undefined ? { lastOutputTokens: outputTokens } : {}),
148 ...(reasoningOutputTokens !== undefined
149 ? { lastReasoningOutputTokens: reasoningOutputTokens }
150 : {}),
151 compactsAutomatically: true,
152 };
153}
154
112155function toTurnId(value: string | undefined): TurnId | undefined {
113156 return value?.trim() ? TurnId.makeUnsafe(value) : undefined;
114157}
......@@ -708,12 +751,17 @@ function mapToRuntimeEvents(
708751 }
709752
710753 if (event.method === "thread/tokenUsage/updated") {
754 const tokenUsage = asObject(payload?.tokenUsage);
755 const normalizedUsage = normalizeCodexTokenUsage(tokenUsage ?? event.payload);
756 if (!normalizedUsage) {
757 return [];
758 }
711759 return [
712760 {
713761 type: "thread.token-usage.updated",
714762 ...runtimeEventBase(event, canonicalThreadId),
715763 payload: {
716 usage: event.payload ?? {},
764 usage: normalizedUsage,
717765 },
718766 },
719767 ];
apps/web/src/components/ChatView.tsx+9
......@@ -143,12 +143,14 @@ import {
143143 type TerminalContextDraft,
144144 type TerminalContextSelection,
145145} from "../lib/terminalContext";
146import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
146147import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
147148import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
148149import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
149150import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
150151import { MessagesTimeline } from "./chat/MessagesTimeline";
151152import { ChatHeader } from "./chat/ChatHeader";
153import { ContextWindowMeter } from "./chat/ContextWindowMeter";
152154import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
153155import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
154156import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
......@@ -482,6 +484,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
482484 const diffOpen = rawSearch.diff === "1";
483485 const activeThreadId = activeThread?.id ?? null;
484486 const activeLatestTurn = activeThread?.latestTurn ?? null;
487 const activeContextWindow = useMemo(
488 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
489 [activeThread?.activities],
490 );
485491 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
486492 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
487493
......@@ -3895,6 +3901,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
38953901 data-chat-composer-actions="right"
38963902 className="flex shrink-0 items-center gap-2"
38973903 >
3904 {activeContextWindow ? (
3905 <ContextWindowMeter usage={activeContextWindow} />
3906 ) : null}
38983907 {isPreparingWorktree ? (
38993908 <span className="text-muted-foreground/70 text-xs">
39003909 Preparing worktree...
apps/web/src/components/chat/ContextWindowMeter.tsx created+114
......@@ -0,0 +1,114 @@
1import { cn } from "~/lib/utils";
2import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";
3import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4
5function formatPercentage(value: number | null): string | null {
6 if (value === null || !Number.isFinite(value)) {
7 return null;
8 }
9 if (value < 10) {
10 return `${value.toFixed(1).replace(/\.0$/, "")}%`;
11 }
12 return `${Math.round(value)}%`;
13}
14
15export function ContextWindowMeter(props: { usage: ContextWindowSnapshot }) {
16 const { usage } = props;
17 const usedPercentage = formatPercentage(usage.usedPercentage);
18 const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
19 const radius = 9.75;
20 const circumference = 2 * Math.PI * radius;
21 const dashOffset = circumference - (normalizedPercentage / 100) * circumference;
22
23 return (
24 <Popover>
25 <PopoverTrigger
26 openOnHover
27 delay={150}
28 closeDelay={0}
29 render={
30 <button
31 type="button"
32 className="group inline-flex items-center justify-center rounded-full transition-opacity hover:opacity-85"
33 aria-label={
34 usage.maxTokens !== null && usedPercentage
35 ? `Context window ${usedPercentage} used`
36 : `Context window ${formatContextWindowTokens(usage.usedTokens)} tokens used`
37 }
38 >
39 <span className="relative flex h-6 w-6 items-center justify-center">
40 <svg
41 viewBox="0 0 24 24"
42 className="-rotate-90 absolute inset-0 h-full w-full transform-gpu"
43 aria-hidden="true"
44 >
45 <circle
46 cx="12"
47 cy="12"
48 r={radius}
49 fill="none"
50 stroke="color-mix(in oklab, var(--color-muted) 70%, transparent)"
51 strokeWidth="3"
52 />
53 <circle
54 cx="12"
55 cy="12"
56 r={radius}
57 fill="none"
58 stroke="var(--color-muted-foreground)"
59 strokeWidth="3"
60 strokeLinecap="round"
61 strokeDasharray={circumference}
62 strokeDashoffset={dashOffset}
63 className="transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none"
64 />
65 </svg>
66 <span
67 className={cn(
68 "relative flex h-[15px] w-[15px] items-center justify-center rounded-full bg-background text-[8px] font-medium",
69 "text-muted-foreground",
70 )}
71 >
72 {usage.usedPercentage !== null
73 ? Math.round(usage.usedPercentage)
74 : formatContextWindowTokens(usage.usedTokens)}
75 </span>
76 </span>
77 </button>
78 }
79 />
80 <PopoverPopup tooltipStyle side="top" align="end" className="w-max max-w-none px-3 py-2">
81 <div className="space-y-1.5 leading-tight">
82 <div className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
83 Context window
84 </div>
85 {usage.maxTokens !== null && usedPercentage ? (
86 <div className="whitespace-nowrap text-xs font-medium text-foreground">
87 <span>{usedPercentage}</span>
88 <span className="mx-1">⋅</span>
89 <span>{formatContextWindowTokens(usage.usedTokens)}</span>
90 <span>/</span>
91 <span>{formatContextWindowTokens(usage.maxTokens ?? null)} context used</span>
92 </div>
93 ) : (
94 <div className="text-sm text-foreground">
95 {formatContextWindowTokens(usage.usedTokens)} tokens used so far
96 </div>
97 )}
98 {(usage.totalProcessedTokens ?? null) !== null &&
99 (usage.totalProcessedTokens ?? 0) > usage.usedTokens ? (
100 <div className="text-xs text-muted-foreground">
101 Total processed: {formatContextWindowTokens(usage.totalProcessedTokens ?? null)}{" "}
102 tokens
103 </div>
104 ) : null}
105 {usage.compactsAutomatically ? (
106 <div className="text-xs text-muted-foreground">
107 Automatically compacts its context when needed.
108 </div>
109 ) : null}
110 </div>
111 </PopoverPopup>
112 </Popover>
113 );
114}
apps/web/src/components/chat/MessagesTimeline.test.tsx+44
......@@ -96,4 +96,48 @@ describe("MessagesTimeline", () => {
9696 expect(markup).toContain("lucide-terminal");
9797 expect(markup).toContain("yoo what&#x27;s ");
9898 });
99
100 it("renders context compaction entries in the normal work log", async () => {
101 const { MessagesTimeline } = await import("./MessagesTimeline");
102 const markup = renderToStaticMarkup(
103 <MessagesTimeline
104 hasMessages
105 isWorking={false}
106 activeTurnInProgress={false}
107 activeTurnStartedAt={null}
108 scrollContainer={null}
109 timelineEntries={[
110 {
111 id: "entry-1",
112 kind: "work",
113 createdAt: "2026-03-17T19:12:28.000Z",
114 entry: {
115 id: "work-1",
116 createdAt: "2026-03-17T19:12:28.000Z",
117 label: "Context compacted",
118 tone: "info",
119 },
120 },
121 ]}
122 completionDividerBeforeEntryId={null}
123 completionSummary={null}
124 turnDiffSummaryByAssistantMessageId={new Map()}
125 nowIso="2026-03-17T19:12:30.000Z"
126 expandedWorkGroups={{}}
127 onToggleWorkGroup={() => {}}
128 onOpenTurnDiff={() => {}}
129 revertTurnCountByUserMessageId={new Map()}
130 onRevertUserMessage={() => {}}
131 isRevertingCheckpoint={false}
132 onImageExpand={() => {}}
133 markdownCwd={undefined}
134 resolvedTheme="light"
135 timestampFormat="locale"
136 workspaceRoot={undefined}
137 />,
138 );
139
140 expect(markup).toContain("Context compacted");
141 expect(markup).toContain("Work log");
142 });
99143});
apps/web/src/lib/contextWindow.test.ts created+67
......@@ -0,0 +1,67 @@
1import { describe, expect, it } from "vitest";
2import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts";
3
4import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow";
5
6function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity {
7 return {
8 id: EventId.makeUnsafe(id),
9 tone: "info",
10 kind,
11 summary: kind,
12 payload,
13 turnId: TurnId.makeUnsafe("turn-1"),
14 createdAt: "2026-03-23T00:00:00.000Z",
15 };
16}
17
18describe("contextWindow", () => {
19 it("derives the latest valid context window snapshot", () => {
20 const snapshot = deriveLatestContextWindowSnapshot([
21 makeActivity("activity-1", "context-window.updated", {
22 usedTokens: 1000,
23 }),
24 makeActivity("activity-2", "tool.started", {}),
25 makeActivity("activity-3", "context-window.updated", {
26 usedTokens: 14_000,
27 maxTokens: 258_000,
28 compactsAutomatically: true,
29 }),
30 ]);
31
32 expect(snapshot).not.toBeNull();
33 expect(snapshot?.usedTokens).toBe(14_000);
34 expect(snapshot?.totalProcessedTokens).toBeNull();
35 expect(snapshot?.maxTokens).toBe(258_000);
36 expect(snapshot?.compactsAutomatically).toBe(true);
37 });
38
39 it("ignores malformed payloads", () => {
40 const snapshot = deriveLatestContextWindowSnapshot([
41 makeActivity("activity-1", "context-window.updated", {}),
42 ]);
43
44 expect(snapshot).toBeNull();
45 });
46
47 it("formats compact token counts", () => {
48 expect(formatContextWindowTokens(999)).toBe("999");
49 expect(formatContextWindowTokens(1400)).toBe("1.4k");
50 expect(formatContextWindowTokens(14_000)).toBe("14k");
51 expect(formatContextWindowTokens(258_000)).toBe("258k");
52 });
53
54 it("includes total processed tokens when available", () => {
55 const snapshot = deriveLatestContextWindowSnapshot([
56 makeActivity("activity-1", "context-window.updated", {
57 usedTokens: 81_659,
58 totalProcessedTokens: 748_126,
59 maxTokens: 258_400,
60 lastUsedTokens: 81_659,
61 }),
62 ]);
63
64 expect(snapshot?.usedTokens).toBe(81_659);
65 expect(snapshot?.totalProcessedTokens).toBe(748_126);
66 });
67});
apps/web/src/lib/contextWindow.ts created+90
......@@ -0,0 +1,90 @@
1import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts";
2
3function asRecord(value: unknown): Record<string, unknown> | null {
4 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
5}
6
7function asFiniteNumber(value: unknown): number | null {
8 return typeof value === "number" && Number.isFinite(value) ? value : null;
9}
10
11function asBoolean(value: unknown): boolean | null {
12 return typeof value === "boolean" ? value : null;
13}
14
15type NullableContextWindowUsage = {
16 readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key]
17 ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null
18 : ThreadTokenUsageSnapshot[Key];
19};
20
21export type ContextWindowSnapshot = NullableContextWindowUsage & {
22 readonly remainingTokens: number | null;
23 readonly usedPercentage: number | null;
24 readonly remainingPercentage: number | null;
25 readonly updatedAt: string;
26};
27
28export function deriveLatestContextWindowSnapshot(
29 activities: ReadonlyArray<OrchestrationThreadActivity>,
30): ContextWindowSnapshot | null {
31 for (let index = activities.length - 1; index >= 0; index -= 1) {
32 const activity = activities[index];
33 if (!activity || activity.kind !== "context-window.updated") {
34 continue;
35 }
36
37 const payload = asRecord(activity.payload);
38 const usedTokens = asFiniteNumber(payload?.usedTokens);
39 if (usedTokens === null || usedTokens <= 0) {
40 continue;
41 }
42
43 const maxTokens = asFiniteNumber(payload?.maxTokens);
44 const usedPercentage =
45 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
46 const remainingTokens =
47 maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
48 const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
49
50 return {
51 usedTokens,
52 totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens),
53 maxTokens,
54 remainingTokens,
55 usedPercentage,
56 remainingPercentage,
57 inputTokens: asFiniteNumber(payload?.inputTokens),
58 cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens),
59 outputTokens: asFiniteNumber(payload?.outputTokens),
60 reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens),
61 lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens),
62 lastInputTokens: asFiniteNumber(payload?.lastInputTokens),
63 lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens),
64 lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens),
65 lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens),
66 toolUses: asFiniteNumber(payload?.toolUses),
67 durationMs: asFiniteNumber(payload?.durationMs),
68 compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false,
69 updatedAt: activity.createdAt,
70 };
71 }
72
73 return null;
74}
75
76export function formatContextWindowTokens(value: number | null): string {
77 if (value === null || !Number.isFinite(value)) {
78 return "0";
79 }
80 if (value < 1_000) {
81 return `${Math.round(value)}`;
82 }
83 if (value < 10_000) {
84 return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
85 }
86 if (value < 1_000_000) {
87 return `${Math.round(value / 1_000)}k`;
88 }
89 return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
90}
apps/web/src/session-logic.test.ts+45
......@@ -966,6 +966,51 @@ describe("deriveTimelineEntries", () => {
966966 });
967967});
968968
969describe("deriveWorkLogEntries context window handling", () => {
970 it("excludes context window updates from the work log", () => {
971 const entries = deriveWorkLogEntries(
972 [
973 makeActivity({
974 id: "context-1",
975 turnId: "turn-1",
976 kind: "context-window.updated",
977 summary: "Context window updated",
978 tone: "info",
979 }),
980 makeActivity({
981 id: "tool-1",
982 turnId: "turn-1",
983 kind: "tool.completed",
984 summary: "Ran command",
985 tone: "tool",
986 }),
987 ],
988 TurnId.makeUnsafe("turn-1"),
989 );
990
991 expect(entries).toHaveLength(1);
992 expect(entries[0]?.label).toBe("Ran command");
993 });
994
995 it("keeps context compaction activities as normal work log entries", () => {
996 const entries = deriveWorkLogEntries(
997 [
998 makeActivity({
999 id: "compaction-1",
1000 turnId: "turn-1",
1001 kind: "context-compaction",
1002 summary: "Context compacted",
1003 tone: "info",
1004 }),
1005 ],
1006 TurnId.makeUnsafe("turn-1"),
1007 );
1008
1009 expect(entries).toHaveLength(1);
1010 expect(entries[0]?.label).toBe("Context compacted");
1011 });
1012});
1013
9691014describe("hasToolActivityForTurn", () => {
9701015 it("returns false when turn id is missing", () => {
9711016 const activities: OrchestrationThreadActivity[] = [
apps/web/src/session-logic.ts+1
......@@ -463,6 +463,7 @@ export function deriveWorkLogEntries(
463463 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
464464 .filter((activity) => activity.kind !== "tool.started")
465465 .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")
466 .filter((activity) => activity.kind !== "context-window.updated")
466467 .filter((activity) => activity.summary !== "Checkpoint captured")
467468 .filter((activity) => !isPlanBoundaryToolActivity(activity))
468469 .map(toDerivedWorkLogEntry);
packages/contracts/src/providerRuntime.test.ts+25
......@@ -139,4 +139,29 @@ describe("ProviderRuntimeEvent", () => {
139139 }),
140140 ).toThrow();
141141 });
142
143 it("decodes normalized thread token usage snapshots", () => {
144 const parsed = decodeRuntimeEvent({
145 type: "thread.token-usage.updated",
146 eventId: "event-token-usage-1",
147 provider: "claudeAgent",
148 createdAt: "2026-02-28T00:00:04.000Z",
149 threadId: "thread-1",
150 payload: {
151 usage: {
152 usedTokens: 31251,
153 maxTokens: 200000,
154 toolUses: 25,
155 durationMs: 43567,
156 },
157 },
158 });
159
160 expect(parsed.type).toBe("thread.token-usage.updated");
161 if (parsed.type !== "thread.token-usage.updated") {
162 throw new Error("expected thread.token-usage.updated");
163 }
164 expect(parsed.payload.usage.maxTokens).toBe(200000);
165 expect(parsed.payload.usage.usedTokens).toBe(31251);
166 });
142167});
packages/contracts/src/providerRuntime.ts+22-1
......@@ -2,7 +2,9 @@ import { Option, Schema } from "effect";
22import {
33 EventId,
44 IsoDateTime,
5 NonNegativeInt,
56 ProviderItemId,
7 PositiveInt,
68 RuntimeItemId,
79 RuntimeRequestId,
810 RuntimeTaskId,
......@@ -293,8 +295,27 @@ const ThreadMetadataUpdatedPayload = Schema.Struct({
293295});
294296export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type;
295297
298export const ThreadTokenUsageSnapshot = Schema.Struct({
299 usedTokens: NonNegativeInt,
300 totalProcessedTokens: Schema.optional(NonNegativeInt),
301 maxTokens: Schema.optional(PositiveInt),
302 inputTokens: Schema.optional(NonNegativeInt),
303 cachedInputTokens: Schema.optional(NonNegativeInt),
304 outputTokens: Schema.optional(NonNegativeInt),
305 reasoningOutputTokens: Schema.optional(NonNegativeInt),
306 lastUsedTokens: Schema.optional(NonNegativeInt),
307 lastInputTokens: Schema.optional(NonNegativeInt),
308 lastCachedInputTokens: Schema.optional(NonNegativeInt),
309 lastOutputTokens: Schema.optional(NonNegativeInt),
310 lastReasoningOutputTokens: Schema.optional(NonNegativeInt),
311 toolUses: Schema.optional(NonNegativeInt),
312 durationMs: Schema.optional(NonNegativeInt),
313 compactsAutomatically: Schema.optional(Schema.Boolean),
314});
315export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type;
316
296317const ThreadTokenUsageUpdatedPayload = Schema.Struct({
297 usage: Schema.Unknown,
318 usage: ThreadTokenUsageSnapshot,
298319});
299320export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type;
300321