From c6231ed2fba9d3c0338e949757aa4d14b654aa9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 23 Mar 2026 21:58:52 -0700 Subject: [PATCH 1/6] Surface context window usage in the UI - Normalize provider token-usage events into thread activities - Add a compact context window meter to chat - Handle context compaction markers in the timeline --- .../src/components/chat/MessagesTimeline.tsx | 31 ++++++++++++++++++- apps/web/src/session-logic.ts | 4 +++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3e462f7fe..4db17a233c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -147,11 +147,25 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } if (timelineEntry.kind === "work") { + if (timelineEntry.entry.display === "timeline-marker") { + nextRows.push({ + kind: "work", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + groupedEntries: [timelineEntry.entry], + }); + continue; + } const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; while (cursor < timelineEntries.length) { const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; + if ( + !nextEntry || + nextEntry.kind !== "work" || + nextEntry.entry.display === "timeline-marker" + ) + break; groupedEntries.push(nextEntry.entry); cursor += 1; } @@ -314,6 +328,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (() => { const groupId = row.id; const groupedEntries = row.groupedEntries; + const markerEntry = + groupedEntries.length === 1 && groupedEntries[0]?.display === "timeline-marker" + ? groupedEntries[0] + : null; + if (markerEntry) { + return ( +
+ + + {markerEntry.label} + + +
+ ); + } const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d6313..47de2415ce 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -40,6 +40,7 @@ export interface WorkLogEntry { command?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; + display?: "timeline-marker"; toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; @@ -522,6 +523,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + if (activity.kind === "context-compaction") { + entry.display = "timeline-marker"; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; From a81ac994b8f8081f569495f96beaf1412a23f825 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 12:14:18 -0700 Subject: [PATCH 2/6] Render context compaction as standard work log entries - Replace timeline marker treatment with normal grouped work rows - Switch context window meter to an SVG ring and show auto-compaction --- .../src/components/chat/MessagesTimeline.tsx | 31 +------------------ apps/web/src/session-logic.ts | 4 --- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4db17a233c..f3e462f7fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -147,25 +147,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } if (timelineEntry.kind === "work") { - if (timelineEntry.entry.display === "timeline-marker") { - nextRows.push({ - kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries: [timelineEntry.entry], - }); - continue; - } const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; while (cursor < timelineEntries.length) { const nextEntry = timelineEntries[cursor]; - if ( - !nextEntry || - nextEntry.kind !== "work" || - nextEntry.entry.display === "timeline-marker" - ) - break; + if (!nextEntry || nextEntry.kind !== "work") break; groupedEntries.push(nextEntry.entry); cursor += 1; } @@ -328,21 +314,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (() => { const groupId = row.id; const groupedEntries = row.groupedEntries; - const markerEntry = - groupedEntries.length === 1 && groupedEntries[0]?.display === "timeline-marker" - ? groupedEntries[0] - : null; - if (markerEntry) { - return ( -
- - - {markerEntry.label} - - -
- ); - } const isExpanded = expandedWorkGroups[groupId] ?? false; const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 47de2415ce..83a95d6313 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -40,7 +40,6 @@ export interface WorkLogEntry { command?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; - display?: "timeline-marker"; toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; @@ -523,9 +522,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } - if (activity.kind === "context-compaction") { - entry.display = "timeline-marker"; - } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; From 4f02b51f567a688fda72148108d18e651ae8a14f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 13:28:34 -0700 Subject: [PATCH 3/6] Tighten thread activity schemas and context window usage - Model orchestration activities with typed payloads - Drop obsolete provider runtime event mappings - Improve context window snapshot derivation --- .../Layers/ProjectionSnapshotQuery.test.ts | 12 +- .../Layers/ProjectionSnapshotQuery.ts | 23 +- .../Layers/ProviderCommandReactor.ts | 3 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 17 +- .../src/provider/Layers/ClaudeAdapter.ts | 165 +------- .../src/provider/Layers/CodexAdapter.ts | 171 -------- apps/web/src/lib/contextWindow.test.ts | 17 +- apps/web/src/lib/contextWindow.ts | 70 ++-- apps/web/src/session-logic.test.ts | 7 +- packages/contracts/src/orchestration.ts | 188 ++++++++- packages/contracts/src/providerRuntime.ts | 386 +----------------- packages/contracts/src/threadUsage.ts | 22 + 12 files changed, 293 insertions(+), 788 deletions(-) create mode 100644 packages/contracts/src/threadUsage.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5080ea8c48..492ab0edc6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -141,9 +141,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'thread-1', 'turn-1', 'info', - 'runtime.note', - 'provider started', - '{"stage":"start"}', + 'runtime.warning', + 'Runtime warning', + '{"message":"provider started"}', '2026-02-24T00:00:06.000Z' ) `; @@ -306,9 +306,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { { id: asEventId("activity-1"), tone: "info", - kind: "runtime.note", - summary: "provider started", - payload: { stage: "start" }, + kind: "runtime.warning", + summary: "Runtime warning", + payload: { message: "provider started" }, turnId: asTurnId("turn-1"), createdAt: "2026-02-24T00:00:06.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cc2f4f87e7..acf0dd5438 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -6,6 +6,7 @@ import { OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, + OrchestrationThreadActivity, ProjectScript, ThreadId, TurnId, @@ -460,16 +461,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of activityRows) { updatedAt = maxIso(updatedAt, row.createdAt); const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - }); + threadActivities.push( + Schema.decodeUnknownSync(OrchestrationThreadActivity)({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }), + ); activitiesByThread.set(row.threadId, threadActivities); } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9399bcc280..d4097a1143 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -160,8 +160,7 @@ const make = Effect.gen(function* () { | "provider.turn.start.failed" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" - | "provider.user-input.respond.failed" - | "provider.session.stop.failed"; + | "provider.user-input.respond.failed"; readonly summary: string; readonly detail: string; readonly turnId: TurnId | null; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d4ed6fba19..37983bb5e9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -731,7 +731,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -897,7 +896,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -1078,7 +1076,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -1136,7 +1133,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "turn.completed", @@ -1144,7 +1140,7 @@ describe("ClaudeAdapterLive", () => { ], ); - const turnCompleted = runtimeEvents[4]; + const turnCompleted = runtimeEvents.find((event) => event.type === "turn.completed"); assert.equal(turnCompleted?.type, "turn.completed"); if (turnCompleted?.type === "turn.completed") { assert.equal(String(turnCompleted.turnId), String(turn.turnId)); @@ -1152,7 +1148,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted."); } - const sessionExited = runtimeEvents[5]; + const sessionExited = runtimeEvents.find((event) => event.type === "session.exited"); assert.equal(sessionExited?.type, "session.exited"); assert.equal(yield* adapter.hasSession(THREAD_ID), false); @@ -1466,7 +1462,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -1611,7 +1606,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -1619,6 +1613,7 @@ describe("ClaudeAdapterLive", () => { "item.completed", "content.delta", "item.completed", + "turn.completed", ], ); @@ -1706,7 +1701,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -1894,7 +1888,6 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", @@ -2001,10 +1994,10 @@ describe("ClaudeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", - "session.configured", "session.state.changed", "turn.started", "thread.started", + "turn.completed", ], ); @@ -2014,7 +2007,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(sessionStarted.threadId, THREAD_ID); } - const threadStarted = runtimeEvents[4]; + const threadStarted = runtimeEvents.find((event) => event.type === "thread.started"); assert.equal(threadStarted?.type, "thread.started"); if (threadStarted?.type === "thread.started") { assert.equal(threadStarted.threadId, THREAD_ID); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index af88fa634a..82d72a4d91 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1994,15 +1994,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }; switch (message.subtype) { - case "init": - yield* offerRuntimeEvent({ - ...base, - type: "session.configured", - payload: { - config: message as Record, - }, - }); - return; case "status": yield* offerRuntimeEvent({ ...base, @@ -2024,43 +2015,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }, }); return; - case "hook_started": - yield* offerRuntimeEvent({ - ...base, - type: "hook.started", - payload: { - hookId: message.hook_id, - hookName: message.hook_name, - hookEvent: message.hook_event, - }, - }); - return; - case "hook_progress": - yield* offerRuntimeEvent({ - ...base, - type: "hook.progress", - payload: { - hookId: message.hook_id, - output: message.output, - stdout: message.stdout, - stderr: message.stderr, - }, - }); - return; - case "hook_response": - yield* offerRuntimeEvent({ - ...base, - type: "hook.completed", - payload: { - hookId: message.hook_id, - outcome: message.outcome, - output: message.output, - stdout: message.stdout, - stderr: message.stderr, - ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), - }, - }); - return; case "task_started": yield* offerRuntimeEvent({ ...base, @@ -2135,28 +2089,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }, }); return; - case "files_persisted": - yield* offerRuntimeEvent({ - ...base, - type: "files.persisted", - payload: { - files: Array.isArray(message.files) - ? message.files.map((file: { filename: string; file_id: string }) => ({ - filename: file.filename, - fileId: file.file_id, - })) - : [], - ...(Array.isArray(message.failed) - ? { - failed: message.failed.map((entry: { filename: string; error: string }) => ({ - filename: entry.filename, - error: entry.error, - })), - } - : {}), - }, - }); - return; default: yield* emitRuntimeWarning( context, @@ -2168,78 +2100,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }); const handleSdkTelemetryMessage = ( - context: ClaudeSessionContext, - message: SDKMessage, - ): Effect.Effect => - Effect.gen(function* () { - const stamp = yield* makeEventStamp(); - const base = { - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), - providerRefs: nativeProviderRefs(context), - raw: { - source: "claude.sdk.message" as const, - method: sdkNativeMethod(message), - messageType: message.type, - payload: message, - }, - }; - - if (message.type === "tool_progress") { - yield* offerRuntimeEvent({ - ...base, - type: "tool.progress", - payload: { - toolUseId: message.tool_use_id, - toolName: message.tool_name, - elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), - }, - }); - return; - } - - if (message.type === "tool_use_summary") { - yield* offerRuntimeEvent({ - ...base, - type: "tool.summary", - payload: { - summary: message.summary, - ...(message.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: message.preceding_tool_use_ids } - : {}), - }, - }); - return; - } - - if (message.type === "auth_status") { - yield* offerRuntimeEvent({ - ...base, - type: "auth.status", - payload: { - isAuthenticating: message.isAuthenticating, - output: message.output, - ...(message.error ? { error: message.error } : {}), - }, - }); - return; - } - - if (message.type === "rate_limit_event") { - yield* offerRuntimeEvent({ - ...base, - type: "account.rate-limits.updated", - payload: { - rateLimits: message, - }, - }); - return; - } - }); + _context: ClaudeSessionContext, + _message: SDKMessage, + ): Effect.Effect => Effect.void; const handleSdkMessage = ( context: ClaudeSessionContext, @@ -2838,28 +2701,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { providerRefs: {}, }); - const configuredStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "session.configured", - eventId: configuredStamp.eventId, - provider: PROVIDER, - createdAt: configuredStamp.createdAt, - threadId, - payload: { - config: { - ...(modelSelection?.model ? { model: modelSelection.model } : {}), - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(effectiveEffort ? { effort: effectiveEffort } : {}), - ...(permissionMode ? { permissionMode } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } - : {}), - ...(fastMode ? { fastMode: true } : {}), - }, - }, - providerRefs: {}, - }); - const readyStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.state.changed", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index ca9c52cf8e..9d0a163981 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -805,18 +805,6 @@ function mapToRuntimeEvents( ]; } - if (event.method === "turn/aborted") { - return [ - { - ...runtimeEventBase(event, canonicalThreadId), - type: "turn.aborted", - payload: { - reason: event.message ?? "Turn aborted", - }, - }, - ]; - } - if (event.method === "turn/plan/updated") { const steps = Array.isArray(payload?.plan) ? payload.plan : []; return [ @@ -951,23 +939,6 @@ function mapToRuntimeEvents( ]; } - if (event.method === "item/mcpToolCall/progress") { - return [ - { - ...runtimeEventBase(event, canonicalThreadId), - type: "tool.progress", - payload: { - ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}), - ...(asString(payload?.toolName) ? { toolName: asString(payload?.toolName) } : {}), - ...(asString(payload?.summary) ? { summary: asString(payload?.summary) } : {}), - ...(asNumber(payload?.elapsedSeconds) !== undefined - ? { elapsedSeconds: asNumber(payload?.elapsedSeconds) } - : {}), - }, - }, - ]; - } - if (event.method === "serverRequest/resolved") { const requestType = toRequestTypeFromResolvedPayload(payload) !== "unknown" @@ -1107,148 +1078,6 @@ function mapToRuntimeEvents( ]; } - if (event.method === "model/rerouted") { - return [ - { - type: "model.rerouted", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - fromModel: asString(payload?.fromModel) ?? "unknown", - toModel: asString(payload?.toModel) ?? "unknown", - reason: asString(payload?.reason) ?? "unknown", - }, - }, - ]; - } - - if (event.method === "deprecationNotice") { - return [ - { - type: "deprecation.notice", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - summary: asString(payload?.summary) ?? "Deprecation notice", - ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), - }, - }, - ]; - } - - if (event.method === "configWarning") { - return [ - { - type: "config.warning", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - summary: asString(payload?.summary) ?? "Configuration warning", - ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), - ...(asString(payload?.path) ? { path: asString(payload?.path) } : {}), - ...(payload?.range !== undefined ? { range: payload.range } : {}), - }, - }, - ]; - } - - if (event.method === "account/updated") { - return [ - { - type: "account.updated", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - account: event.payload ?? {}, - }, - }, - ]; - } - - if (event.method === "account/rateLimits/updated") { - return [ - { - type: "account.rate-limits.updated", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - rateLimits: event.payload ?? {}, - }, - }, - ]; - } - - if (event.method === "mcpServer/oauthLogin/completed") { - return [ - { - type: "mcp.oauth.completed", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - success: payload?.success === true, - ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}), - ...(asString(payload?.error) ? { error: asString(payload?.error) } : {}), - }, - }, - ]; - } - - if (event.method === "thread/realtime/started") { - const realtimeSessionId = asString(payload?.realtimeSessionId); - return [ - { - type: "thread.realtime.started", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - realtimeSessionId, - }, - }, - ]; - } - - if (event.method === "thread/realtime/itemAdded") { - return [ - { - type: "thread.realtime.item-added", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - item: event.payload ?? {}, - }, - }, - ]; - } - - if (event.method === "thread/realtime/outputAudio/delta") { - return [ - { - type: "thread.realtime.audio.delta", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - audio: event.payload ?? {}, - }, - }, - ]; - } - - if (event.method === "thread/realtime/error") { - const message = asString(payload?.message) ?? event.message ?? "Realtime error"; - return [ - { - type: "thread.realtime.error", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - message, - }, - }, - ]; - } - - if (event.method === "thread/realtime/closed") { - return [ - { - type: "thread.realtime.closed", - ...runtimeEventBase(event, canonicalThreadId), - payload: { - reason: event.message, - }, - }, - ]; - } - if (event.method === "error") { const message = asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error"; diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index 2173c18aa7..42fbdde9ce 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -1,18 +1,27 @@ import { describe, expect, it } from "vitest"; -import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; +import { + EventId, + type OrchestrationThreadActivity, + type OrchestrationThreadActivityKind, + TurnId, +} from "@t3tools/contracts"; import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; -function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { +function makeActivity( + id: string, + kind: OrchestrationThreadActivityKind, + payload: unknown, +): OrchestrationThreadActivity { return { id: EventId.makeUnsafe(id), - tone: "info", + tone: kind.startsWith("tool.") ? "tool" : "info", kind, summary: kind, payload, turnId: TurnId.makeUnsafe("turn-1"), createdAt: "2026-03-23T00:00:00.000Z", - }; + } as OrchestrationThreadActivity; } describe("contextWindow", () => { diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index f668135a13..388a5d7fe8 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,17 +1,5 @@ import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - -function asFiniteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - -function asBoolean(value: unknown): boolean | null { - return typeof value === "boolean" ? value : null; -} - type NullableContextWindowUsage = { readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] ? Exclude | null @@ -34,38 +22,56 @@ export function deriveLatestContextWindowSnapshot( continue; } - const payload = asRecord(activity.payload); - const usedTokens = asFiniteNumber(payload?.usedTokens); - if (usedTokens === null || usedTokens <= 0) { + const { + payload: { + usedTokens, + totalProcessedTokens, + maxTokens, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + lastUsedTokens, + lastInputTokens, + lastCachedInputTokens, + lastOutputTokens, + lastReasoningOutputTokens, + toolUses, + durationMs, + compactsAutomatically, + }, + } = activity; + if (!Number.isFinite(usedTokens) || usedTokens <= 0) { continue; } - const maxTokens = asFiniteNumber(payload?.maxTokens); const usedPercentage = - maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null; + maxTokens !== undefined && maxTokens > 0 + ? Math.min(100, (usedTokens / maxTokens) * 100) + : null; const remainingTokens = - maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; + maxTokens !== undefined ? Math.max(0, Math.round(maxTokens - usedTokens)) : null; const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null; return { usedTokens, - totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens), - maxTokens, + totalProcessedTokens: totalProcessedTokens ?? null, + maxTokens: maxTokens ?? null, remainingTokens, usedPercentage, remainingPercentage, - inputTokens: asFiniteNumber(payload?.inputTokens), - cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens), - outputTokens: asFiniteNumber(payload?.outputTokens), - reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens), - lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens), - lastInputTokens: asFiniteNumber(payload?.lastInputTokens), - lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens), - lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens), - lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens), - toolUses: asFiniteNumber(payload?.toolUses), - durationMs: asFiniteNumber(payload?.durationMs), - compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, + inputTokens: inputTokens ?? null, + cachedInputTokens: cachedInputTokens ?? null, + outputTokens: outputTokens ?? null, + reasoningOutputTokens: reasoningOutputTokens ?? null, + lastUsedTokens: lastUsedTokens ?? null, + lastInputTokens: lastInputTokens ?? null, + lastCachedInputTokens: lastCachedInputTokens ?? null, + lastOutputTokens: lastOutputTokens ?? null, + lastReasoningOutputTokens: lastReasoningOutputTokens ?? null, + toolUses: toolUses ?? null, + durationMs: durationMs ?? null, + compactsAutomatically: compactsAutomatically ?? false, updatedAt: activity.createdAt, }; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index c786ffc72b..51fcd474fc 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -3,6 +3,7 @@ import { MessageId, ThreadId, TurnId, + type OrchestrationThreadActivityKind, type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; @@ -25,7 +26,7 @@ import { function makeActivity(overrides: { id?: string; createdAt?: string; - kind?: string; + kind?: OrchestrationThreadActivityKind; summary?: string; tone?: OrchestrationThreadActivity["tone"]; payload?: Record; @@ -42,7 +43,7 @@ function makeActivity(overrides: { payload, turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), - }; + } as OrchestrationThreadActivity; } describe("derivePendingApprovals", () => { @@ -1024,7 +1025,7 @@ describe("hasToolActivityForTurn", () => { it("returns true only for matching tool activity in the target turn", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), - makeActivity({ id: "info-1", turnId: "turn-2", kind: "turn.completed", tone: "info" }), + makeActivity({ id: "info-1", turnId: "turn-2", kind: "task.completed", tone: "info" }), ]; expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-1"))).toBe(true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 333d5ca1eb..ad040d976c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -14,6 +14,7 @@ import { TrimmedNonEmptyString, TurnId, } from "./baseSchemas"; +import { ThreadTokenUsageSnapshot } from "./threadUsage"; export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", @@ -255,16 +256,197 @@ export const OrchestrationThreadActivityTone = Schema.Literals([ ]); export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivityTone.Type; -export const OrchestrationThreadActivity = Schema.Struct({ +export const OrchestrationThreadActivityKind = Schema.Literals([ + "approval.requested", + "approval.resolved", + "runtime.error", + "runtime.warning", + "turn.plan.updated", + "user-input.requested", + "user-input.resolved", + "task.started", + "task.progress", + "task.completed", + "context-compaction", + "context-window.updated", + "tool.updated", + "tool.completed", + "tool.started", + "checkpoint.revert.failed", + "checkpoint.capture.failed", + "checkpoint.captured", + "provider.turn.start.failed", + "provider.turn.interrupt.failed", + "provider.approval.respond.failed", + "provider.user-input.respond.failed", +]); +export type OrchestrationThreadActivityKind = typeof OrchestrationThreadActivityKind.Type; + +const OrchestrationThreadActivityBaseFields = { id: EventId, tone: OrchestrationThreadActivityTone, - kind: TrimmedNonEmptyString, summary: TrimmedNonEmptyString, - payload: Schema.Unknown, turnId: Schema.NullOr(TurnId), sequence: Schema.optional(NonNegativeInt), createdAt: IsoDateTime, +} as const; + +const ApprovalRequestedActivityPayload = Schema.Struct({ + requestId: Schema.optional(TrimmedNonEmptyString), + requestKind: Schema.optional(ProviderRequestKind), + requestType: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), +}); + +const ApprovalResolvedActivityPayload = Schema.Struct({ + requestId: Schema.optional(TrimmedNonEmptyString), + requestKind: Schema.optional(ProviderRequestKind), + requestType: Schema.optional(TrimmedNonEmptyString), + decision: Schema.optional(TrimmedNonEmptyString), +}); + +const RuntimeErrorActivityPayload = Schema.Struct({ + message: TrimmedNonEmptyString, +}); + +const RuntimeWarningActivityPayload = Schema.Struct({ + message: TrimmedNonEmptyString, + detail: Schema.optional(Schema.Unknown), +}); + +const RuntimePlanStep = Schema.Struct({ + step: TrimmedNonEmptyString, + status: TrimmedNonEmptyString, +}); + +const TurnPlanUpdatedActivityPayload = Schema.Struct({ + plan: Schema.Array(RuntimePlanStep), + explanation: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); + +const UserInputRequestedActivityPayload = Schema.Struct({ + requestId: Schema.optional(TrimmedNonEmptyString), + questions: Schema.Array(Schema.Unknown), +}); + +const UserInputResolvedActivityPayload = Schema.Struct({ + requestId: Schema.optional(TrimmedNonEmptyString), + answers: Schema.Record(Schema.String, Schema.Unknown), +}); + +const TaskStartedActivityPayload = Schema.Struct({ + taskId: TrimmedNonEmptyString, + taskType: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), }); + +const TaskProgressActivityPayload = Schema.Struct({ + taskId: TrimmedNonEmptyString, + detail: TrimmedNonEmptyString, + summary: Schema.optional(TrimmedNonEmptyString), + lastToolName: Schema.optional(TrimmedNonEmptyString), + usage: Schema.optional(Schema.Unknown), +}); + +const TaskCompletedActivityPayload = Schema.Struct({ + taskId: TrimmedNonEmptyString, + status: TrimmedNonEmptyString, + detail: Schema.optional(TrimmedNonEmptyString), + usage: Schema.optional(Schema.Unknown), +}); + +const ContextCompactionActivityPayload = Schema.Struct({ + state: Schema.Literal("compacted"), + detail: Schema.optional(Schema.Unknown), +}); + +const ToolStartedOrCompletedActivityPayload = Schema.Struct({ + itemType: TrimmedNonEmptyString, + detail: Schema.optional(TrimmedNonEmptyString), +}); + +const ToolUpdatedActivityPayload = Schema.Struct({ + itemType: TrimmedNonEmptyString, + status: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + data: Schema.optional(Schema.Unknown), +}); + +const CheckpointRevertFailedActivityPayload = Schema.Struct({ + turnCount: NonNegativeInt, + detail: TrimmedNonEmptyString, +}); + +const CheckpointCaptureFailedActivityPayload = Schema.Struct({ + detail: TrimmedNonEmptyString, +}); + +const CheckpointCapturedActivityPayload = Schema.Struct({ + turnCount: NonNegativeInt, + status: OrchestrationCheckpointStatus, +}); + +const ProviderFailureActivityPayload = Schema.Struct({ + detail: TrimmedNonEmptyString, + requestId: Schema.optional(TrimmedNonEmptyString), +}); + +function makeThreadActivitySchema< + Kind extends OrchestrationThreadActivityKind, + Payload extends Schema.Schema, +>(kind: Kind, tone: OrchestrationThreadActivityTone, payload: Payload) { + return Schema.Struct({ + ...OrchestrationThreadActivityBaseFields, + tone: Schema.Literal(tone), + kind: Schema.Literal(kind), + payload, + }); +} + +export const OrchestrationThreadActivity = Schema.Union([ + makeThreadActivitySchema("approval.requested", "approval", ApprovalRequestedActivityPayload), + makeThreadActivitySchema("approval.resolved", "approval", ApprovalResolvedActivityPayload), + makeThreadActivitySchema("runtime.error", "error", RuntimeErrorActivityPayload), + makeThreadActivitySchema("runtime.warning", "info", RuntimeWarningActivityPayload), + makeThreadActivitySchema("turn.plan.updated", "info", TurnPlanUpdatedActivityPayload), + makeThreadActivitySchema("user-input.requested", "info", UserInputRequestedActivityPayload), + makeThreadActivitySchema("user-input.resolved", "info", UserInputResolvedActivityPayload), + makeThreadActivitySchema("task.started", "info", TaskStartedActivityPayload), + makeThreadActivitySchema("task.progress", "info", TaskProgressActivityPayload), + makeThreadActivitySchema("task.completed", "info", TaskCompletedActivityPayload), + makeThreadActivitySchema("context-compaction", "info", ContextCompactionActivityPayload), + makeThreadActivitySchema("context-window.updated", "info", ThreadTokenUsageSnapshot), + makeThreadActivitySchema("tool.updated", "tool", ToolUpdatedActivityPayload), + makeThreadActivitySchema("tool.completed", "tool", ToolStartedOrCompletedActivityPayload), + makeThreadActivitySchema("tool.started", "tool", ToolStartedOrCompletedActivityPayload), + makeThreadActivitySchema( + "checkpoint.revert.failed", + "error", + CheckpointRevertFailedActivityPayload, + ), + makeThreadActivitySchema( + "checkpoint.capture.failed", + "error", + CheckpointCaptureFailedActivityPayload, + ), + makeThreadActivitySchema("checkpoint.captured", "info", CheckpointCapturedActivityPayload), + makeThreadActivitySchema("provider.turn.start.failed", "error", ProviderFailureActivityPayload), + makeThreadActivitySchema( + "provider.turn.interrupt.failed", + "error", + ProviderFailureActivityPayload, + ), + makeThreadActivitySchema( + "provider.approval.respond.failed", + "error", + ProviderFailureActivityPayload, + ), + makeThreadActivitySchema( + "provider.user-input.respond.failed", + "error", + ProviderFailureActivityPayload, + ), +]); export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; const OrchestrationLatestTurnState = Schema.Literals([ diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 81231d88f6..da34b7f247 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -2,9 +2,7 @@ import { Option, Schema } from "effect"; import { EventId, IsoDateTime, - NonNegativeInt, ProviderItemId, - PositiveInt, RuntimeItemId, RuntimeRequestId, RuntimeTaskId, @@ -13,6 +11,8 @@ import { TurnId, } from "./baseSchemas"; import { ProviderKind } from "./orchestration"; +export { ThreadTokenUsageSnapshot } from "./threadUsage"; +import { ThreadTokenUsageSnapshot as ThreadTokenUsageSnapshotSchema } from "./threadUsage"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); @@ -143,21 +143,14 @@ export type CanonicalRequestType = typeof CanonicalRequestType.Type; const ProviderRuntimeEventType = Schema.Literals([ "session.started", - "session.configured", "session.state.changed", "session.exited", "thread.started", "thread.state.changed", "thread.metadata.updated", "thread.token-usage.updated", - "thread.realtime.started", - "thread.realtime.item-added", - "thread.realtime.audio.delta", - "thread.realtime.error", - "thread.realtime.closed", "turn.started", "turn.completed", - "turn.aborted", "turn.plan.updated", "turn.proposed.delta", "turn.proposed.completed", @@ -173,41 +166,20 @@ const ProviderRuntimeEventType = Schema.Literals([ "task.started", "task.progress", "task.completed", - "hook.started", - "hook.progress", - "hook.completed", - "tool.progress", - "tool.summary", - "auth.status", - "account.updated", - "account.rate-limits.updated", - "mcp.status.updated", - "mcp.oauth.completed", - "model.rerouted", - "config.warning", - "deprecation.notice", - "files.persisted", "runtime.warning", "runtime.error", ]); export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type; const SessionStartedType = Schema.Literal("session.started"); -const SessionConfiguredType = Schema.Literal("session.configured"); const SessionStateChangedType = Schema.Literal("session.state.changed"); const SessionExitedType = Schema.Literal("session.exited"); const ThreadStartedType = Schema.Literal("thread.started"); const ThreadStateChangedType = Schema.Literal("thread.state.changed"); const ThreadMetadataUpdatedType = Schema.Literal("thread.metadata.updated"); const ThreadTokenUsageUpdatedType = Schema.Literal("thread.token-usage.updated"); -const ThreadRealtimeStartedType = Schema.Literal("thread.realtime.started"); -const ThreadRealtimeItemAddedType = Schema.Literal("thread.realtime.item-added"); -const ThreadRealtimeAudioDeltaType = Schema.Literal("thread.realtime.audio.delta"); -const ThreadRealtimeErrorType = Schema.Literal("thread.realtime.error"); -const ThreadRealtimeClosedType = Schema.Literal("thread.realtime.closed"); const TurnStartedType = Schema.Literal("turn.started"); const TurnCompletedType = Schema.Literal("turn.completed"); -const TurnAbortedType = Schema.Literal("turn.aborted"); const TurnPlanUpdatedType = Schema.Literal("turn.plan.updated"); const TurnProposedDeltaType = Schema.Literal("turn.proposed.delta"); const TurnProposedCompletedType = Schema.Literal("turn.proposed.completed"); @@ -223,20 +195,6 @@ const UserInputResolvedType = Schema.Literal("user-input.resolved"); const TaskStartedType = Schema.Literal("task.started"); const TaskProgressType = Schema.Literal("task.progress"); const TaskCompletedType = Schema.Literal("task.completed"); -const HookStartedType = Schema.Literal("hook.started"); -const HookProgressType = Schema.Literal("hook.progress"); -const HookCompletedType = Schema.Literal("hook.completed"); -const ToolProgressType = Schema.Literal("tool.progress"); -const ToolSummaryType = Schema.Literal("tool.summary"); -const AuthStatusType = Schema.Literal("auth.status"); -const AccountUpdatedType = Schema.Literal("account.updated"); -const AccountRateLimitsUpdatedType = Schema.Literal("account.rate-limits.updated"); -const McpStatusUpdatedType = Schema.Literal("mcp.status.updated"); -const McpOauthCompletedType = Schema.Literal("mcp.oauth.completed"); -const ModelReroutedType = Schema.Literal("model.rerouted"); -const ConfigWarningType = Schema.Literal("config.warning"); -const DeprecationNoticeType = Schema.Literal("deprecation.notice"); -const FilesPersistedType = Schema.Literal("files.persisted"); const RuntimeWarningType = Schema.Literal("runtime.warning"); const RuntimeErrorType = Schema.Literal("runtime.error"); @@ -259,11 +217,6 @@ const SessionStartedPayload = Schema.Struct({ }); export type SessionStartedPayload = typeof SessionStartedPayload.Type; -const SessionConfiguredPayload = Schema.Struct({ - config: UnknownRecordSchema, -}); -export type SessionConfiguredPayload = typeof SessionConfiguredPayload.Type; - const SessionStateChangedPayload = Schema.Struct({ state: RuntimeSessionState, reason: Schema.optional(TrimmedNonEmptyStringSchema), @@ -295,55 +248,11 @@ const ThreadMetadataUpdatedPayload = Schema.Struct({ }); export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; -export const ThreadTokenUsageSnapshot = Schema.Struct({ - usedTokens: NonNegativeInt, - totalProcessedTokens: Schema.optional(NonNegativeInt), - maxTokens: Schema.optional(PositiveInt), - inputTokens: Schema.optional(NonNegativeInt), - cachedInputTokens: Schema.optional(NonNegativeInt), - outputTokens: Schema.optional(NonNegativeInt), - reasoningOutputTokens: Schema.optional(NonNegativeInt), - lastUsedTokens: Schema.optional(NonNegativeInt), - lastInputTokens: Schema.optional(NonNegativeInt), - lastCachedInputTokens: Schema.optional(NonNegativeInt), - lastOutputTokens: Schema.optional(NonNegativeInt), - lastReasoningOutputTokens: Schema.optional(NonNegativeInt), - toolUses: Schema.optional(NonNegativeInt), - durationMs: Schema.optional(NonNegativeInt), - compactsAutomatically: Schema.optional(Schema.Boolean), -}); -export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; - const ThreadTokenUsageUpdatedPayload = Schema.Struct({ - usage: ThreadTokenUsageSnapshot, + usage: ThreadTokenUsageSnapshotSchema, }); export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; -const ThreadRealtimeStartedPayload = Schema.Struct({ - realtimeSessionId: Schema.optional(TrimmedNonEmptyStringSchema), -}); -export type ThreadRealtimeStartedPayload = typeof ThreadRealtimeStartedPayload.Type; - -const ThreadRealtimeItemAddedPayload = Schema.Struct({ - item: Schema.Unknown, -}); -export type ThreadRealtimeItemAddedPayload = typeof ThreadRealtimeItemAddedPayload.Type; - -const ThreadRealtimeAudioDeltaPayload = Schema.Struct({ - audio: Schema.Unknown, -}); -export type ThreadRealtimeAudioDeltaPayload = typeof ThreadRealtimeAudioDeltaPayload.Type; - -const ThreadRealtimeErrorPayload = Schema.Struct({ - message: TrimmedNonEmptyStringSchema, -}); -export type ThreadRealtimeErrorPayload = typeof ThreadRealtimeErrorPayload.Type; - -const ThreadRealtimeClosedPayload = Schema.Struct({ - reason: Schema.optional(TrimmedNonEmptyStringSchema), -}); -export type ThreadRealtimeClosedPayload = typeof ThreadRealtimeClosedPayload.Type; - const TurnStartedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyStringSchema), effort: Schema.optional(TrimmedNonEmptyStringSchema), @@ -360,11 +269,6 @@ const TurnCompletedPayload = Schema.Struct({ }); export type TurnCompletedPayload = typeof TurnCompletedPayload.Type; -const TurnAbortedPayload = Schema.Struct({ - reason: TrimmedNonEmptyStringSchema, -}); -export type TurnAbortedPayload = typeof TurnAbortedPayload.Type; - const RuntimePlanStep = Schema.Struct({ step: TrimmedNonEmptyStringSchema, status: RuntimePlanStepStatus, @@ -474,113 +378,6 @@ const TaskCompletedPayload = Schema.Struct({ }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; -const HookStartedPayload = Schema.Struct({ - hookId: TrimmedNonEmptyStringSchema, - hookName: TrimmedNonEmptyStringSchema, - hookEvent: TrimmedNonEmptyStringSchema, -}); -export type HookStartedPayload = typeof HookStartedPayload.Type; - -const HookProgressPayload = Schema.Struct({ - hookId: TrimmedNonEmptyStringSchema, - output: Schema.optional(Schema.String), - stdout: Schema.optional(Schema.String), - stderr: Schema.optional(Schema.String), -}); -export type HookProgressPayload = typeof HookProgressPayload.Type; - -const HookCompletedPayload = Schema.Struct({ - hookId: TrimmedNonEmptyStringSchema, - outcome: Schema.Literals(["success", "error", "cancelled"]), - output: Schema.optional(Schema.String), - stdout: Schema.optional(Schema.String), - stderr: Schema.optional(Schema.String), - exitCode: Schema.optional(Schema.Int), -}); -export type HookCompletedPayload = typeof HookCompletedPayload.Type; - -const ToolProgressPayload = Schema.Struct({ - toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), - toolName: Schema.optional(TrimmedNonEmptyStringSchema), - summary: Schema.optional(TrimmedNonEmptyStringSchema), - elapsedSeconds: Schema.optional(Schema.Number), -}); -export type ToolProgressPayload = typeof ToolProgressPayload.Type; - -const ToolSummaryPayload = Schema.Struct({ - summary: TrimmedNonEmptyStringSchema, - precedingToolUseIds: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), -}); -export type ToolSummaryPayload = typeof ToolSummaryPayload.Type; - -const AuthStatusPayload = Schema.Struct({ - isAuthenticating: Schema.optional(Schema.Boolean), - output: Schema.optional(Schema.Array(Schema.String)), - error: Schema.optional(TrimmedNonEmptyStringSchema), -}); -export type AuthStatusPayload = typeof AuthStatusPayload.Type; - -const AccountUpdatedPayload = Schema.Struct({ - account: Schema.Unknown, -}); -export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type; - -const AccountRateLimitsUpdatedPayload = Schema.Struct({ - rateLimits: Schema.Unknown, -}); -export type AccountRateLimitsUpdatedPayload = typeof AccountRateLimitsUpdatedPayload.Type; - -const McpStatusUpdatedPayload = Schema.Struct({ - status: Schema.Unknown, -}); -export type McpStatusUpdatedPayload = typeof McpStatusUpdatedPayload.Type; - -const McpOauthCompletedPayload = Schema.Struct({ - success: Schema.Boolean, - name: Schema.optional(TrimmedNonEmptyStringSchema), - error: Schema.optional(TrimmedNonEmptyStringSchema), -}); -export type McpOauthCompletedPayload = typeof McpOauthCompletedPayload.Type; - -const ModelReroutedPayload = Schema.Struct({ - fromModel: TrimmedNonEmptyStringSchema, - toModel: TrimmedNonEmptyStringSchema, - reason: TrimmedNonEmptyStringSchema, -}); -export type ModelReroutedPayload = typeof ModelReroutedPayload.Type; - -const ConfigWarningPayload = Schema.Struct({ - summary: TrimmedNonEmptyStringSchema, - details: Schema.optional(TrimmedNonEmptyStringSchema), - path: Schema.optional(TrimmedNonEmptyStringSchema), - range: Schema.optional(Schema.Unknown), -}); -export type ConfigWarningPayload = typeof ConfigWarningPayload.Type; - -const DeprecationNoticePayload = Schema.Struct({ - summary: TrimmedNonEmptyStringSchema, - details: Schema.optional(TrimmedNonEmptyStringSchema), -}); -export type DeprecationNoticePayload = typeof DeprecationNoticePayload.Type; - -const FilesPersistedPayload = Schema.Struct({ - files: Schema.Array( - Schema.Struct({ - filename: TrimmedNonEmptyStringSchema, - fileId: TrimmedNonEmptyStringSchema, - }), - ), - failed: Schema.optional( - Schema.Array( - Schema.Struct({ - filename: TrimmedNonEmptyStringSchema, - error: TrimmedNonEmptyStringSchema, - }), - ), - ), -}); -export type FilesPersistedPayload = typeof FilesPersistedPayload.Type; - const RuntimeWarningPayload = Schema.Struct({ message: TrimmedNonEmptyStringSchema, detail: Schema.optional(Schema.Unknown), @@ -601,14 +398,6 @@ const ProviderRuntimeSessionStartedEvent = Schema.Struct({ }); export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; -const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: SessionConfiguredType, - payload: SessionConfiguredPayload, -}); -export type ProviderRuntimeSessionConfiguredEvent = - typeof ProviderRuntimeSessionConfiguredEvent.Type; - const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: SessionStateChangedType, @@ -655,46 +444,6 @@ const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({ export type ProviderRuntimeThreadTokenUsageUpdatedEvent = typeof ProviderRuntimeThreadTokenUsageUpdatedEvent.Type; -const ProviderRuntimeThreadRealtimeStartedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ThreadRealtimeStartedType, - payload: ThreadRealtimeStartedPayload, -}); -export type ProviderRuntimeThreadRealtimeStartedEvent = - typeof ProviderRuntimeThreadRealtimeStartedEvent.Type; - -const ProviderRuntimeThreadRealtimeItemAddedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ThreadRealtimeItemAddedType, - payload: ThreadRealtimeItemAddedPayload, -}); -export type ProviderRuntimeThreadRealtimeItemAddedEvent = - typeof ProviderRuntimeThreadRealtimeItemAddedEvent.Type; - -const ProviderRuntimeThreadRealtimeAudioDeltaEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ThreadRealtimeAudioDeltaType, - payload: ThreadRealtimeAudioDeltaPayload, -}); -export type ProviderRuntimeThreadRealtimeAudioDeltaEvent = - typeof ProviderRuntimeThreadRealtimeAudioDeltaEvent.Type; - -const ProviderRuntimeThreadRealtimeErrorEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ThreadRealtimeErrorType, - payload: ThreadRealtimeErrorPayload, -}); -export type ProviderRuntimeThreadRealtimeErrorEvent = - typeof ProviderRuntimeThreadRealtimeErrorEvent.Type; - -const ProviderRuntimeThreadRealtimeClosedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ThreadRealtimeClosedType, - payload: ThreadRealtimeClosedPayload, -}); -export type ProviderRuntimeThreadRealtimeClosedEvent = - typeof ProviderRuntimeThreadRealtimeClosedEvent.Type; - const ProviderRuntimeTurnStartedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: TurnStartedType, @@ -709,13 +458,6 @@ const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ }); export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; -const ProviderRuntimeTurnAbortedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: TurnAbortedType, - payload: TurnAbortedPayload, -}); -export type ProviderRuntimeTurnAbortedEvent = typeof ProviderRuntimeTurnAbortedEvent.Type; - const ProviderRuntimeTurnPlanUpdatedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: TurnPlanUpdatedType, @@ -825,107 +567,6 @@ const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ }); export type ProviderRuntimeTaskCompletedEvent = typeof ProviderRuntimeTaskCompletedEvent.Type; -const ProviderRuntimeHookStartedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: HookStartedType, - payload: HookStartedPayload, -}); -export type ProviderRuntimeHookStartedEvent = typeof ProviderRuntimeHookStartedEvent.Type; - -const ProviderRuntimeHookProgressEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: HookProgressType, - payload: HookProgressPayload, -}); -export type ProviderRuntimeHookProgressEvent = typeof ProviderRuntimeHookProgressEvent.Type; - -const ProviderRuntimeHookCompletedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: HookCompletedType, - payload: HookCompletedPayload, -}); -export type ProviderRuntimeHookCompletedEvent = typeof ProviderRuntimeHookCompletedEvent.Type; - -const ProviderRuntimeToolProgressEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ToolProgressType, - payload: ToolProgressPayload, -}); -export type ProviderRuntimeToolProgressEvent = typeof ProviderRuntimeToolProgressEvent.Type; - -const ProviderRuntimeToolSummaryEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ToolSummaryType, - payload: ToolSummaryPayload, -}); -export type ProviderRuntimeToolSummaryEvent = typeof ProviderRuntimeToolSummaryEvent.Type; - -const ProviderRuntimeAuthStatusEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: AuthStatusType, - payload: AuthStatusPayload, -}); -export type ProviderRuntimeAuthStatusEvent = typeof ProviderRuntimeAuthStatusEvent.Type; - -const ProviderRuntimeAccountUpdatedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: AccountUpdatedType, - payload: AccountUpdatedPayload, -}); -export type ProviderRuntimeAccountUpdatedEvent = typeof ProviderRuntimeAccountUpdatedEvent.Type; - -const ProviderRuntimeAccountRateLimitsUpdatedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: AccountRateLimitsUpdatedType, - payload: AccountRateLimitsUpdatedPayload, -}); -export type ProviderRuntimeAccountRateLimitsUpdatedEvent = - typeof ProviderRuntimeAccountRateLimitsUpdatedEvent.Type; - -const ProviderRuntimeMcpStatusUpdatedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: McpStatusUpdatedType, - payload: McpStatusUpdatedPayload, -}); -export type ProviderRuntimeMcpStatusUpdatedEvent = typeof ProviderRuntimeMcpStatusUpdatedEvent.Type; - -const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: McpOauthCompletedType, - payload: McpOauthCompletedPayload, -}); -export type ProviderRuntimeMcpOauthCompletedEvent = - typeof ProviderRuntimeMcpOauthCompletedEvent.Type; - -const ProviderRuntimeModelReroutedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ModelReroutedType, - payload: ModelReroutedPayload, -}); -export type ProviderRuntimeModelReroutedEvent = typeof ProviderRuntimeModelReroutedEvent.Type; - -const ProviderRuntimeConfigWarningEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: ConfigWarningType, - payload: ConfigWarningPayload, -}); -export type ProviderRuntimeConfigWarningEvent = typeof ProviderRuntimeConfigWarningEvent.Type; - -const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: DeprecationNoticeType, - payload: DeprecationNoticePayload, -}); -export type ProviderRuntimeDeprecationNoticeEvent = - typeof ProviderRuntimeDeprecationNoticeEvent.Type; - -const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ - ...ProviderRuntimeEventBase.fields, - type: FilesPersistedType, - payload: FilesPersistedPayload, -}); -export type ProviderRuntimeFilesPersistedEvent = typeof ProviderRuntimeFilesPersistedEvent.Type; - const ProviderRuntimeWarningEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: RuntimeWarningType, @@ -942,21 +583,14 @@ export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeSessionStartedEvent, - ProviderRuntimeSessionConfiguredEvent, ProviderRuntimeSessionStateChangedEvent, ProviderRuntimeSessionExitedEvent, ProviderRuntimeThreadStartedEvent, ProviderRuntimeThreadStateChangedEvent, ProviderRuntimeThreadMetadataUpdatedEvent, ProviderRuntimeThreadTokenUsageUpdatedEvent, - ProviderRuntimeThreadRealtimeStartedEvent, - ProviderRuntimeThreadRealtimeItemAddedEvent, - ProviderRuntimeThreadRealtimeAudioDeltaEvent, - ProviderRuntimeThreadRealtimeErrorEvent, - ProviderRuntimeThreadRealtimeClosedEvent, ProviderRuntimeTurnStartedEvent, ProviderRuntimeTurnCompletedEvent, - ProviderRuntimeTurnAbortedEvent, ProviderRuntimeTurnPlanUpdatedEvent, ProviderRuntimeTurnProposedDeltaEvent, ProviderRuntimeTurnProposedCompletedEvent, @@ -972,20 +606,6 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeTaskStartedEvent, ProviderRuntimeTaskProgressEvent, ProviderRuntimeTaskCompletedEvent, - ProviderRuntimeHookStartedEvent, - ProviderRuntimeHookProgressEvent, - ProviderRuntimeHookCompletedEvent, - ProviderRuntimeToolProgressEvent, - ProviderRuntimeToolSummaryEvent, - ProviderRuntimeAuthStatusEvent, - ProviderRuntimeAccountUpdatedEvent, - ProviderRuntimeAccountRateLimitsUpdatedEvent, - ProviderRuntimeMcpStatusUpdatedEvent, - ProviderRuntimeMcpOauthCompletedEvent, - ProviderRuntimeModelReroutedEvent, - ProviderRuntimeConfigWarningEvent, - ProviderRuntimeDeprecationNoticeEvent, - ProviderRuntimeFilesPersistedEvent, ProviderRuntimeWarningEvent, ProviderRuntimeErrorEvent, ]); diff --git a/packages/contracts/src/threadUsage.ts b/packages/contracts/src/threadUsage.ts new file mode 100644 index 0000000000..31a74048dc --- /dev/null +++ b/packages/contracts/src/threadUsage.ts @@ -0,0 +1,22 @@ +import { Schema } from "effect"; + +import { NonNegativeInt, PositiveInt } from "./baseSchemas"; + +export const ThreadTokenUsageSnapshot = Schema.Struct({ + usedTokens: NonNegativeInt, + totalProcessedTokens: Schema.optional(NonNegativeInt), + maxTokens: Schema.optional(PositiveInt), + inputTokens: Schema.optional(NonNegativeInt), + cachedInputTokens: Schema.optional(NonNegativeInt), + outputTokens: Schema.optional(NonNegativeInt), + reasoningOutputTokens: Schema.optional(NonNegativeInt), + lastUsedTokens: Schema.optional(NonNegativeInt), + lastInputTokens: Schema.optional(NonNegativeInt), + lastCachedInputTokens: Schema.optional(NonNegativeInt), + lastOutputTokens: Schema.optional(NonNegativeInt), + lastReasoningOutputTokens: Schema.optional(NonNegativeInt), + toolUses: Schema.optional(NonNegativeInt), + durationMs: Schema.optional(NonNegativeInt), + compactsAutomatically: Schema.optional(Schema.Boolean), +}); +export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; From beb429603158e60870b79121363c5b30b2cc6076 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 14:35:16 -0700 Subject: [PATCH 4/6] Normalize tool lifecycle payloads - Canonicalize provider runtime tool data and usage snapshots - Update work log extraction and user input contracts for the new schema - Add coverage for the revised lifecycle and ingestion shapes --- .../Layers/ProviderRuntimeIngestion.test.ts | 4 +- .../Layers/ProviderRuntimeIngestion.ts | 19 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 27 +- .../src/provider/Layers/ClaudeAdapter.ts | 241 ++++++++++++++---- .../src/provider/Layers/CodexAdapter.ts | 155 +++++++++-- apps/web/src/components/ChatView.tsx | 3 +- apps/web/src/pendingUserInput.ts | 4 +- apps/web/src/session-logic.test.ts | 29 +-- apps/web/src/session-logic.ts | 164 ++++++------ packages/contracts/src/index.ts | 4 + packages/contracts/src/jsonValue.ts | 7 + packages/contracts/src/orchestration.ts | 20 +- packages/contracts/src/provider.ts | 2 +- packages/contracts/src/providerRuntime.ts | 40 +-- packages/contracts/src/toolLifecycle.ts | 39 +++ packages/contracts/src/userInput.ts | 29 +++ 16 files changed, 545 insertions(+), 242 deletions(-) create mode 100644 packages/contracts/src/jsonValue.ts create mode 100644 packages/contracts/src/toolLifecycle.ts create mode 100644 packages/contracts/src/userInput.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b29df5c8fe..38a849343c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1830,7 +1830,9 @@ describe("ProviderRuntimeIngestion", () => { status: "in_progress", title: "Run tests", detail: "bun test", - data: { pid: 123 }, + data: { + kind: "generic", + }, }, }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 170b5bc717..f930599950 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,6 +1,7 @@ import { ApprovalRequestId, type AssistantDeliveryMode, + type CanonicalJsonValue, CommandId, MessageId, type OrchestrationEvent, @@ -102,6 +103,16 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function toCanonicalJsonValue(value: unknown): CanonicalJsonValue | undefined { + if (value === undefined) { + return undefined; + } + const normalized = JSON.parse( + JSON.stringify(value, (_key, nestedValue) => (nestedValue === undefined ? null : nestedValue)), + ) as CanonicalJsonValue | null; + return normalized ?? undefined; +} + function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { @@ -283,7 +294,9 @@ function runtimeEventToActivities( summary: "Runtime warning", payload: { message: truncateDetail(event.payload.message), - ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), + ...(event.payload.detail !== undefined + ? { detail: toCanonicalJsonValue(event.payload.detail) } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -433,7 +446,9 @@ function runtimeEventToActivities( summary: "Context compacted", payload: { state: event.payload.state, - ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), + ...(event.payload.detail !== undefined + ? { detail: toCanonicalJsonValue(event.payload.detail) } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 37983bb5e9..ee3406b0d0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -926,12 +926,13 @@ describe("ClaudeAdapterLive", () => { const toolInputUpdated = runtimeEvents.find( (event) => event.type === "item.updated" && - (event.payload.data as { input?: { pattern?: string; path?: string } } | undefined)?.input - ?.pattern === "foo", + event.payload.data?.kind === "generic" && + event.payload.data.input?.pattern === "foo", ); assert.equal(toolInputUpdated?.type, "item.updated"); if (toolInputUpdated?.type === "item.updated") { assert.deepEqual(toolInputUpdated.payload.data, { + kind: "generic", toolName: "Grep", input: { pattern: "foo", @@ -943,17 +944,25 @@ describe("ClaudeAdapterLive", () => { const toolResultUpdated = runtimeEvents.find( (event) => event.type === "item.updated" && - (event.payload.data as { result?: { tool_use_id?: string } } | undefined)?.result - ?.tool_use_id === "tool-grep-1", + event.payload.data?.kind === "generic" && + event.payload.data.result !== undefined && + event.payload.data.result !== null && + typeof event.payload.data.result === "object" && + !Array.isArray(event.payload.data.result) && + "tool_use_id" in event.payload.data.result && + event.payload.data.result.tool_use_id === "tool-grep-1", ); assert.equal(toolResultUpdated?.type, "item.updated"); if (toolResultUpdated?.type === "item.updated") { assert.equal( - ( - toolResultUpdated.payload.data as { - result?: { content?: string }; - } - ).result?.content, + toolResultUpdated.payload.data?.kind === "generic" && + toolResultUpdated.payload.data.result !== undefined && + toolResultUpdated.payload.data.result !== null && + typeof toolResultUpdated.payload.data.result === "object" && + !Array.isArray(toolResultUpdated.payload.data.result) && + "content" in toolResultUpdated.payload.data.result + ? toolResultUpdated.payload.data.result.content + : undefined, "src/example.ts:1:foo", ); } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 82d72a4d91..37d260e77b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -21,6 +21,7 @@ import { import { ApprovalRequestId, type CanonicalItemType, + type CanonicalToolLifecycleData, type CanonicalRequestType, EventId, type ProviderApprovalDecision, @@ -755,6 +756,116 @@ function tryParseJsonRecord(value: string): Record | undefined } } +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function pushChangedFile(target: string[], seen: Set, value: unknown) { + if (typeof value !== "string") { + return; + } + const normalized = value.trim(); + if (normalized.length === 0 || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +} + +function collectChangedFiles(value: unknown, target: string[], seen: Set, depth: number) { + if (depth > 4 || target.length >= 12) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectChangedFiles(entry, target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } + return; + } + if (!value || typeof value !== "object") { + return; + } + const record = value as Record; + pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.filePath); + pushChangedFile(target, seen, record.relativePath); + pushChangedFile(target, seen, record.filename); + pushChangedFile(target, seen, record.newPath); + pushChangedFile(target, seen, record.oldPath); + for (const nestedKey of ["result", "input", "content", "files", "edits", "changes"]) { + if (nestedKey in record) { + collectChangedFiles(record[nestedKey], target, seen, depth + 1); + } + } +} + +function canonicalToolLifecycleData(input: { + itemType?: CanonicalItemType | undefined; + toolName?: string | undefined; + input?: Record | undefined; + result?: unknown; +}): CanonicalToolLifecycleData | undefined { + const command = normalizeCommandValue(input.input?.command); + const changedFiles: string[] = []; + const seen = new Set(); + collectChangedFiles(input.input, changedFiles, seen, 0); + collectChangedFiles(input.result, changedFiles, seen, 0); + if (input.itemType === "command_execution" && command) { + const resultRecord = + input.result && typeof input.result === "object" && !Array.isArray(input.result) + ? (input.result as Record) + : undefined; + return { + kind: "command_execution", + command, + ...(input.toolName ? { toolName: input.toolName } : {}), + ...(input.input ? { input: input.input as CanonicalToolLifecycleData["input"] } : {}), + ...(typeof resultRecord?.content === "string" ? { output: resultRecord.content } : {}), + ...(typeof resultRecord?.is_error === "boolean" && resultRecord.is_error === false + ? { exitCode: 0 } + : {}), + ...(input.result !== undefined + ? { result: input.result as CanonicalToolLifecycleData["result"] } + : {}), + }; + } + if (input.itemType === "file_change" && changedFiles.length > 0) { + return { + kind: "file_change", + changedFiles, + ...(input.toolName ? { toolName: input.toolName } : {}), + ...(input.input ? { input: input.input as CanonicalToolLifecycleData["input"] } : {}), + ...(input.result !== undefined + ? { result: input.result as CanonicalToolLifecycleData["result"] } + : {}), + }; + } + if (input.toolName || input.input || input.result !== undefined) { + return { + kind: "generic", + ...(input.toolName ? { toolName: input.toolName } : {}), + ...(input.input ? { input: input.input as CanonicalToolLifecycleData["input"] } : {}), + ...(input.result !== undefined + ? { result: input.result as CanonicalToolLifecycleData["result"] } + : {}), + }; + } + return undefined; +} + function toolInputFingerprint(input: Record): string | undefined { try { return JSON.stringify(input); @@ -1431,8 +1542,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { payload: { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(usageSnapshot ? { usage: usageSnapshot } : {}), ...(typeof result?.total_cost_usd === "number" ? { totalCostUsd: result.total_cost_usd } : {}), @@ -1458,10 +1568,19 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { status: status === "completed" ? "completed" : "failed", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), - data: { + ...(canonicalToolLifecycleData({ + itemType: tool.itemType, toolName: tool.toolName, input: tool.input, - }, + }) + ? { + data: canonicalToolLifecycleData({ + itemType: tool.itemType, + toolName: tool.toolName, + input: tool.input, + }), + } + : {}), }, providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), raw: { @@ -1515,8 +1634,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { payload: { state: status, ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), - ...(result?.usage ? { usage: result.usage } : {}), - ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(usageSnapshot ? { usage: usageSnapshot } : {}), ...(typeof result?.total_cost_usd === "number" ? { totalCostUsd: result.total_cost_usd } : {}), @@ -1654,10 +1772,19 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { status: "inProgress", title: nextTool.title, ...(nextTool.detail ? { detail: nextTool.detail } : {}), - data: { + ...(canonicalToolLifecycleData({ + itemType: nextTool.itemType, toolName: nextTool.toolName, input: nextTool.input, - }, + }) + ? { + data: canonicalToolLifecycleData({ + itemType: nextTool.itemType, + toolName: nextTool.toolName, + input: nextTool.input, + }), + } + : {}), }, providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), raw: { @@ -1723,10 +1850,19 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { status: "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), - data: { + ...(canonicalToolLifecycleData({ + itemType: tool.itemType, toolName: tool.toolName, input: toolInput, - }, + }) + ? { + data: canonicalToolLifecycleData({ + itemType: tool.itemType, + toolName: tool.toolName, + input: toolInput, + }), + } + : {}), }, providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), raw: { @@ -1779,11 +1915,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const [index, tool] = toolEntry; const itemStatus = toolResult.isError ? "failed" : "completed"; - const toolData = { + const toolData = canonicalToolLifecycleData({ + itemType: tool.itemType, toolName: tool.toolName, input: tool.input, result: toolResult.block, - }; + }); const updatedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ @@ -1799,7 +1936,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { status: toolResult.isError ? "failed" : "inProgress", title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), - data: toolData, + ...(toolData ? { data: toolData } : {}), }, providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), raw: { @@ -1847,7 +1984,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { status: itemStatus, title: tool.title, ...(tool.detail ? { detail: tool.detail } : {}), - data: toolData, + ...(toolData ? { data: toolData } : {}), }, providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), raw: { @@ -2026,25 +2163,22 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }, }); return; - case "task_progress": - if (message.usage) { - const normalizedUsage = normalizeClaudeTokenUsage( - message.usage, - context.lastKnownContextWindow, - ); - if (normalizedUsage) { - context.lastKnownTokenUsage = normalizedUsage; - const usageStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - eventId: usageStamp.eventId, - createdAt: usageStamp.createdAt, - type: "thread.token-usage.updated", - payload: { - usage: normalizedUsage, - }, - }); - } + case "task_progress": { + const normalizedUsage = message.usage + ? normalizeClaudeTokenUsage(message.usage, context.lastKnownContextWindow) + : undefined; + if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; + const usageStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: usageStamp.eventId, + createdAt: usageStamp.createdAt, + type: "thread.token-usage.updated", + payload: { + usage: normalizedUsage, + }, + }); } yield* offerRuntimeEvent({ ...base, @@ -2053,30 +2187,28 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { taskId: RuntimeTaskId.makeUnsafe(message.task_id), description: message.description, ...(message.summary ? { summary: message.summary } : {}), - ...(message.usage ? { usage: message.usage } : {}), + ...(normalizedUsage ? { usage: normalizedUsage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), }, }); return; - case "task_notification": - if (message.usage) { - const normalizedUsage = normalizeClaudeTokenUsage( - message.usage, - context.lastKnownContextWindow, - ); - if (normalizedUsage) { - context.lastKnownTokenUsage = normalizedUsage; - const usageStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - ...base, - eventId: usageStamp.eventId, - createdAt: usageStamp.createdAt, - type: "thread.token-usage.updated", - payload: { - usage: normalizedUsage, - }, - }); - } + } + case "task_notification": { + const normalizedUsage = message.usage + ? normalizeClaudeTokenUsage(message.usage, context.lastKnownContextWindow) + : undefined; + if (normalizedUsage) { + context.lastKnownTokenUsage = normalizedUsage; + const usageStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + eventId: usageStamp.eventId, + createdAt: usageStamp.createdAt, + type: "thread.token-usage.updated", + payload: { + usage: normalizedUsage, + }, + }); } yield* offerRuntimeEvent({ ...base, @@ -2085,10 +2217,11 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { taskId: RuntimeTaskId.makeUnsafe(message.task_id), status: message.status, ...(message.summary ? { summary: message.summary } : {}), - ...(message.usage ? { usage: message.usage } : {}), + ...(normalizedUsage ? { usage: normalizedUsage } : {}), }, }); return; + } default: yield* emitRuntimeWarning( context, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 9d0a163981..c52b3c955e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -8,6 +8,7 @@ */ import { type CanonicalItemType, + type CanonicalToolLifecycleData, type CanonicalRequestType, type ProviderEvent, type ProviderRuntimeEvent, @@ -110,6 +111,111 @@ function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function pushChangedFile(target: string[], seen: Set, value: unknown) { + if (typeof value !== "string") { + return; + } + const normalized = value.trim(); + if (normalized.length === 0 || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +} + +function collectChangedFiles(value: unknown, target: string[], seen: Set, depth: number) { + if (depth > 4 || target.length >= 12) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectChangedFiles(entry, target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } + return; + } + + const record = asObject(value); + if (!record) { + return; + } + + pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.filePath); + pushChangedFile(target, seen, record.relativePath); + pushChangedFile(target, seen, record.filename); + pushChangedFile(target, seen, record.newPath); + pushChangedFile(target, seen, record.oldPath); + + for (const nestedKey of ["item", "result", "input", "changes", "files", "edits", "patches"]) { + if (!(nestedKey in record)) { + continue; + } + collectChangedFiles(record[nestedKey], target, seen, depth + 1); + } +} + +function buildCanonicalToolLifecycleData( + itemType: CanonicalItemType, + source: Record, + payload: Record, +): CanonicalToolLifecycleData | undefined { + const result = asObject(source.result); + const input = asObject(source.input); + const command = [ + normalizeCommandValue(source.command), + normalizeCommandValue(input?.command), + normalizeCommandValue(result?.command), + normalizeCommandValue(payload.command), + ].find((candidate) => candidate !== undefined); + const changedFiles: string[] = []; + const seen = new Set(); + collectChangedFiles(source, changedFiles, seen, 0); + collectChangedFiles(payload, changedFiles, seen, 0); + if (itemType === "command_execution" && command) { + return { + kind: "command_execution", + command, + ...(input ? { input: input as CanonicalToolLifecycleData["input"] } : {}), + ...(result ? { result: result as CanonicalToolLifecycleData["result"] } : {}), + ...(typeof result?.content === "string" ? { output: result.content } : {}), + ...(asNumber(result?.exitCode) !== undefined ? { exitCode: asNumber(result?.exitCode) } : {}), + }; + } + if (itemType === "file_change" && changedFiles.length > 0) { + return { + kind: "file_change", + changedFiles, + ...(input ? { input: input as CanonicalToolLifecycleData["input"] } : {}), + ...(result ? { result: result as CanonicalToolLifecycleData["result"] } : {}), + }; + } + if (input || result) { + return { + kind: "generic", + ...(input ? { input: input as CanonicalToolLifecycleData["input"] } : {}), + ...(result ? { result: result as CanonicalToolLifecycleData["result"] } : {}), + }; + } + return undefined; +} + function normalizeCodexTokenUsage(value: unknown): ThreadTokenUsageSnapshot | undefined { const usage = asObject(value); const totalUsage = asObject(usage?.total_token_usage ?? usage?.total); @@ -317,27 +423,29 @@ function toCanonicalUserInputAnswers( return {}; } - return Object.fromEntries( - Object.entries(answers).flatMap(([questionId, value]) => { - if (typeof value === "string") { - return [[questionId, value] as const]; - } + const normalized: Record = {}; + for (const [questionId, value] of Object.entries(answers)) { + if (typeof value === "string") { + normalized[questionId] = value; + continue; + } - if (Array.isArray(value)) { - const normalized = value.filter((entry): entry is string => typeof entry === "string"); - return [[questionId, normalized.length === 1 ? normalized[0] : normalized] as const]; - } + if (Array.isArray(value)) { + const entries = value.filter((entry): entry is string => typeof entry === "string"); + normalized[questionId] = entries.length === 1 ? (entries[0] ?? entries) : entries; + continue; + } - const answerObject = asObject(value); - const answerList = asArray(answerObject?.answers)?.filter( - (entry): entry is string => typeof entry === "string", - ); - if (!answerList) { - return []; - } - return [[questionId, answerList.length === 1 ? answerList[0] : answerList] as const]; - }), - ); + const answerObject = asObject(value); + const answerList = asArray(answerObject?.answers)?.filter( + (entry): entry is string => typeof entry === "string", + ); + if (answerList) { + normalized[questionId] = answerList.length === 1 ? (answerList[0] ?? answerList) : answerList; + } + } + + return normalized; } function toUserInputQuestions(payload: Record | undefined) { @@ -562,7 +670,9 @@ function mapItemLifecycle( ...(status ? { status } : {}), ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), ...(detail ? { detail } : {}), - ...(event.payload !== undefined ? { data: event.payload } : {}), + ...(buildCanonicalToolLifecycleData(itemType, source, payload ?? {}) + ? { data: buildCanonicalToolLifecycleData(itemType, source, payload ?? {}) } + : {}), }, }; } @@ -794,8 +904,9 @@ function mapToRuntimeEvents( payload: { state: toTurnStatus(turn?.status), ...(asString(turn?.stopReason) ? { stopReason: asString(turn?.stopReason) } : {}), - ...(turn?.usage !== undefined ? { usage: turn.usage } : {}), - ...(asObject(turn?.modelUsage) ? { modelUsage: asObject(turn?.modelUsage) } : {}), + ...(normalizeCodexTokenUsage(turn?.usage) + ? { usage: normalizeCodexTokenUsage(turn?.usage) } + : {}), ...(asNumber(turn?.totalCostUsd) !== undefined ? { totalCostUsd: asNumber(turn?.totalCostUsd) } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbc887bf62..da4516587e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7,6 +7,7 @@ import { type ProjectScript, type ModelSlug, type ProviderKind, + type ProviderUserInputAnswers, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, @@ -2735,7 +2736,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onRespondToUserInput = useCallback( - async (requestId: ApprovalRequestId, answers: Record) => { + async (requestId: ApprovalRequestId, answers: ProviderUserInputAnswers) => { const api = readNativeApi(); if (!api || !activeThreadId) return; diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 86e41285ad..8b3d5466f3 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,4 +1,4 @@ -import type { UserInputQuestion } from "@t3tools/contracts"; +import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; export interface PendingUserInputDraftAnswer { selectedOptionLabel?: string; @@ -55,7 +55,7 @@ export function setPendingUserInputCustomAnswer( export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { +): ProviderUserInputAnswers | null { const answers: Record = {}; for (const question of questions) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 51fcd474fc..9ab153a8df 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -697,9 +697,8 @@ describe("deriveWorkLogEntries", () => { payload: { itemType: "command_execution", data: { - item: { - command: ["bun", "run", "lint"], - }, + kind: "command_execution", + command: "bun run lint", }, }, }), @@ -721,12 +720,11 @@ describe("deriveWorkLogEntries", () => { status: "completed", detail: '{ "dev": "vite dev --port 3000" } ', data: { - item: { - command: ["bun", "run", "dev"], - result: { - content: '{ "dev": "vite dev --port 3000" } ', - exitCode: 0, - }, + kind: "command_execution", + command: "bun run dev", + result: { + content: '{ "dev": "vite dev --port 3000" } ', + exitCode: 0, }, }, }, @@ -751,12 +749,8 @@ describe("deriveWorkLogEntries", () => { payload: { itemType: "file_change", data: { - item: { - changes: [ - { path: "apps/web/src/components/ChatView.tsx" }, - { filename: "apps/web/src/session-logic.ts" }, - ], - }, + kind: "file_change", + changedFiles: ["apps/web/src/components/ChatView.tsx", "apps/web/src/session-logic.ts"], }, }, }), @@ -792,9 +786,7 @@ describe("deriveWorkLogEntries", () => { title: "Tool call", detail: 'Read: {"file_path":"/tmp/app.ts"}', data: { - item: { - command: ["sed", "-n", "1,40p", "/tmp/app.ts"], - }, + kind: "generic", }, }, }), @@ -819,7 +811,6 @@ describe("deriveWorkLogEntries", () => { createdAt: "2026-02-23T00:00:03.000Z", label: "Tool call completed", detail: 'Read: {"file_path":"/tmp/app.ts"}', - command: "sed -n 1,40p /tmp/app.ts", itemType: "dynamic_tool_call", toolTitle: "Tool call", }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 83a95d6313..57ad56124e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + type CanonicalToolLifecycleData, isToolLifecycleItemType, type OrchestrationLatestTurn, type OrchestrationThreadActivity, @@ -476,22 +477,10 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; } - - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); + return activity.payload.detail?.startsWith("ExitPlanMode:") ?? false; } function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const command = extractToolCommand(payload); - const changedFiles = extractChangedFiles(payload); - const title = extractToolTitle(payload); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, @@ -499,12 +488,24 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo tone: activity.tone === "approval" ? "info" : activity.tone, activityKind: activity.kind, }; - const itemType = extractWorkLogItemType(payload); - const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { - const detail = stripTrailingExitCode(payload.detail).output; - if (detail) { - entry.detail = detail; + const toolPayload = toolLifecyclePayloadFromActivity(activity); + const detail = + "detail" in activity.payload && typeof activity.payload.detail === "string" + ? activity.payload.detail + : undefined; + const toolData = toolPayload && "data" in toolPayload ? toolPayload.data : undefined; + const command = extractToolCommand(toolData); + const changedFiles = extractChangedFiles(toolData); + const title = + "title" in activity.payload && typeof activity.payload.title === "string" + ? extractToolTitle(activity.payload.title) + : null; + const itemType = extractWorkLogItemType(toolPayload); + const requestKind = extractWorkLogRequestKind(activity); + if (detail && detail.length > 0) { + const normalizedDetail = stripTrailingExitCode(detail).output; + if (normalizedDetail) { + entry.detail = normalizedDetail; } } if (command) { @@ -624,10 +625,6 @@ function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPl }; } -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - function asTrimmedString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -650,22 +647,37 @@ function normalizeCommandValue(value: unknown): string | null { return parts.length > 0 ? parts.join(" ") : null; } -function extractToolCommand(payload: Record | null): string | null { - const data = asRecord(payload?.data); - const item = asRecord(data?.item); - const itemResult = asRecord(item?.result); - const itemInput = asRecord(item?.input); +function toolLifecyclePayloadFromActivity( + activity: OrchestrationThreadActivity, +): + | Extract< + OrchestrationThreadActivity, + { kind: "tool.started" | "tool.updated" | "tool.completed" } + >["payload"] + | null { + switch (activity.kind) { + case "tool.started": + case "tool.updated": + case "tool.completed": + return activity.payload; + default: + return null; + } +} + +function extractToolCommand(data: CanonicalToolLifecycleData | undefined): string | null { + if (data?.kind !== "command_execution") { + return null; + } const candidates = [ - normalizeCommandValue(item?.command), - normalizeCommandValue(itemInput?.command), - normalizeCommandValue(itemResult?.command), - normalizeCommandValue(data?.command), + normalizeCommandValue(data.command), + normalizeCommandValue(data.input?.command), ]; return candidates.find((candidate) => candidate !== null) ?? null; } -function extractToolTitle(payload: Record | null): string | null { - return asTrimmedString(payload?.title); +function extractToolTitle(value: string | undefined): string | null { + return asTrimmedString(value); } function stripTrailingExitCode(value: string): { @@ -690,28 +702,36 @@ function stripTrailingExitCode(value: string): { } function extractWorkLogItemType( - payload: Record | null, + payload: + | Extract< + OrchestrationThreadActivity, + { kind: "tool.started" | "tool.updated" | "tool.completed" } + >["payload"] + | null, ): WorkLogEntry["itemType"] | undefined { - if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { + if (payload && isToolLifecycleItemType(payload.itemType)) { return payload.itemType; } return undefined; } function extractWorkLogRequestKind( - payload: Record | null, + activity: OrchestrationThreadActivity, ): WorkLogEntry["requestKind"] | undefined { + if (activity.kind !== "approval.requested" && activity.kind !== "approval.resolved") { + return undefined; + } if ( - payload?.requestKind === "command" || - payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + activity.payload.requestKind === "command" || + activity.payload.requestKind === "file-read" || + activity.payload.requestKind === "file-change" ) { - return payload.requestKind; + return activity.payload.requestKind; } - return requestKindFromRequestType(payload?.requestType) ?? undefined; + return requestKindFromRequestType(activity.payload.requestType) ?? undefined; } -function pushChangedFile(target: string[], seen: Set, value: unknown) { +function pushChangedFile(target: string[], seen: Set, value: string | undefined) { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { return; @@ -720,58 +740,16 @@ function pushChangedFile(target: string[], seen: Set, value: unknown) { target.push(normalized); } -function collectChangedFiles(value: unknown, target: string[], seen: Set, depth: number) { - if (depth > 4 || target.length >= 12) { - return; - } - if (Array.isArray(value)) { - for (const entry of value) { - collectChangedFiles(entry, target, seen, depth + 1); - if (target.length >= 12) { - return; - } - } - return; - } - - const record = asRecord(value); - if (!record) { - return; - } - - pushChangedFile(target, seen, record.path); - pushChangedFile(target, seen, record.filePath); - pushChangedFile(target, seen, record.relativePath); - pushChangedFile(target, seen, record.filename); - pushChangedFile(target, seen, record.newPath); - pushChangedFile(target, seen, record.oldPath); - - for (const nestedKey of [ - "item", - "result", - "input", - "data", - "changes", - "files", - "edits", - "patch", - "patches", - "operations", - ]) { - if (!(nestedKey in record)) { - continue; - } - collectChangedFiles(record[nestedKey], target, seen, depth + 1); - if (target.length >= 12) { - return; - } - } -} - -function extractChangedFiles(payload: Record | null): string[] { +function extractChangedFiles(data: CanonicalToolLifecycleData | undefined): string[] { const changedFiles: string[] = []; const seen = new Set(); - collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); + const files = data?.kind === "file_change" ? data.changedFiles : []; + for (const file of files) { + pushChangedFile(changedFiles, seen, file); + if (changedFiles.length >= 12) { + break; + } + } return changedFiles; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..355a04f6ca 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,11 +3,15 @@ export * from "./ipc"; export * from "./terminal"; export * from "./provider"; export * from "./providerRuntime"; +export * from "./threadUsage"; export * from "./model"; export * from "./ws"; export * from "./keybindings"; export * from "./server"; export * from "./git"; +export * from "./jsonValue"; export * from "./orchestration"; +export * from "./toolLifecycle"; +export * from "./userInput"; export * from "./editor"; export * from "./project"; diff --git a/packages/contracts/src/jsonValue.ts b/packages/contracts/src/jsonValue.ts new file mode 100644 index 0000000000..c1dcd499cf --- /dev/null +++ b/packages/contracts/src/jsonValue.ts @@ -0,0 +1,7 @@ +import { Schema } from "effect"; + +export const CanonicalJsonValueSchema = Schema.Json; +export type CanonicalJsonValue = typeof CanonicalJsonValueSchema.Type; + +export const CanonicalJsonObjectSchema = Schema.Record(Schema.String, CanonicalJsonValueSchema); +export type CanonicalJsonObject = typeof CanonicalJsonObjectSchema.Type; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index ad040d976c..b5a4b44a7f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -14,7 +14,10 @@ import { TrimmedNonEmptyString, TurnId, } from "./baseSchemas"; +import { CanonicalJsonValueSchema } from "./jsonValue"; +import { CanonicalToolLifecycleData } from "./toolLifecycle"; import { ThreadTokenUsageSnapshot } from "./threadUsage"; +import { ProviderUserInputAnswers, UserInputQuestion } from "./userInput"; export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", @@ -96,9 +99,6 @@ export const ProviderApprovalDecision = Schema.Literals([ "cancel", ]); export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; -export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); -export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; - export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; @@ -311,7 +311,7 @@ const RuntimeErrorActivityPayload = Schema.Struct({ const RuntimeWarningActivityPayload = Schema.Struct({ message: TrimmedNonEmptyString, - detail: Schema.optional(Schema.Unknown), + detail: Schema.optional(CanonicalJsonValueSchema), }); const RuntimePlanStep = Schema.Struct({ @@ -326,12 +326,12 @@ const TurnPlanUpdatedActivityPayload = Schema.Struct({ const UserInputRequestedActivityPayload = Schema.Struct({ requestId: Schema.optional(TrimmedNonEmptyString), - questions: Schema.Array(Schema.Unknown), + questions: Schema.Array(UserInputQuestion), }); const UserInputResolvedActivityPayload = Schema.Struct({ requestId: Schema.optional(TrimmedNonEmptyString), - answers: Schema.Record(Schema.String, Schema.Unknown), + answers: ProviderUserInputAnswers, }); const TaskStartedActivityPayload = Schema.Struct({ @@ -345,19 +345,19 @@ const TaskProgressActivityPayload = Schema.Struct({ detail: TrimmedNonEmptyString, summary: Schema.optional(TrimmedNonEmptyString), lastToolName: Schema.optional(TrimmedNonEmptyString), - usage: Schema.optional(Schema.Unknown), + usage: Schema.optional(ThreadTokenUsageSnapshot), }); const TaskCompletedActivityPayload = Schema.Struct({ taskId: TrimmedNonEmptyString, status: TrimmedNonEmptyString, detail: Schema.optional(TrimmedNonEmptyString), - usage: Schema.optional(Schema.Unknown), + usage: Schema.optional(ThreadTokenUsageSnapshot), }); const ContextCompactionActivityPayload = Schema.Struct({ state: Schema.Literal("compacted"), - detail: Schema.optional(Schema.Unknown), + detail: Schema.optional(CanonicalJsonValueSchema), }); const ToolStartedOrCompletedActivityPayload = Schema.Struct({ @@ -369,7 +369,7 @@ const ToolUpdatedActivityPayload = Schema.Struct({ itemType: TrimmedNonEmptyString, status: Schema.optional(TrimmedNonEmptyString), detail: Schema.optional(TrimmedNonEmptyString), - data: Schema.optional(Schema.Unknown), + data: Schema.optional(CanonicalToolLifecycleData), }); const CheckpointRevertFailedActivityPayload = Schema.Struct({ diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e28088dc92..e62484f30b 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderUserInputAnswers } from "./userInput"; import { ApprovalRequestId, EventId, @@ -20,7 +21,6 @@ import { ProviderRequestKind, ProviderSandboxMode, ProviderStartOptions, - ProviderUserInputAnswers, RuntimeMode, } from "./orchestration"; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index da34b7f247..18df88c9bb 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,4 +1,4 @@ -import { Option, Schema } from "effect"; +import { Schema } from "effect"; import { EventId, IsoDateTime, @@ -11,11 +11,13 @@ import { TurnId, } from "./baseSchemas"; import { ProviderKind } from "./orchestration"; +import { CanonicalToolLifecycleData } from "./toolLifecycle"; export { ThreadTokenUsageSnapshot } from "./threadUsage"; -import { ThreadTokenUsageSnapshot as ThreadTokenUsageSnapshotSchema } from "./threadUsage"; +import { ThreadTokenUsageSnapshot } from "./threadUsage"; +export { ProviderUserInputAnswers, UserInputQuestion } from "./userInput"; +import { ProviderUserInputAnswers, UserInputQuestion } from "./userInput"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; -const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.notification", @@ -244,12 +246,12 @@ export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; const ThreadMetadataUpdatedPayload = Schema.Struct({ name: Schema.optional(TrimmedNonEmptyStringSchema), - metadata: Schema.optional(UnknownRecordSchema), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }); export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; const ThreadTokenUsageUpdatedPayload = Schema.Struct({ - usage: ThreadTokenUsageSnapshotSchema, + usage: ThreadTokenUsageSnapshot, }); export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; @@ -262,8 +264,7 @@ export type TurnStartedPayload = typeof TurnStartedPayload.Type; const TurnCompletedPayload = Schema.Struct({ state: RuntimeTurnState, stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), - usage: Schema.optional(Schema.Unknown), - modelUsage: Schema.optional(UnknownRecordSchema), + usage: Schema.optional(ThreadTokenUsageSnapshot), totalCostUsd: Schema.optional(Schema.Number), errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), }); @@ -301,7 +302,7 @@ export const ItemLifecyclePayload = Schema.Struct({ status: Schema.optional(RuntimeItemStatus), title: Schema.optional(TrimmedNonEmptyStringSchema), detail: Schema.optional(TrimmedNonEmptyStringSchema), - data: Schema.optional(Schema.Unknown), + data: Schema.optional(CanonicalToolLifecycleData), }); export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type; @@ -327,30 +328,13 @@ const RequestResolvedPayload = Schema.Struct({ }); export type RequestResolvedPayload = typeof RequestResolvedPayload.Type; -const UserInputQuestionOption = Schema.Struct({ - label: TrimmedNonEmptyStringSchema, - description: TrimmedNonEmptyStringSchema, -}); -export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; - -export const UserInputQuestion = Schema.Struct({ - id: TrimmedNonEmptyStringSchema, - header: TrimmedNonEmptyStringSchema, - question: TrimmedNonEmptyStringSchema, - options: Schema.Array(UserInputQuestionOption), - multiSelect: Schema.optional(Schema.Boolean).pipe( - Schema.withConstructorDefault(() => Option.some(false)), - ), -}); -export type UserInputQuestion = typeof UserInputQuestion.Type; - const UserInputRequestedPayload = Schema.Struct({ questions: Schema.Array(UserInputQuestion), }); export type UserInputRequestedPayload = typeof UserInputRequestedPayload.Type; const UserInputResolvedPayload = Schema.Struct({ - answers: UnknownRecordSchema, + answers: ProviderUserInputAnswers, }); export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; @@ -365,7 +349,7 @@ const TaskProgressPayload = Schema.Struct({ taskId: RuntimeTaskId, description: TrimmedNonEmptyStringSchema, summary: Schema.optional(TrimmedNonEmptyStringSchema), - usage: Schema.optional(Schema.Unknown), + usage: Schema.optional(ThreadTokenUsageSnapshot), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; @@ -374,7 +358,7 @@ const TaskCompletedPayload = Schema.Struct({ taskId: RuntimeTaskId, status: Schema.Literals(["completed", "failed", "stopped"]), summary: Schema.optional(TrimmedNonEmptyStringSchema), - usage: Schema.optional(Schema.Unknown), + usage: Schema.optional(ThreadTokenUsageSnapshot), }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; diff --git a/packages/contracts/src/toolLifecycle.ts b/packages/contracts/src/toolLifecycle.ts new file mode 100644 index 0000000000..017efb26ff --- /dev/null +++ b/packages/contracts/src/toolLifecycle.ts @@ -0,0 +1,39 @@ +import { Schema } from "effect"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { CanonicalJsonObjectSchema, CanonicalJsonValueSchema } from "./jsonValue"; + +export const CommandExecutionToolLifecycleData = Schema.Struct({ + kind: Schema.Literal("command_execution"), + command: TrimmedNonEmptyString, + toolName: Schema.optional(TrimmedNonEmptyString), + input: Schema.optional(CanonicalJsonObjectSchema), + output: Schema.optional(Schema.String), + exitCode: Schema.optional(NonNegativeInt), + result: Schema.optional(CanonicalJsonValueSchema), +}); +export type CommandExecutionToolLifecycleData = typeof CommandExecutionToolLifecycleData.Type; + +export const FileChangeToolLifecycleData = Schema.Struct({ + kind: Schema.Literal("file_change"), + changedFiles: Schema.Array(TrimmedNonEmptyString), + toolName: Schema.optional(TrimmedNonEmptyString), + input: Schema.optional(CanonicalJsonObjectSchema), + result: Schema.optional(CanonicalJsonValueSchema), +}); +export type FileChangeToolLifecycleData = typeof FileChangeToolLifecycleData.Type; + +export const GenericToolLifecycleData = Schema.Struct({ + kind: Schema.Literal("generic"), + toolName: Schema.optional(TrimmedNonEmptyString), + input: Schema.optional(CanonicalJsonObjectSchema), + result: Schema.optional(CanonicalJsonValueSchema), +}); +export type GenericToolLifecycleData = typeof GenericToolLifecycleData.Type; + +export const CanonicalToolLifecycleData = Schema.Union([ + CommandExecutionToolLifecycleData, + FileChangeToolLifecycleData, + GenericToolLifecycleData, +]); +export type CanonicalToolLifecycleData = typeof CanonicalToolLifecycleData.Type; diff --git a/packages/contracts/src/userInput.ts b/packages/contracts/src/userInput.ts new file mode 100644 index 0000000000..5194d95d96 --- /dev/null +++ b/packages/contracts/src/userInput.ts @@ -0,0 +1,29 @@ +import { Option, Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const UserInputQuestionOption = Schema.Struct({ + label: TrimmedNonEmptyString, + description: TrimmedNonEmptyString, +}); +export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; + +export const UserInputQuestion = Schema.Struct({ + id: TrimmedNonEmptyString, + header: TrimmedNonEmptyString, + question: TrimmedNonEmptyString, + options: Schema.Array(UserInputQuestionOption), + multiSelect: Schema.optional(Schema.Boolean).pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), +}); +export type UserInputQuestion = typeof UserInputQuestion.Type; + +export const ProviderUserInputAnswer = Schema.Union([ + TrimmedNonEmptyString, + Schema.Array(TrimmedNonEmptyString), +]); +export type ProviderUserInputAnswer = typeof ProviderUserInputAnswer.Type; + +export const ProviderUserInputAnswers = Schema.Record(Schema.String, ProviderUserInputAnswer); +export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; From e02c60c94ecd2cd0a4389f95e69de526e359c84d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 23:02:30 -0700 Subject: [PATCH 5/6] Fix projection snapshot import after rebase Co-authored-by: codex --- apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index acf0dd5438..5dc19f0e7e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -17,7 +17,6 @@ import { type OrchestrationProject, type OrchestrationSession, type OrchestrationThread, - type OrchestrationThreadActivity, ModelSelection, } from "@t3tools/contracts"; import { Effect, Layer, Schema, Struct } from "effect"; From 0458f4e6c5be48b260ddd264c56e81ee98899125 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 23:39:44 -0700 Subject: [PATCH 6/6] Adjust Claude adapter stream event test counts - Update live Claude adapter tests to match the current startup event sequence - Fix stream drain expectations after removing the configured event --- .../src/provider/Layers/ClaudeAdapter.test.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ee3406b0d0..96e9d836b9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -614,7 +614,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( Stream.runCollect, Effect.forkChild, ); @@ -742,7 +742,7 @@ describe("ClaudeAdapterLive", () => { ], ); - const turnStarted = runtimeEvents[3]; + const turnStarted = runtimeEvents[2]; assert.equal(turnStarted?.type, "turn.started"); if (turnStarted?.type === "turn.started") { assert.equal(String(turnStarted.turnId), String(turn.turnId)); @@ -790,7 +790,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe( Stream.runCollect, Effect.forkChild, ); @@ -977,7 +977,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1053,7 +1053,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1242,7 +1242,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1289,7 +1289,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 5).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1343,7 +1343,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1412,7 +1412,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1668,7 +1668,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( Stream.runCollect, Effect.forkChild, ); @@ -1736,7 +1736,7 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 12).pipe( Stream.runCollect, Effect.forkChild, ); @@ -2041,7 +2041,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "approval-required", }); - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); yield* adapter.sendTurn({ threadId: session.threadId, @@ -2150,7 +2150,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "approval-required", }); - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); const createInput = harness.getLastCreateQueryInput(); const canUseTool = createInput?.options.canUseTool; @@ -2498,7 +2498,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); yield* adapter.sendTurn({ threadId: session.threadId, @@ -2564,7 +2564,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); yield* adapter.sendTurn({ threadId: session.threadId, @@ -2636,8 +2636,8 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "approval-required", }); - // Drain the session startup events (started, configured, state.changed). - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + // Drain the session startup events (started, state.changed). + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); yield* adapter.sendTurn({ threadId: session.threadId, @@ -2761,7 +2761,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); const createInput = harness.getLastCreateQueryInput(); const canUseTool = createInput?.options.canUseTool; @@ -2829,7 +2829,7 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "approval-required", }); - yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + yield* Stream.take(adapter.streamEvents, 2).pipe(Stream.runDrain); const createInput = harness.getLastCreateQueryInput(); const canUseTool = createInput?.options.canUseTool;