| author | |
| committer | |
| log | 8f3515572de09d41cbfe586a94696277ef000bb9 |
| tree | fedb221dbc0ca8ea760ce6bf3d1dd17512c58ee0 |
| parent | 529315b4c8ec274980b06c41170f48b01fed6646 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 1900 | 1900 | ||
| 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 | } |
| 103 | 104 | ||
| 105 | function 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 | |||
| 104 | function runtimePayloadRecord(event: ProviderRuntimeEvent): Record<string, unknown> | undefined { | 114 | function 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 | } |
| 411 | 421 | ||
| 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; |
| 1227 | 1227 | ||
| 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 | }); |
| 1269 | 1269 | ||
| 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 | } |
| 261 | 263 | ||
| 264 | function 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 | |||
| 288 | function 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 | |||
| 262 | function asCanonicalTurnId(value: TurnId): TurnId { | 340 | function 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 | }); |
| 1358 | 1462 | ||
| 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 | }); |
| 843 | 907 | ||
| 844 | afterAll(() => { | 908 | afterAll(() => { |
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 | } |
| 111 | 112 | ||
| 113 | function 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 | |||
| 112 | function toTurnId(value: string | undefined): TurnId | undefined { | 155 | function 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 | } |
| 709 | 752 | ||
| 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"; |
| 146 | import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; | ||
| 146 | import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; | 147 | import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; |
| 147 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 148 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 148 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | 149 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 149 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 150 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 150 | import { MessagesTimeline } from "./chat/MessagesTimeline"; | 151 | import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 151 | import { ChatHeader } from "./chat/ChatHeader"; | 152 | import { ChatHeader } from "./chat/ChatHeader"; |
| 153 | import { ContextWindowMeter } from "./chat/ContextWindowMeter"; | ||
| 152 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 154 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 153 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; | 155 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; |
| 154 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; | 156 | import { 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); |
| 487 | 493 | ||
| ... | @@ -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 @@ | ||
| 1 | import { cn } from "~/lib/utils"; | ||
| 2 | import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; | ||
| 3 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 4 | |||
| 5 | function 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 | |||
| 15 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; | ||
| 5 | |||
| 6 | function 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 | |||
| 18 | describe("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 @@ | ||
| 1 | import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | function asRecord(value: unknown): Record<string, unknown> | null { | ||
| 4 | return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | ||
| 5 | } | ||
| 6 | |||
| 7 | function asFiniteNumber(value: unknown): number | null { | ||
| 8 | return typeof value === "number" && Number.isFinite(value) ? value : null; | ||
| 9 | } | ||
| 10 | |||
| 11 | function asBoolean(value: unknown): boolean | null { | ||
| 12 | return typeof value === "boolean" ? value : null; | ||
| 13 | } | ||
| 14 | |||
| 15 | type NullableContextWindowUsage = { | ||
| 16 | readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] | ||
| 17 | ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null | ||
| 18 | : ThreadTokenUsageSnapshot[Key]; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export type ContextWindowSnapshot = NullableContextWindowUsage & { | ||
| 22 | readonly remainingTokens: number | null; | ||
| 23 | readonly usedPercentage: number | null; | ||
| 24 | readonly remainingPercentage: number | null; | ||
| 25 | readonly updatedAt: string; | ||
| 26 | }; | ||
| 27 | |||
| 28 | export 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 | |||
| 76 | export 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 | }); |
| 968 | 968 | ||
| 969 | describe("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 | |||
| 969 | describe("hasToolActivityForTurn", () => { | 1014 | describe("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"; |
| 2 | import { | 2 | import { |
| 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 | }); |
| 294 | export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; | 296 | export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; |
| 295 | 297 | ||
| 298 | export 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 | }); | ||
| 315 | export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; | ||
| 316 | |||
| 296 | const ThreadTokenUsageUpdatedPayload = Schema.Struct({ | 317 | const ThreadTokenUsageUpdatedPayload = Schema.Struct({ |
| 297 | usage: Schema.Unknown, | 318 | usage: ThreadTokenUsageSnapshot, |
| 298 | }); | 319 | }); |
| 299 | export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; | 320 | export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; |
| 300 | 321 |