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", () => {...@@ -1898,6 +1898,185 @@ describe("ProviderRuntimeIngestion", () => {
1898 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");1898 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");
1899 });1899 });
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
1901 it("projects Codex task lifecycle chunks into thread activities", async () => {2080 it("projects Codex task lifecycle chunks into thread activities", async () => {
1902 const harness = await createHarness();2081 const harness = await createHarness();
1903 const now = new Date().toISOString();2082 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+52
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 CheckpointRef,8 CheckpointRef,
9 isToolLifecycleItemType,9 isToolLifecycleItemType,
10 ThreadId,10 ThreadId,
11 type ThreadTokenUsageSnapshot,
11 TurnId,12 TurnId,
12 type OrchestrationThreadActivity,13 type OrchestrationThreadActivity,
13 type ProviderRuntimeEvent,14 type ProviderRuntimeEvent,
...@@ -101,6 +102,15 @@ function asString(value: unknown): string | undefined {...@@ -101,6 +102,15 @@ function asString(value: unknown): string | undefined {
101 return typeof value === "string" ? value : undefined;102 return typeof value === "string" ? value : undefined;
102}103}
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
104function runtimePayloadRecord(event: ProviderRuntimeEvent): Record<string, unknown> | undefined {114function runtimePayloadRecord(event: ProviderRuntimeEvent): Record<string, unknown> | undefined {
105 const payload = (event as { payload?: unknown }).payload;115 const payload = (event as { payload?: unknown }).payload;
106 if (!payload || typeof payload !== "object") {116 if (!payload || typeof payload !== "object") {
...@@ -409,6 +419,48 @@ function runtimeEventToActivities(...@@ -409,6 +419,48 @@ function runtimeEventToActivities(
409 ];419 ];
410 }420 }
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
412 case "item.updated": {464 case "item.updated": {
413 if (!isToolLifecycleItemType(event.payload.itemType)) {465 if (!isToolLifecycleItemType(event.payload.itemType)) {
414 return [];466 return [];
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+122-1
...@@ -1225,7 +1225,7 @@ describe("ClaudeAdapterLive", () => {...@@ -1225,7 +1225,7 @@ describe("ClaudeAdapterLive", () => {
1225 return Effect.gen(function* () {1225 return Effect.gen(function* () {
1226 const adapter = yield* ClaudeAdapter;1226 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(
1229 Stream.runCollect,1229 Stream.runCollect,
1230 Effect.forkChild,1230 Effect.forkChild,
1231 );1231 );
...@@ -1267,6 +1267,127 @@ describe("ClaudeAdapterLive", () => {...@@ -1267,6 +1267,127 @@ describe("ClaudeAdapterLive", () => {
1267 );1267 );
1268 });1268 });
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
1270 it.effect(1391 it.effect(
1271 "emits completion only after turn result when assistant frames arrive before deltas",1392 "emits completion only after turn result when assistant frames arrive before deltas",
1272 () => {1393 () => {
apps/server/src/provider/Layers/ClaudeAdapter.ts+157
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 type ProviderRuntimeTurnStatus,29 type ProviderRuntimeTurnStatus,
30 type ProviderSendTurnInput,30 type ProviderSendTurnInput,
31 type ProviderSession,31 type ProviderSession,
32 type ThreadTokenUsageSnapshot,
32 type ProviderUserInputAnswers,33 type ProviderUserInputAnswers,
33 type RuntimeContentStreamKind,34 type RuntimeContentStreamKind,
34 RuntimeItemId,35 RuntimeItemId,
...@@ -156,6 +157,7 @@ interface ClaudeSessionContext {...@@ -156,6 +157,7 @@ interface ClaudeSessionContext {
156 }>;157 }>;
157 readonly inFlightTools: Map<number, ToolInFlight>;158 readonly inFlightTools: Map<number, ToolInFlight>;
158 turnState: ClaudeTurnState | undefined;159 turnState: ClaudeTurnState | undefined;
160 lastKnownContextWindow: number | undefined;
159 lastAssistantUuid: string | undefined;161 lastAssistantUuid: string | undefined;
160 lastThreadStartedId: string | undefined;162 lastThreadStartedId: string | undefined;
161 stopped: boolean;163 stopped: boolean;
...@@ -259,6 +261,82 @@ function asRuntimeItemId(value: string): RuntimeItemId {...@@ -259,6 +261,82 @@ function asRuntimeItemId(value: string): RuntimeItemId {
259 return RuntimeItemId.makeUnsafe(value);261 return RuntimeItemId.makeUnsafe(value);
260}262}
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
262function asCanonicalTurnId(value: TurnId): TurnId {340function asCanonicalTurnId(value: TurnId): TurnId {
263 return value;341 return value;
264}342}
...@@ -1287,8 +1365,34 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -1287,8 +1365,34 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
1287 result?: SDKResultMessage,1365 result?: SDKResultMessage,
1288 ): Effect.Effect<void> =>1366 ): Effect.Effect<void> =>
1289 Effect.gen(function* () {1367 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
1290 const turnState = context.turnState;1379 const turnState = context.turnState;
1291 if (!turnState) {1380 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
1292 const stamp = yield* makeEventStamp();1396 const stamp = yield* makeEventStamp();
1293 yield* offerRuntimeEvent({1397 yield* offerRuntimeEvent({
1294 type: "turn.completed",1398 type: "turn.completed",
...@@ -1356,6 +1460,22 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -1356,6 +1460,22 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
1356 items: [...turnState.items],1460 items: [...turnState.items],
1357 });1461 });
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
1359 const stamp = yield* makeEventStamp();1479 const stamp = yield* makeEventStamp();
1360 yield* offerRuntimeEvent({1480 yield* offerRuntimeEvent({
1361 type: "turn.completed",1481 type: "turn.completed",
...@@ -1925,6 +2045,24 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -1925,6 +2045,24 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
1925 });2045 });
1926 return;2046 return;
1927 case "task_progress":2047 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 }
1928 yield* offerRuntimeEvent({2066 yield* offerRuntimeEvent({
1929 ...base,2067 ...base,
1930 type: "task.progress",2068 type: "task.progress",
...@@ -1938,6 +2076,24 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -1938,6 +2076,24 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
1938 });2076 });
1939 return;2077 return;
1940 case "task_notification":2078 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 }
1941 yield* offerRuntimeEvent({2097 yield* offerRuntimeEvent({
1942 ...base,2098 ...base,
1943 type: "task.completed",2099 type: "task.completed",
...@@ -2637,6 +2793,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2637,6 +2793,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2637 turns: [],2793 turns: [],
2638 inFlightTools,2794 inFlightTools,
2639 turnState: undefined,2795 turnState: undefined,
2796 lastKnownContextWindow: undefined,
2640 lastAssistantUuid: resumeState?.resumeSessionAt,2797 lastAssistantUuid: resumeState?.resumeSessionAt,
2641 lastThreadStartedId: undefined,2798 lastThreadStartedId: undefined,
2642 stopped: false,2799 stopped: false,
apps/server/src/provider/Layers/CodexAdapter.test.ts+64
...@@ -839,6 +839,70 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -839,6 +839,70 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
839 assert.equal(firstEvent.value.payload.taskId, "turn-child");839 assert.equal(firstEvent.value.payload.taskId, "turn-child");
840 }),840 }),
841 );841 );
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 );
842});906});
843907
844afterAll(() => {908afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+49-1
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type CanonicalRequestType,11 type CanonicalRequestType,
12 type ProviderEvent,12 type ProviderEvent,
13 type ProviderRuntimeEvent,13 type ProviderRuntimeEvent,
14 type ThreadTokenUsageSnapshot,
14 type ProviderUserInputAnswers,15 type ProviderUserInputAnswers,
15 RuntimeItemId,16 RuntimeItemId,
16 RuntimeRequestId,17 RuntimeRequestId,
...@@ -109,6 +110,48 @@ function asNumber(value: unknown): number | undefined {...@@ -109,6 +110,48 @@ function asNumber(value: unknown): number | undefined {
109 return typeof value === "number" && Number.isFinite(value) ? value : undefined;110 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
110}111}
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
112function toTurnId(value: string | undefined): TurnId | undefined {155function toTurnId(value: string | undefined): TurnId | undefined {
113 return value?.trim() ? TurnId.makeUnsafe(value) : undefined;156 return value?.trim() ? TurnId.makeUnsafe(value) : undefined;
114}157}
...@@ -708,12 +751,17 @@ function mapToRuntimeEvents(...@@ -708,12 +751,17 @@ function mapToRuntimeEvents(
708 }751 }
709752
710 if (event.method === "thread/tokenUsage/updated") {753 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 }
711 return [759 return [
712 {760 {
713 type: "thread.token-usage.updated",761 type: "thread.token-usage.updated",
714 ...runtimeEventBase(event, canonicalThreadId),762 ...runtimeEventBase(event, canonicalThreadId),
715 payload: {763 payload: {
716 usage: event.payload ?? {},764 usage: normalizedUsage,
717 },765 },
718 },766 },
719 ];767 ];
apps/web/src/components/ChatView.tsx+9
...@@ -143,12 +143,14 @@ import {...@@ -143,12 +143,14 @@ import {
143 type TerminalContextDraft,143 type TerminalContextDraft,
144 type TerminalContextSelection,144 type TerminalContextSelection,
145} from "../lib/terminalContext";145} from "../lib/terminalContext";
146import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
146import { shouldUseCompactComposerFooter } from "./composerFooterLayout";147import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
147import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";148import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
148import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";149import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
149import { PullRequestThreadDialog } from "./PullRequestThreadDialog";150import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
150import { MessagesTimeline } from "./chat/MessagesTimeline";151import { MessagesTimeline } from "./chat/MessagesTimeline";
151import { ChatHeader } from "./chat/ChatHeader";152import { ChatHeader } from "./chat/ChatHeader";
153import { ContextWindowMeter } from "./chat/ContextWindowMeter";
152import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";154import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
153import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";155import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
154import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";156import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
...@@ -482,6 +484,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -482,6 +484,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
482 const diffOpen = rawSearch.diff === "1";484 const diffOpen = rawSearch.diff === "1";
483 const activeThreadId = activeThread?.id ?? null;485 const activeThreadId = activeThread?.id ?? null;
484 const activeLatestTurn = activeThread?.latestTurn ?? null;486 const activeLatestTurn = activeThread?.latestTurn ?? null;
487 const activeContextWindow = useMemo(
488 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
489 [activeThread?.activities],
490 );
485 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);491 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
486 const activeProject = projects.find((p) => p.id === activeThread?.projectId);492 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
487493
...@@ -3895,6 +3901,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3895,6 +3901,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
3895 data-chat-composer-actions="right"3901 data-chat-composer-actions="right"
3896 className="flex shrink-0 items-center gap-2"3902 className="flex shrink-0 items-center gap-2"
3897 >3903 >
3904 {activeContextWindow ? (
3905 <ContextWindowMeter usage={activeContextWindow} />
3906 ) : null}
3898 {isPreparingWorktree ? (3907 {isPreparingWorktree ? (
3899 <span className="text-muted-foreground/70 text-xs">3908 <span className="text-muted-foreground/70 text-xs">
3900 Preparing worktree...3909 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", () => {...@@ -96,4 +96,48 @@ describe("MessagesTimeline", () => {
96 expect(markup).toContain("lucide-terminal");96 expect(markup).toContain("lucide-terminal");
97 expect(markup).toContain("yoo what&#x27;s ");97 expect(markup).toContain("yoo what&#x27;s ");
98 });98 });
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 });
99});143});
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", () => {...@@ -966,6 +966,51 @@ describe("deriveTimelineEntries", () => {
966 });966 });
967});967});
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
969describe("hasToolActivityForTurn", () => {1014describe("hasToolActivityForTurn", () => {
970 it("returns false when turn id is missing", () => {1015 it("returns false when turn id is missing", () => {
971 const activities: OrchestrationThreadActivity[] = [1016 const activities: OrchestrationThreadActivity[] = [
apps/web/src/session-logic.ts+1
...@@ -463,6 +463,7 @@ export function deriveWorkLogEntries(...@@ -463,6 +463,7 @@ export function deriveWorkLogEntries(
463 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))463 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
464 .filter((activity) => activity.kind !== "tool.started")464 .filter((activity) => activity.kind !== "tool.started")
465 .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")465 .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")
466 .filter((activity) => activity.kind !== "context-window.updated")
466 .filter((activity) => activity.summary !== "Checkpoint captured")467 .filter((activity) => activity.summary !== "Checkpoint captured")
467 .filter((activity) => !isPlanBoundaryToolActivity(activity))468 .filter((activity) => !isPlanBoundaryToolActivity(activity))
468 .map(toDerivedWorkLogEntry);469 .map(toDerivedWorkLogEntry);
packages/contracts/src/providerRuntime.test.ts+25
...@@ -139,4 +139,29 @@ describe("ProviderRuntimeEvent", () => {...@@ -139,4 +139,29 @@ describe("ProviderRuntimeEvent", () => {
139 }),139 }),
140 ).toThrow();140 ).toThrow();
141 });141 });
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 });
142});167});
packages/contracts/src/providerRuntime.ts+22-1
...@@ -2,7 +2,9 @@ import { Option, Schema } from "effect";...@@ -2,7 +2,9 @@ import { Option, Schema } from "effect";
2import {2import {
3 EventId,3 EventId,
4 IsoDateTime,4 IsoDateTime,
5 NonNegativeInt,
5 ProviderItemId,6 ProviderItemId,
7 PositiveInt,
6 RuntimeItemId,8 RuntimeItemId,
7 RuntimeRequestId,9 RuntimeRequestId,
8 RuntimeTaskId,10 RuntimeTaskId,
...@@ -293,8 +295,27 @@ const ThreadMetadataUpdatedPayload = Schema.Struct({...@@ -293,8 +295,27 @@ const ThreadMetadataUpdatedPayload = Schema.Struct({
293});295});
294export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type;296export 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
296const ThreadTokenUsageUpdatedPayload = Schema.Struct({317const ThreadTokenUsageUpdatedPayload = Schema.Struct({
297 usage: Schema.Unknown,318 usage: ThreadTokenUsageSnapshot,
298});319});
299export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type;320export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type;
300321