diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 66c4359b8190..6964386a546e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1410,6 +1410,23 @@ function renderFeedEntry( return ; } + if (entry.type === "reasoning-markdown") { + return ( + + + + + + ); + } + if (entry.type === "agent-spawn") { return ( { + const turnId = TurnId.make("segment-turn"); + const at = (second: number) => `2026-09-08T00:00:${String(second).padStart(2, "0")}.000Z`; + const thinkingActivity = ( + id: string, + kind: "tool.updated" | "tool.completed", + second: number, + toolCallId = id, + activityTurnId = turnId, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: "Thinking", + tone: "tool", + createdAt: at(second), + turnId: activityTurnId, + payload: { + itemType: "reasoning", + toolCallId, + status: kind === "tool.completed" ? "completed" : "inProgress", + title: "Thinking", + }, + }); + const toolActivity = (id: string, second: number) => + makeActivity({ + id: EventId.make(id), + kind: "tool.completed", + summary: "Ran command", + tone: "tool", + createdAt: at(second), + turnId, + payload: { + itemType: "command_execution", + toolCallId: `call-${id}`, + status: "completed", + title: "Ran command", + command: "git status", + }, + }); + const settledTurn = { + turnId, + state: "completed" as const, + requestedAt: at(0), + startedAt: at(0), + completedAt: at(30), + assistantMessageId: null, + }; + const summaries = (rows: ThreadFeedEntry[]) => + rows + .filter((row) => row.type === "work-toggle") + .map((row) => (row.type === "work-toggle" ? row.summary : null)); + + it("splits tool runs at thinking boundaries instead of one giant pile", () => { + const thread = makeThread({ + id: ThreadId.make("segment-split"), + projectId: ProjectId.make("project-1"), + title: "Segments", + latestTurn: settledTurn, + activities: [ + thinkingActivity("thought-1-updated", "tool.updated", 0, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), + toolActivity("tool-1", 5), + toolActivity("tool-2", 6), + toolActivity("tool-3", 7), + thinkingActivity("thought-2-updated", "tool.updated", 8, "thought-2"), + thinkingActivity("thought-2-completed", "tool.completed", 15, "thought-2"), + toolActivity("tool-4", 16), + toolActivity("tool-5", 17), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + settledTurn, + new Set([turnId]), + ); + + expect(summaries(rows)).toEqual([ + "Thought for 4.0s", + "Ran 3 commands", + "Thought for 7.0s", + "Ran 2 commands", + ]); + }); + + it("renders provider reasoning text as inline markdown, not a Thought accordion", () => { + const text = + "The user wants to know their opencode version. I should run the command to check."; + const thread = makeThread({ + id: ThreadId.make("segment-visible-text"), + projectId: ProjectId.make("project-1"), + title: "Visible thoughts", + latestTurn: settledTurn, + activities: [ + makeActivity({ + id: EventId.make("thought-text-updated"), + kind: "tool.updated", + summary: "Thinking", + tone: "tool", + createdAt: at(0), + turnId, + payload: { + itemType: "reasoning", + toolCallId: "thought-text", + status: "inProgress", + title: "Thinking", + detail: "The user wants", + }, + }), + makeActivity({ + id: EventId.make("thought-text-completed"), + kind: "tool.completed", + summary: "Thinking", + tone: "tool", + createdAt: at(4), + turnId, + payload: { + itemType: "reasoning", + toolCallId: "thought-text", + status: "completed", + title: "Thinking", + detail: text, + }, + }), + toolActivity("tool-1", 5), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + settledTurn, + new Set([turnId]), + ); + expect(summaries(rows)).toEqual(["Ran command"]); + const markdown = rows.find((row) => row.type === "reasoning-markdown"); + expect(markdown).toMatchObject({ + type: "reasoning-markdown", + text, + streaming: false, + }); + expect( + rows.some((row) => row.type === "work-toggle" && row.summary.startsWith("Thought")), + ).toBe(false); + }); + + it("keeps a stable reasoning-markdown row id across streaming updates", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const present = (activities: ReturnType[]) => { + const thread = makeThread({ + id: ThreadId.make("segment-stable-id"), + projectId: ProjectId.make("project-1"), + title: "Stable id", + latestTurn: runningTurn, + activities, + }); + return deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + }; + const first = present([ + { + ...thinkingActivity("thought-1-a", "tool.updated", 1, "thought-1"), + payload: { + itemType: "reasoning", + toolCallId: "thought-1", + status: "inProgress", + title: "Thinking", + detail: "Start", + }, + }, + ]); + const second = present([ + { + ...thinkingActivity("thought-1-a", "tool.updated", 1, "thought-1"), + payload: { + itemType: "reasoning", + toolCallId: "thought-1", + status: "inProgress", + title: "Thinking", + detail: "Start", + }, + }, + { + ...thinkingActivity("thought-1-b", "tool.updated", 2, "thought-1"), + payload: { + itemType: "reasoning", + toolCallId: "thought-1", + status: "inProgress", + title: "Thinking", + detail: " middle", + }, + }, + { + ...thinkingActivity("thought-1-c", "tool.updated", 3, "thought-1"), + payload: { + itemType: "reasoning", + toolCallId: "thought-1", + status: "inProgress", + title: "Thinking", + detail: " end", + }, + }, + ]); + const firstRow = first.find((row) => row.type === "reasoning-markdown"); + const secondRow = second.find((row) => row.type === "reasoning-markdown"); + expect(firstRow).toMatchObject({ + type: "reasoning-markdown", + id: `reasoning-markdown:${turnId}:thought-1`, + text: "Start", + streaming: true, + }); + expect(secondRow).toMatchObject({ + type: "reasoning-markdown", + id: `reasoning-markdown:${turnId}:thought-1`, + text: "Start middle end", + streaming: true, + }); + expect(secondRow?.id).toBe(firstRow?.id); + }); + + it("keeps settled history monotonic while the live tail alternates", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const commentary = { + id: MessageId.make("monotonic-commentary"), + role: "assistant" as const, + text: "Checking the next step.", + turnId, + streaming: false, + createdAt: at(3), + updatedAt: at(3), + }; + const steps = [ + thinkingActivity("thought-a-start", "tool.updated", 1, "thought-a"), + thinkingActivity("thought-a-end", "tool.completed", 2, "thought-a"), + toolActivity("tool-a", 5), + toolActivity("tool-b", 7), + thinkingActivity("thought-b-start", "tool.updated", 8, "thought-b"), + thinkingActivity("thought-b-end", "tool.completed", 9, "thought-b"), + toolActivity("tool-c", 11), + thinkingActivity("thought-c-start", "tool.updated", 12, "thought-c"), + ]; + const stages = [ + { activityCount: 1, commentary: false }, + { activityCount: 2, commentary: false }, + { activityCount: 2, commentary: true }, + ...steps.slice(2).map((_, index) => ({ activityCount: index + 3, commentary: true })), + ]; + let settledHistory: string[] = []; + let finalRows: ThreadFeedEntry[] = []; + + for (const [index, stage] of stages.entries()) { + const thread = makeThread({ + id: ThreadId.make(`segment-monotonic-${index}`), + projectId: ProjectId.make("project-1"), + title: "Monotonic segments", + latestTurn: runningTurn, + messages: stage.commentary ? [commentary] : [], + activities: steps.slice(0, stage.activityCount), + }); + finalRows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + const history = finalRows + .filter( + (row) => + (row.type === "work-toggle" && !row.shimmer) || + (row.type === "message" && row.message.role === "assistant"), + ) + .map((row) => + row.type === "work-toggle" + ? `${row.id}:${row.summary}` + : row.type === "message" + ? `${row.id}:${row.message.text}` + : row.id, + ); + expect(history.slice(0, settledHistory.length)).toEqual(settledHistory); + settledHistory = history; + expect( + finalRows.filter( + (row) => (row.type === "work-toggle" && row.shimmer) || row.type === "thinking", + ), + ).toHaveLength(1); + } + + expect(summaries(finalRows)).toEqual([ + "Thought for 1.0s", + "Ran 2 commands", + "Thought for 1.0s", + "Ran command", + "Thinking", + ]); + + const restoredThread = makeThread({ + id: ThreadId.make("segment-monotonic-restored"), + projectId: ProjectId.make("project-1"), + title: "Restored monotonic segments", + latestTurn: settledTurn, + messages: [commentary], + activities: steps, + }); + expect( + summaries( + deriveThreadFeedPresentation( + buildThreadFeed(restoredThread), + settledTurn, + new Set([turnId]), + ), + ), + ).toEqual(["Thought for 1.0s", "Ran 2 commands", "Thought for 1.0s", "Ran command", "Thought"]); + }); + + it("expands a thought into its lifecycle pair", () => { + const thread = makeThread({ + id: ThreadId.make("segment-expand"), + projectId: ProjectId.make("project-1"), + title: "Expand thought", + latestTurn: settledTurn, + activities: [ + thinkingActivity("thought-1-updated", "tool.updated", 0, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + settledTurn, + new Set([turnId]), + new Set(["work-group:tool:segment-turn:thought-1"]), + ); + + expect(summaries(rows)).toEqual(["Thought for 4.0s"]); + const details = rows.filter((row) => row.type === "activity-group"); + expect(details).toHaveLength(1); + // The lifecycle pair merges into one entry carrying both ends. + expect(details[0]?.type === "activity-group" && details[0].activities).toHaveLength(1); + }); + + it("shimmers the live thought and hides the generic thinking fallback", () => { + const thread = makeThread({ + id: ThreadId.make("segment-live"), + projectId: ProjectId.make("project-1"), + title: "Live thought", + latestTurn: { ...settledTurn, state: "running", completedAt: null }, + activities: [ + toolActivity("tool-1", 6), + thinkingActivity("thought-live", "tool.updated", 7, "thought-live"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + { ...settledTurn, state: "running", completedAt: null }, + new Set([turnId]), + new Set(), + at(0), + ); + + const live = rows.filter((row) => row.type === "work-toggle" && row.shimmer); + expect(live).toHaveLength(1); + expect(live[0]).toMatchObject({ summary: "Thinking", live: true }); + expect(rows.some((row) => row.type === "thinking")).toBe(false); + }); + + it("renders an interrupted thought without inventing a duration", () => { + const thread = makeThread({ + id: ThreadId.make("segment-interrupted"), + projectId: ProjectId.make("project-1"), + title: "Interrupted", + latestTurn: settledTurn, + activities: [ + toolActivity("tool-1", 5), + thinkingActivity("thought-live", "tool.updated", 6, "thought-live"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + settledTurn, + new Set([turnId]), + ); + + expect(summaries(rows)).toEqual(["Ran command", "Thought"]); + }); + + it("keeps completed thoughts static while the turn keeps working", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-working-history"), + projectId: ProjectId.make("project-1"), + title: "Working history", + latestTurn: runningTurn, + activities: [ + thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 5, "thought-1"), + { + ...toolActivity("tool-1", 9), + payload: { + itemType: "command_execution", + toolCallId: "call-tool-1", + status: "inProgress", + title: "Ran command", + command: "git status", + }, + }, + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + // Exactly one shimmering row (the running tool); the finished thought is + // history even though the turn is still working. + const shimmering = rows.filter((row) => row.type === "work-toggle" && row.shimmer); + expect(shimmering).toHaveLength(1); + expect(summaries(rows)).toEqual(["Thought for 4.0s", "Ran command"]); + }); + + it("pins an earlier thought static when commentary splits the groups", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-split-groups"), + projectId: ProjectId.make("project-1"), + title: "Split groups", + latestTurn: runningTurn, + messages: [ + { + id: MessageId.make("commentary"), + role: "assistant", + text: "On it.", + turnId, + streaming: false, + createdAt: at(6), + updatedAt: at(6), + }, + ], + activities: [ + thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 5, "thought-1"), + { + ...toolActivity("tool-1", 9), + payload: { + itemType: "command_execution", + toolCallId: "call-tool-1", + status: "inProgress", + title: "Ran command", + command: "git status", + }, + }, + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + // The live tool lives in a later group than the thought; still exactly + // one shimmering row, and the thought is static history. + const shimmering = rows.filter((row) => row.type === "work-toggle" && row.shimmer); + expect(shimmering).toHaveLength(1); + expect(summaries(rows)).toEqual(["Thought for 4.0s", "Ran command"]); + expect(rows.some((row) => row.type === "message")).toBe(true); + }); + + it("lets only the latest open thought shimmer", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-two-open"), + projectId: ProjectId.make("project-1"), + title: "Two open", + latestTurn: runningTurn, + activities: [ + thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), + thinkingActivity("thought-2-updated", "tool.updated", 2, "thought-2"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + const shimmering = rows.filter((row) => row.type === "work-toggle" && row.shimmer); + expect(shimmering).toHaveLength(1); + expect(shimmering[0]).toMatchObject({ summary: "Thinking" }); + expect(summaries(rows)).toEqual(["Thought", "Thinking"]); + expect(rows.some((row) => row.type === "thinking")).toBe(false); + }); + + it("keeps a commentary-separated live thought in the single live slot", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-commentary-live"), + projectId: ProjectId.make("project-1"), + title: "Commentary then live thought", + latestTurn: runningTurn, + messages: [ + { + id: MessageId.make("commentary-after-thought"), + role: "assistant", + text: "Still working.", + turnId, + streaming: false, + createdAt: at(2), + updatedAt: at(2), + }, + ], + activities: [thinkingActivity("thought-live", "tool.updated", 1, "thought-live")], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + expect(rows.filter((row) => row.type === "work-toggle" && row.shimmer)).toMatchObject([ + { summary: "Thinking", live: true, id: LIVE_ACTIVITY_ROW_ID }, + ]); + expect(rows.some((row) => row.type === "thinking")).toBe(false); + + const liveThought = rows.find( + (row): row is Extract<(typeof rows)[number], { type: "work-toggle" }> => + row.type === "work-toggle" && row.live, + )!; + const expandedRows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set([liveThought.groupId]), + at(0), + ); + expect( + expandedRows + .filter((row) => row.type === "activity-group") + .flatMap((row) => row.activities) + .filter((activity) => activity.live), + ).toMatchObject([{ live: true }]); + }); + + it("lets a later thought take the live slot from an earlier tool", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-after-live-tool"), + projectId: ProjectId.make("project-1"), + title: "Thought after tool", + latestTurn: runningTurn, + activities: [ + { + ...toolActivity("tool-1", 1), + payload: { + itemType: "command_execution", + toolCallId: "call-tool-1", + status: "inProgress", + title: "Running command", + command: "git status", + }, + }, + thinkingActivity("thought-live", "tool.updated", 2, "thought-live"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + expect(rows.filter((row) => row.type === "work-toggle" && row.shimmer)).toMatchObject([ + { summary: "Thinking", live: true }, + ]); + expect(rows.some((row) => row.type === "thinking")).toBe(false); + }); + + it("scopes terminal reasoning identity to the unsettled turn", () => { + const oldTurnId = TurnId.make("older-segment-turn"); + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-turn-scoped-identity"), + projectId: ProjectId.make("project-1"), + title: "Turn-scoped reasoning", + latestTurn: runningTurn, + activities: [ + thinkingActivity("old-completed", "tool.completed", 1, "shared-id", oldTurnId), + thinkingActivity("current-open", "tool.updated", 2, "shared-id"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + expect(rows.filter((row) => row.type === "work-toggle" && row.shimmer)).toMatchObject([ + { summary: "Thinking", live: true }, + ]); + expect(rows.some((row) => row.type === "thinking")).toBe(false); + }); + + it("does not reactivate an older thought after a later thought completes", () => { + const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; + const thread = makeThread({ + id: ThreadId.make("segment-terminal-boundary"), + projectId: ProjectId.make("project-1"), + title: "Terminal reasoning boundary", + latestTurn: runningTurn, + activities: [ + thinkingActivity("thought-a-open", "tool.updated", 1, "thought-a"), + thinkingActivity("thought-b-open", "tool.updated", 2, "thought-b"), + thinkingActivity("thought-b-done", "tool.completed", 3, "thought-b"), + ], + }); + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + runningTurn, + new Set([turnId]), + new Set(), + at(0), + ); + + expect(rows.some((row) => row.type === "work-toggle" && row.shimmer)).toBe(false); + expect(rows.filter((row) => row.type === "thinking")).toHaveLength(1); + }); + + it("folds settled thoughts away with their turn", () => { + const thread = makeThread({ + id: ThreadId.make("segment-fold"), + projectId: ProjectId.make("project-1"), + title: "Fold", + latestTurn: settledTurn, + activities: [ + thinkingActivity("thought-1-updated", "tool.updated", 0, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), + toolActivity("tool-1", 5), + ], + }); + const rows = deriveThreadFeedPresentation(buildThreadFeed(thread), settledTurn, new Set()); + + expect(rows.map((row) => row.type)).toEqual(["turn-fold"]); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f1550042eae8..427cd5b01e67 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -19,10 +19,15 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, extractWorkLogToolLifecycleStatus, + formatThinkingSegmentLabel, + isReasoningItemPayload, + isReasoningSegmentEntry, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, + reasoningHasVisibleText, + reasoningSegmentSpanForEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, @@ -101,6 +106,12 @@ export interface WorkLogEntry { toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; toolCallId?: string; + /** + * Reasoning segment start, preserved when lifecycle updates merge into + * their completion. Lets the feed bound "Thought for Xs" even when tool + * activity interleaves between the segment's start and end. + */ + segmentStartedAt?: string; /** * One row per workflow run or per-turn batch of direct spawns, like web's * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row @@ -193,6 +204,15 @@ export type ThreadFeedEntry = readonly createdAt: string; readonly turnId: TurnId | null; } + | { + /** Provider-supplied readable reasoning as chronological Markdown. */ + readonly type: "reasoning-markdown"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; + readonly text: string; + readonly streaming: boolean; + } | { /** * One batch of spawned subagents. Rendered as its own card because a @@ -246,6 +266,8 @@ const presentedActivityGroupsCache = new WeakMap< readonly unsettledTurnId: TurnId | null; readonly isWorking: boolean; readonly activeTail: boolean; + readonly designatedThinkingActivityId: string | null; + readonly hasLiveToolActivity: boolean; readonly rows: ReadonlyArray; } >(); @@ -494,7 +516,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ...(taskId ? { taskId } : {}), label: taskLabel || activity.summary, tone: - activity.kind === "task.progress" + activity.kind === "task.progress" || isReasoningItemPayload(payload) ? "thinking" : activity.tone === "approval" ? "info" @@ -534,9 +556,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (!taskDetailAsLabel && output) { entry.detail = output; } else if (!taskDetailAsLabel && typeof payload?.detail === "string") { - const detail = stripTrailingExitCode(payload.detail).output; + // Provider reasoning text is opaque — do not strip command exit-code suffixes. + const detail = isReasoningItemPayload(payload) + ? payload.detail + : stripTrailingExitCode(payload.detail).output; const data = asRecord(payload.data); const repeatsCommand = + !isReasoningItemPayload(payload) && detail !== null && commandDetailRepeatsCommand({ detail, @@ -829,7 +855,13 @@ function shouldCollapseToolLifecycleEntries( return false; } if (previous.sourceActivityKind === "tool.completed") { - return false; + // Allow corrective reasoning completions to refresh terminal detail. + return ( + isReasoningSegmentEntry(previous) && + isReasoningSegmentEntry(next) && + previous.toolCallId !== undefined && + previous.toolCallId === next.toolCallId + ); } if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { return true; @@ -848,7 +880,16 @@ function mergeDerivedWorkLogEntries( next: DerivedWorkLogEntry, ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); - const detail = next.detail ?? previous.detail; + // OpenCode streams inProgress reasoning detail as incremental chunks. + const reasoningMerge = isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next); + const detail = + reasoningMerge && + previous.toolLifecycleStatus === "inProgress" && + next.toolLifecycleStatus === "inProgress" && + previous.detail !== undefined && + next.detail !== undefined + ? `${previous.detail}${next.detail}` + : (next.detail ?? previous.detail); const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; @@ -862,11 +903,21 @@ function mergeDerivedWorkLogEntries( const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolData = next.toolData ?? previous.toolData; + // A reasoning completion never carries its segment's start, so keep the + // earliest observed time across the merge. Interleaved tool activity can't + // break the pairing: collapse keys on identity, not adjacency. + const segmentStartedAt = + next.segmentStartedAt ?? + previous.segmentStartedAt ?? + (reasoningMerge ? previous.createdAt : undefined); + // Reasoning pairs anchor at their end like web (chronological with the + // tools that follow); other rows keep the launch anchor so streaming + // updates never move them. Keep a stable id across reasoning merges so + // list virtualization does not remount the Markdown row on every chunk. return { ...previous, ...next, - id: previous.id, - createdAt: previous.createdAt, + ...(!reasoningMerge ? { id: previous.id, createdAt: previous.createdAt } : { id: previous.id }), ...(detail ? { detail } : {}), ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), @@ -882,6 +933,7 @@ function mergeDerivedWorkLogEntries( ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), ...(toolCallId ? { toolCallId } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(segmentStartedAt ? { segmentStartedAt } : {}), }; } @@ -1748,6 +1800,7 @@ export function deriveThreadFeedPresentation( entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking" && + entry.type !== "reasoning-markdown" && entry.type !== "agent-spawn", ); const activeTailGroup = sourceFeed.findLast( @@ -1756,6 +1809,11 @@ export function deriveThreadFeedPresentation( const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); const unsettledTurnId = deriveUnsettledTurnId(latestTurn); const isWorking = activeWorkStartedAt !== null; + // At most one thought is ever live feed-wide: the latest still-open + // thinking activity of the unsettled turn. Same-turn thoughts can sit in + // different groups (commentary splits them), so this is computed once + // here, not per group. + const thinkingLiveScope = designateLiveThinkingScope(sourceFeed, isWorking, unsettledTurnId); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedTurnIds.has(fold.turnId)) { @@ -1805,6 +1863,7 @@ export function deriveThreadFeedPresentation( unsettledTurnId, isWorking, isActiveTailGroup, + thinkingLiveScope, ); } } @@ -1844,13 +1903,95 @@ function thinkingRow(createdAt: string, turnId: TurnId | null) { return cachedThinkingRow; } +interface ThinkingLiveScope { + readonly designatedThinkingActivityId: string | null; + readonly hasLiveToolActivity: boolean; +} + +/** + * Feed-wide live-thought arbitration: at most one thought is ever live — + * the latest still-open thinking activity of the unsettled turn — and a + * live tool run pins earlier thoughts static. Same-turn thoughts can sit in + * different groups (commentary splits them), so this runs once per + * presentation, not per group. Completed siblings disqualify stale + * in-progress updates delivered out of order. + */ +function designateLiveThinkingScope( + feed: ReadonlyArray, + isWorking: boolean, + unsettledTurnId: TurnId | null, +): ThinkingLiveScope { + if (!isWorking || unsettledTurnId === null) { + return { designatedThinkingActivityId: null, hasLiveToolActivity: false }; + } + const terminalReasoningIds = new Set(); + let designatedThinkingActivityId: string | null = null; + let hasLiveToolActivity = false; + for (const entry of feed) { + if (entry.type !== "activity-group") continue; + for (const activity of entry.activities) { + const workEntry = activity.workEntry; + if (isReasoningSegmentEntry(workEntry)) { + if ( + activity.turnId === unsettledTurnId && + workEntry.toolLifecycleStatus !== undefined && + workEntry.toolLifecycleStatus !== "inProgress" && + workEntry.toolCallId !== undefined + ) { + terminalReasoningIds.add(workEntry.toolCallId); + } + } + } + } + for (const entry of feed) { + if (entry.type !== "activity-group") continue; + for (const activity of entry.activities) { + const workEntry = activity.workEntry; + if (activity.turnId !== unsettledTurnId) { + continue; + } + if ( + isReasoningSegmentEntry(workEntry) && + activity.lifecycleStatus !== undefined && + activity.lifecycleStatus !== "inProgress" + ) { + designatedThinkingActivityId = null; + hasLiveToolActivity = false; + continue; + } + if (activity.lifecycleStatus !== "inProgress") continue; + if (!isReasoningSegmentEntry(workEntry)) { + hasLiveToolActivity = true; + continue; + } + if (workEntry.toolCallId !== undefined && terminalReasoningIds.has(workEntry.toolCallId)) { + continue; + } + designatedThinkingActivityId = activity.id; + hasLiveToolActivity = false; + } + } + return { designatedThinkingActivityId, hasLiveToolActivity }; +} + function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude< + ThreadFeedEntry, + { + readonly type: + | "turn-fold" + | "work-toggle" + | "thinking" + | "reasoning-markdown" + | "agent-spawn"; + } + >, expandedWorkGroupIds: ReadonlySet, unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + thinkingLiveScope: ThinkingLiveScope, ): void { if (entry.type !== "activity-group") { result.push(entry); @@ -1867,6 +2008,8 @@ function appendPresentedFeedEntry( cached.unsettledTurnId !== unsettledTurnId || cached.isWorking !== isWorking || cached.activeTail !== activeTail || + cached.designatedThinkingActivityId !== thinkingLiveScope.designatedThinkingActivityId || + cached.hasLiveToolActivity !== thinkingLiveScope.hasLiveToolActivity || cached.rows.some( (row) => (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) || @@ -1881,8 +2024,16 @@ function appendPresentedFeedEntry( unsettledTurnId, isWorking, activeTail, + thinkingLiveScope, ); - cached = { unsettledTurnId, isWorking, activeTail, rows }; + cached = { + unsettledTurnId, + isWorking, + activeTail, + designatedThinkingActivityId: thinkingLiveScope.designatedThinkingActivityId, + hasLiveToolActivity: thinkingLiveScope.hasLiveToolActivity, + rows, + }; presentedActivityGroupsCache.set(entry, cached); } for (const row of cached.rows) { @@ -1897,11 +2048,15 @@ function appendActivityGroupRows( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + thinkingLiveScope: ThinkingLiveScope, ): void { const activities = omitSupersededLifecycleMarkers( entry.activities.filter( (activity) => !(activity.toolLike && activity.status === "neutral") || + // Reasoning segments are structural boundaries, not tool output: + // completed thoughts render their duration, live ones shimmer. + isReasoningSegmentEntry(activity.workEntry) || (isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId), @@ -1911,6 +2066,10 @@ function appendActivityGroupRows( if (activities.length === 0) { return; } + // Live-thought arbitration is feed-wide (see designateLiveThinkingScope): + // same-turn thoughts can sit in different groups, so a per-group latest + // would animate twice. thinkingLiveScope carries the single designation. + const { designatedThinkingActivityId, hasLiveToolActivity } = thinkingLiveScope; let groupableRun: ThreadFeedActivity[] = []; const flushGroupableRun = (isTrailingRun: boolean) => { if (groupableRun.length === 0) return; @@ -1922,12 +2081,30 @@ function appendActivityGroupRows( unsettledTurnId, isWorking, activeTail && isTrailingRun, + groupableRun.every((activity) => isReasoningSegmentEntry(activity.workEntry)) + ? { designatedThinkingActivityId, hasLiveToolActivity } + : undefined, ); groupableRun = []; }; for (const activity of activities) { const spawn = activity.workEntry.agentSpawn; if (activity.workEntry.tone !== "error" && spawn === undefined) { + // A thinking segment ends the tool run before it so thought and + // action stay in separate compact rows (mirrors web grouping). + // Distinct thoughts split too, so a superseded thought renders + // statically beside the live one instead of hiding inside it. + const reasoning = isReasoningSegmentEntry(activity.workEntry); + if (groupableRun.length > 0) { + const previous = groupableRun.at(-1)!.workEntry; + const previousReasoning = isReasoningSegmentEntry(previous); + if ( + previousReasoning !== reasoning || + (reasoning && previous.toolCallId !== activity.workEntry.toolCallId) + ) { + flushGroupableRun(false); + } + } groupableRun.push(activity); continue; } @@ -1966,6 +2143,7 @@ function appendToolGroupRows( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + thinkingLive?: { designatedThinkingActivityId: string | null; hasLiveToolActivity: boolean }, ): void { const firstEntry = activities[0]!.workEntry; const identity = firstEntry.toolCallId @@ -1973,6 +2151,10 @@ function appendToolGroupRows( : activities[0]!.id; const groupId = `work-group:${identity}`; const expanded = expandedWorkGroupIds.has(groupId); + if (thinkingLive !== undefined) { + appendThinkingSegmentRows(result, sourceGroup, activities, groupId, expanded, thinkingLive); + return; + } const latestActiveActivity = activities.findLast( (activity) => isWorking && @@ -2069,6 +2251,78 @@ function appendToolGroupRows( }); } +/** + * Settled thinking renders one compact row per thought ("Thought for 4s", + * or a static "Thought" without accurate timing); only the designated live + * thought shimmers in the turn's live slot like a running tool. + */ +function appendThinkingSegmentRows( + result: ThreadFeedEntry[], + sourceGroup: Extract, + activities: ReadonlyArray, + groupId: string, + expanded: boolean, + thinkingLive: { designatedThinkingActivityId: string | null; hasLiveToolActivity: boolean }, +): void { + for (const activity of activities) { + const entry = activity.workEntry; + const span = reasoningSegmentSpanForEntry(entry); + const live = + activity.id === thinkingLive.designatedThinkingActivityId && + !thinkingLive.hasLiveToolActivity; + const shimmer = live; + const text = entry.detail?.trim() ?? ""; + if (reasoningHasVisibleText(entry) && text.length > 0) { + // Stable across streaming lifecycle merges: turn + reasoning identity. + const reasoningIdentity = + entry.toolCallId !== undefined + ? `${entry.turnId ?? sourceGroup.turnId ?? groupId}:${entry.toolCallId}` + : `${groupId}:${activity.id}`; + result.push({ + type: "reasoning-markdown", + id: `reasoning-markdown:${reasoningIdentity}`, + createdAt: span.startedAt ?? activity.createdAt, + turnId: sourceGroup.turnId, + text, + streaming: live, + }); + continue; + } + result.push({ + type: "work-toggle", + // The shimmering row is the turn's live slot; it keeps that identity + // until "Thinking" takes the slot (mirrors the tool live row). + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `work-toggle:${groupId}:${activity.id}`, + createdAt: span.startedAt ?? activity.createdAt, + turnId: sourceGroup.turnId, + groupId, + hiddenCount: 1, + expanded, + summary: live ? "Thinking" : formatThinkingSegmentLabel(span), + summaryKind: toolGroupSummaryKind([entry]), + hasFailure: false, + live, + shimmer, + }); + if (!expanded) { + continue; + } + result.push({ + type: "activity-group", + id: `work-details:${groupId}:${activity.id}`, + createdAt: activity.createdAt, + turnId: activity.turnId, + activities: [ + { + ...activity, + groupedToolDetail: true, + live, + }, + ], + }); + } +} + function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense); const presentation = resolveWorkEntryToolPresentation({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index e6468ff8f789..ce109544f833 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; import type { OrchestrationThreadActivity } from "@t3tools/contracts"; -import { projectActivityPayload } from "./ActivityPayloadProjection.ts"; +import { + projectActivityPayload, + projectThreadDetailSnapshot, +} from "./ActivityPayloadProjection.ts"; function activity(payload: Record): OrchestrationThreadActivity { return { @@ -344,3 +347,202 @@ describe("projectActivityPayload", () => { expect(projected.payload).toEqual(source.payload); }); }); + +describe("projectThreadDetailSnapshot reasoning retention", () => { + const lifecycleActivity = ( + id: string, + kind: "tool.updated" | "tool.completed", + itemType: string, + toolCallId: string, + createdAt: string, + ): OrchestrationThreadActivity => + ({ + id, + tone: "tool", + kind, + summary: itemType === "reasoning" ? "Thinking" : "Render", + payload: { + itemType, + toolCallId, + status: kind === "tool.completed" ? "completed" : "inProgress", + title: itemType === "reasoning" ? "Thinking" : "Render", + }, + turnId: "turn-1", + createdAt, + }) as unknown as OrchestrationThreadActivity; + + it("keeps the reasoning start update that completion would otherwise supersede", () => { + const snapshot = { + snapshotSequence: 0, + thread: { + activities: [ + lifecycleActivity("reasoning-updated", "tool.updated", "reasoning", "reasoning-1", "t1"), + lifecycleActivity("tool-updated", "tool.updated", "command_execution", "tool-1", "t2"), + lifecycleActivity( + "tool-completed", + "tool.completed", + "command_execution", + "tool-1", + "t3", + ), + lifecycleActivity( + "reasoning-completed", + "tool.completed", + "reasoning", + "reasoning-1", + "t4", + ), + ], + }, + } as unknown as Parameters[0]; + + const projected = projectThreadDetailSnapshot(snapshot); + // The ordinary tool update is slimmed away, but the reasoning start update + // must survive reloads: clients bound segment durations from it. + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + "reasoning-updated", + "tool-completed", + "reasoning-completed", + ]); + }); + + it("drops intermediate reasoning text copies while keeping start timing and final text", () => { + // Incremental chunks (the live/write path), not cumulative prefixes. + const chunks = Array.from({ length: 40 }, (_, index) => `word${index} `); + const finalText = chunks.join(""); + expect(finalText.length).toBeGreaterThan(180); + + const activities: OrchestrationThreadActivity[] = chunks.map((detail, index) => { + const row = lifecycleActivity( + `reasoning-updated-${index}`, + "tool.updated", + "reasoning", + "reasoning-stream", + `t${String(index).padStart(2, "0")}`, + ); + return { + ...row, + payload: { + ...(row.payload as Record), + detail, + }, + } as OrchestrationThreadActivity; + }); + // Live/persist path proof: 40 incremental chunks stay O(N) bytes total, + // not O(N²) cumulative prefixes. + const persistedDetailBytes = activities.reduce((total, activity) => { + const detail = (activity.payload as Record).detail; + return total + (typeof detail === "string" ? detail.length : 0); + }, 0); + expect(persistedDetailBytes).toBe(finalText.length); + expect(persistedDetailBytes).toBeLessThan(finalText.length * 2); + + activities.push({ + ...lifecycleActivity( + "reasoning-completed", + "tool.completed", + "reasoning", + "reasoning-stream", + "t99", + ), + payload: { + itemType: "reasoning", + toolCallId: "reasoning-stream", + status: "completed", + title: "Thinking", + detail: finalText, + }, + } as OrchestrationThreadActivity); + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 0, + thread: { activities }, + } as unknown as Parameters[0]); + + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + "reasoning-updated-0", + "reasoning-completed", + ]); + const start = projected.thread.activities[0]!; + const completed = projected.thread.activities[1]!; + expect((start.payload as Record).detail).toBeUndefined(); + expect((completed.payload as Record).detail).toBe(finalText); + // Snapshot stores O(1) bodies, not every streaming chunk. + const detailBytes = projected.thread.activities.reduce((total, activity) => { + const detail = (activity.payload as Record).detail; + return total + (typeof detail === "string" ? detail.length : 0); + }, 0); + expect(detailBytes).toBe(finalText.length); + }); + + it("keeps first and latest in-flight reasoning updates across distinct createdAt values", () => { + const chunks = ["partial-", "one-", "two-", "three-", "four"]; + const activities = chunks.map((detail, index) => { + const n = index + 1; + const row = lifecycleActivity( + `reasoning-updated-${n}`, + "tool.updated", + "reasoning", + "reasoning-live", + `2026-08-01T10:00:0${n}.000Z`, + ); + return { + ...row, + payload: { + ...(row.payload as Record), + detail, + }, + } as OrchestrationThreadActivity; + }); + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 0, + thread: { activities }, + } as unknown as Parameters[0]); + + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + "reasoning-updated-1", + "reasoning-updated-5", + ]); + expect(projected.thread.activities).toHaveLength(2); + expect(projected.thread.activities[0]?.createdAt).toBe("2026-08-01T10:00:01.000Z"); + expect( + (projected.thread.activities[0]?.payload as Record).detail, + ).toBeUndefined(); + expect(projected.thread.activities[1]?.createdAt).toBe("2026-08-01T10:00:05.000Z"); + // Latest retained row reconstructs the full concatenated body from chunks. + expect((projected.thread.activities[1]?.payload as Record).detail).toBe( + chunks.join(""), + ); + }); + + it("keeps a single in-flight reasoning update when first and latest are the same row", () => { + const row = lifecycleActivity( + "reasoning-updated-only", + "tool.updated", + "reasoning", + "reasoning-live", + "2026-08-01T10:00:01.000Z", + ); + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 0, + thread: { + activities: [ + { + ...row, + payload: { + ...(row.payload as Record), + detail: "only-partial", + }, + } as OrchestrationThreadActivity, + ], + }, + } as unknown as Parameters[0]); + + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + "reasoning-updated-only", + ]); + expect((projected.thread.activities[0]?.payload as Record).detail).toBe( + "only-partial", + ); + }); +}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0525aae7b72b..09f861444c2d 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -609,37 +609,108 @@ function dropSupersededToolUpdatedActivities( activities: ReadonlyArray, ): ReadonlyArray { const completionIndicesByKey = new Map(); + const updateIndicesByKey = new Map(); for (let index = 0; index < activities.length; index += 1) { const activity = activities[index]!; - if (activity.kind !== "tool.completed") { - continue; - } const identity = toolLifecycleIdentity(activity); if (!identity) { continue; } const key = `${activity.turnId ?? ""}\u0000${identity}`; - const indices = completionIndicesByKey.get(key); + if (activity.kind === "tool.completed") { + const indices = completionIndicesByKey.get(key); + if (indices) { + indices.push(index); + } else { + completionIndicesByKey.set(key, [index]); + } + continue; + } + if (activity.kind !== "tool.updated") { + continue; + } + const indices = updateIndicesByKey.get(key); if (indices) { indices.push(index); } else { - completionIndicesByKey.set(key, [index]); + updateIndicesByKey.set(key, [index]); } } - if (completionIndicesByKey.size === 0) { + if (completionIndicesByKey.size === 0 && updateIndicesByKey.size === 0) { return activities; } - return activities.filter((activity, index) => { + // Completed thoughts: first update (start timing, no partial detail) + + // completion (final text). In-flight thoughts: first update (start timing, + // no partial detail) + latest update with reconstructed full text (OpenCode + // streams incremental detail chunks). If first === latest, keep that single + // update with its detail. Intermediate streaming updates are dropped from + // snapshots (live clients already received them as appends). + const retainedReasoningUpdateIndices = new Set(); + const stripDetailFromReasoningUpdateIndices = new Set(); + const reconstructedReasoningDetailByIndex = new Map(); + for (const [key, updateIndices] of updateIndicesByKey) { + const firstUpdate = updateIndices[0]; + const lastUpdate = updateIndices[updateIndices.length - 1]; + if (firstUpdate === undefined || lastUpdate === undefined) { + continue; + } + const payload = asRecord(activities[firstUpdate]?.payload); + if (payload?.itemType !== "reasoning") { + continue; + } + const hasLaterCompletion = + completionIndicesByKey.get(key)?.some((index) => index > firstUpdate) ?? false; + retainedReasoningUpdateIndices.add(firstUpdate); + if (hasLaterCompletion) { + stripDetailFromReasoningUpdateIndices.add(firstUpdate); + continue; + } + if (lastUpdate !== firstUpdate) { + retainedReasoningUpdateIndices.add(lastUpdate); + stripDetailFromReasoningUpdateIndices.add(firstUpdate); + const reconstructed = updateIndices + .map((updateIndex) => { + const detail = asRecord(activities[updateIndex]?.payload)?.detail; + return typeof detail === "string" ? detail : ""; + }) + .join(""); + if (reconstructed.trim().length > 0) { + reconstructedReasoningDetailByIndex.set(lastUpdate, reconstructed); + } + } + } + + return activities.flatMap((activity, index) => { if (activity.kind !== "tool.updated") { - return true; + return [activity]; } const identity = toolLifecycleIdentity(activity); if (!identity) { - return true; + return [activity]; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); - return !indices?.some((completionIndex) => completionIndex > index); + const key = `${activity.turnId ?? ""}\u0000${identity}`; + if (asRecord(activity.payload)?.itemType === "reasoning") { + if (!retainedReasoningUpdateIndices.has(index)) { + return []; + } + const reconstructed = reconstructedReasoningDetailByIndex.get(index); + if (reconstructed !== undefined) { + const payload = asRecord(activity.payload) ?? {}; + return [{ ...activity, payload: { ...payload, detail: reconstructed } }]; + } + if (!stripDetailFromReasoningUpdateIndices.has(index)) { + return [activity]; + } + const payload = asRecord(activity.payload); + if (!payload || payload.detail === undefined) { + return [activity]; + } + const { detail: _detail, ...rest } = payload; + return [{ ...activity, payload: rest }]; + } + const indices = completionIndicesByKey.get(key); + return indices?.some((completionIndex) => completionIndex > index) ? [] : [activity]; }); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts index 27564eb572ca..62b039bb0d3b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts @@ -1,6 +1,7 @@ import { EventId, ProviderDriverKind, + RuntimeItemId, RuntimeTaskId, ThreadId, type ProviderRuntimeEvent, @@ -142,3 +143,143 @@ describe("runtimeEventToActivities tool streaming persistence", () => { expect(payload.data).toEqual(streamingData); }); }); + +describe("runtimeEventToActivities reasoning lifecycle", () => { + const reasoningUpdated = { + ...base, + provider: ProviderDriverKind.make("opencode"), + type: "item.updated", + eventId: EventId.make("evt-reasoning-updated"), + itemId: RuntimeItemId.make("reasoning-part-1"), + createdAt: "2026-08-06T00:00:01.000Z", + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Thinking", + }, + } satisfies ProviderRuntimeEvent; + + it("projects reasoning updates as thinking tool activity without text", () => { + const activities = runtimeEventToActivities(reasoningUpdated); + + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + tone: "tool", + kind: "tool.updated", + summary: "Thinking", + }); + const payload = activities[0]?.payload as Record; + expect(payload.itemType).toBe("reasoning"); + expect(payload.toolCallId).toBe("reasoning-part-1"); + expect(payload.status).toBe("inProgress"); + expect(payload.title).toBe("Thinking"); + expect(payload).not.toHaveProperty("detail"); + }); + + it("preserves provider reasoning text on lifecycle detail without truncating it", () => { + const longDetail = `${"The user wants to know their opencode version. ".repeat(8)}I should run the command.`; + expect(longDetail.length).toBeGreaterThan(180); + + const updated = runtimeEventToActivities({ + ...reasoningUpdated, + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Thinking", + detail: longDetail, + }, + }); + expect(updated[0]?.payload).toMatchObject({ + itemType: "reasoning", + detail: longDetail, + }); + + const completed = runtimeEventToActivities({ + ...reasoningUpdated, + type: "item.completed", + eventId: EventId.make("evt-reasoning-completed-text"), + createdAt: "2026-08-06T00:00:05.000Z", + payload: { + itemType: "reasoning", + status: "completed", + title: "Thinking", + detail: longDetail, + }, + }); + expect(completed[0]?.payload).toMatchObject({ + itemType: "reasoning", + status: "completed", + detail: longDetail, + }); + }); + + it("projects reasoning completions with terminal status", () => { + const activities = runtimeEventToActivities({ + ...reasoningUpdated, + type: "item.completed", + eventId: EventId.make("evt-reasoning-completed"), + createdAt: "2026-08-06T00:00:05.000Z", + payload: { itemType: "reasoning", status: "completed", title: "Thinking" }, + }); + + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + tone: "tool", + kind: "tool.completed", + summary: "Thinking", + createdAt: "2026-08-06T00:00:05.000Z", + }); + const payload = activities[0]?.payload as Record; + expect(payload.itemType).toBe("reasoning"); + expect(payload.toolCallId).toBe("reasoning-part-1"); + expect(payload.status).toBe("completed"); + expect(payload).not.toHaveProperty("detail"); + }); + + it("still drops reasoning starts and unrelated item types", () => { + expect( + runtimeEventToActivities({ + ...reasoningUpdated, + type: "item.started", + eventId: EventId.make("evt-reasoning-started"), + }), + ).toEqual([]); + for (const itemType of ["plan", "assistant_message", "unknown"] as const) { + expect( + runtimeEventToActivities({ + ...reasoningUpdated, + type: "item.completed", + eventId: EventId.make(`evt-other-${itemType}`), + payload: { itemType, status: "completed", title: "Other" }, + }), + ).toEqual([]); + } + }); + + it("drops status-less Codex-style reasoning updates so timelines stay unchanged", () => { + // Codex summaryPartAdded emits itemType reasoning without lifecycle status. + expect( + runtimeEventToActivities({ + ...reasoningUpdated, + provider: ProviderDriverKind.make("codex"), + eventId: EventId.make("evt-codex-reasoning"), + payload: { + itemType: "reasoning", + data: { text: "summary part" }, + }, + }), + ).toEqual([]); + expect( + runtimeEventToActivities({ + ...reasoningUpdated, + provider: ProviderDriverKind.make("codex"), + type: "item.completed", + eventId: EventId.make("evt-codex-reasoning-completed"), + payload: { + itemType: "reasoning", + data: { text: "summary part" }, + }, + }), + ).toEqual([]); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d7ae589047bf..351eab77ae6c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -848,7 +848,17 @@ export function runtimeEventToActivities( } case "item.updated": { - if (!isToolLifecycleItemType(event.payload.itemType)) { + // Reasoning items project like tools so clients can render thinking + // segments as activity boundaries. Require lifecycle status so status-less + // provider updates (e.g. Codex summaryPartAdded) do not become stray + // Thought rows. When an adapter supplies provider reasoning text on + // lifecycle `detail`, preserve it (content.delta reasoning_text is still + // dropped above; detail is the carrier). OpenCode streams growth as + // incremental detail chunks; clients concatenate inProgress updates. + if ( + !isToolLifecycleItemType(event.payload.itemType) && + !(event.payload.itemType === "reasoning" && event.payload.status !== undefined) + ) { return []; } // A streaming update's `data` carries the full tool output accumulated @@ -858,6 +868,13 @@ export function runtimeEventToActivities( // needs it: ws.ts and http.ts apply `projectActivityPayload` before any // payload reaches a client. Persist the projected form for non-terminal // updates; `item.completed` below still persists the full payload. + // Reasoning detail is the visible thought body — do not truncate it. + const updatedDetail = + event.payload.detail === undefined + ? undefined + : event.payload.itemType === "reasoning" + ? event.payload.detail + : truncateDetail(event.payload.detail); return [ projectActivityPayload({ id: event.eventId, @@ -870,7 +887,7 @@ export function runtimeEventToActivities( ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.title ? { title: event.payload.title } : {}), - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(updatedDetail !== undefined ? { detail: updatedDetail } : {}), ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), @@ -887,9 +904,20 @@ export function runtimeEventToActivities( } case "item.completed": { - if (!isToolLifecycleItemType(event.payload.itemType)) { + // See item.updated above: reasoning lifecycle becomes thinking activity. + // Require status so only true lifecycle completions project. + if ( + !isToolLifecycleItemType(event.payload.itemType) && + !(event.payload.itemType === "reasoning" && event.payload.status !== undefined) + ) { return []; } + const completedDetail = + event.payload.detail === undefined + ? undefined + : event.payload.itemType === "reasoning" + ? event.payload.detail + : truncateDetail(event.payload.detail); return [ { id: event.eventId, @@ -902,7 +930,7 @@ export function runtimeEventToActivities( ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.title ? { title: event.payload.title } : {}), - ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(completedDetail !== undefined ? { detail: completedDetail } : {}), ...(event.payload.toolSurface ? { toolSurface: event.payload.toolSurface } : {}), ...(event.payload.toolIcon ? { toolIcon: event.payload.toolIcon } : {}), ...(event.payload.toolSource ? { toolSource: event.payload.toolSource } : {}), diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts index 440356ef933a..e23aecae6ab9 100644 --- a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -29,21 +29,27 @@ function makeToolActivity( readonly kind?: "tool.updated" | "tool.completed"; readonly toolCallId?: string; readonly turnId?: TurnId; + readonly itemType?: string; + readonly detail?: string; } = {}, ): OrchestrationEvent { const { kind = "tool.updated", toolCallId = "call-edit", turnId: activityTurnId = turnId, + itemType = "file_change", + detail, } = options; const activity: OrchestrationThreadActivity = { id: EventId.make(`activity-${sequence}`), tone: "tool", kind, - summary: "Editing app.ts", + summary: itemType === "reasoning" ? "Thinking" : "Editing app.ts", payload: { - itemType: "file_change", - title: "Editing app.ts", + itemType, + title: itemType === "reasoning" ? "Thinking" : "Editing app.ts", + ...(toolCallId ? { toolCallId } : {}), + ...(detail !== undefined ? { detail } : {}), data: toolCallId ? { toolCallId } : {}, }, turnId: activityTurnId, @@ -100,6 +106,28 @@ describe("ThreadLiveEventCoalescer", () => { expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); }); + it("does not drop incremental reasoning detail chunks for the same toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "reasoning-1", itemType: "reasoning", detail: "Start" }), + makeToolActivity(2, { toolCallId: "reasoning-1", itemType: "reasoning", detail: " middle" }), + makeToolActivity(3, { toolCallId: "reasoning-1", itemType: "reasoning", detail: " end" }), + makeToolActivity(4, { toolCallId: "call-a" }), + makeToolActivity(5, { toolCallId: "call-a" }), + ]; + + const coalesced = coalesceLiveToolUpdatedEvents(events); + expect(coalesced.map((event) => event.sequence)).toEqual([1, 2, 3, 5]); + expect( + coalesced + .filter((event) => event.type === "thread.activity-appended") + .map((event) => + event.type === "thread.activity-appended" + ? (event.payload.activity.payload as { detail?: string }).detail + : undefined, + ), + ).toEqual(["Start", " middle", " end", undefined]); + }); + it("preserves parallel same-label calls without a stable toolCallId", () => { const events = [ makeToolActivity(1, { toolCallId: "" }), diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts index 2831746d3e3d..8c84d7ddcadb 100644 --- a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -48,10 +48,20 @@ function stableToolCallIdentity(event: OrchestrationEvent): string | null { return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); } +function isReasoningToolUpdated(event: OrchestrationEvent): boolean { + if (event.type !== "thread.activity-appended" || event.payload.activity.kind !== "tool.updated") { + return false; + } + const payload = event.payload.activity.payload; + return Predicate.isObject(payload) && payload.itemType === "reasoning"; +} + /** * Retain only the latest in-flight update for each stable tool-call id in a * live run. Anonymous calls pass through because labels are not unique when - * tools execute in parallel. Survivors remain in sequence order. + * tools execute in parallel. Reasoning updates also pass through: OpenCode + * streams incremental detail chunks that clients concatenate, so dropping + * intermediates would lose visible text. Survivors remain in sequence order. */ export function coalesceLiveToolUpdatedEvents( events: ReadonlyArray, @@ -64,6 +74,10 @@ export function coalesceLiveToolUpdatedEvents( const latestUpdates: Array = []; for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { const event = pendingUpdates[index]!; + if (isReasoningToolUpdated(event)) { + latestUpdates.push(event); + continue; + } const identity = stableToolCallIdentity(event); const activity = event.type === "thread.activity-appended" ? event.payload.activity : undefined; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index a6636fcd69d0..b979af04c19a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -30,6 +30,7 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, + type ProviderRuntimeEvent, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; @@ -630,6 +631,14 @@ function makeOpenCodeEventQueue() { }; } +const isItemLifecycleForTest = ( + event: ProviderRuntimeEvent, +): event is Extract< + ProviderRuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" } +> => + event.type === "item.updated" || event.type === "item.completed" || event.type === "item.started"; + const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({ id, sessionID, @@ -7116,72 +7125,111 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.deepEqual( events .filter((event) => event.type === "item.completed") - .map((event) => event.payload.detail), - ["Hello world", "Fresh", "Second", "New"], + .map((event) => [event.payload.itemType, event.payload.detail]), + [ + ["reasoning", "Thinking"], + ["assistant_message", "Hello world"], + ["reasoning", "Thinking more"], + ["assistant_message", "Fresh"], + ["assistant_message", "Second"], + ["reasoning", "New thoughts"], + ["assistant_message", "New"], + ], ); + // Reasoning parts project lifecycle boundaries and carry provider text + // on detail when present. This reconnect fixture stamps native end on + // every snapshot, so each part sighting is one in-progress update plus + // an immediate completion. A later edit ("Thinking more") refreshes the + // terminal detail without reopening the segment as live. + const reasoningUpdates = events + .filter((event) => event.type === "item.updated") + .filter((event) => event.payload.itemType === "reasoning"); + NodeAssert.equal(reasoningUpdates.length, 2); + NodeAssert.deepEqual( + reasoningUpdates.map((event) => event.payload.detail), + ["Thinking", "New thoughts"], + ); + for (const update of reasoningUpdates) { + NodeAssert.equal(update.itemId, "reasoning-part"); + NodeAssert.equal(update.payload.status, "inProgress"); + NodeAssert.equal(update.payload.title, "Thinking"); + NodeAssert.equal(update.createdAt, "1970-01-01T00:00:00.001Z"); + } + const reasoningCompletions = events + .filter((event) => event.type === "item.completed") + .filter((event) => event.payload.itemType === "reasoning"); + NodeAssert.deepEqual( + reasoningCompletions.map((event) => event.payload.detail), + ["Thinking", "Thinking more", "New thoughts"], + ); + for (const completed of reasoningCompletions) { + NodeAssert.equal(completed.itemId, "reasoning-part"); + NodeAssert.equal(completed.payload.status, "completed"); + NodeAssert.equal(completed.payload.title, "Thinking"); + NodeAssert.equal(completed.createdAt, "1970-01-01T00:00:00.002Z"); + } yield* adapter.stopSession(threadId); - }), + }).pipe(Effect.scoped), ); - it.effect("maps native task progress only while a turn is active", () => + it.effect("emits reasoning segment boundaries for empty reasoning text around a tool", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-native-progress"); + const threadId = asThreadId("thread-opencode-empty-reasoning"); const sessionID = "http://127.0.0.1:9999/session"; - const startProgress = promiseWithResolvers(); - const finishTurn = promiseWithResolvers(); - const lateProgress = promiseWithResolvers(); - const todos = [ - { content: "Read files", status: "completed", priority: "high" }, - { content: "Fix OpenCode", status: "in_progress", priority: "high" }, - { content: "Run tests", status: "pending", priority: "medium" }, - { content: "Old task", status: "cancelled", priority: "low" }, - ]; - const todoEvent = { - id: "evt-todos", - type: "todo.updated", - properties: { sessionID, todos }, - } satisfies OpenCodeEvent; - runtimeMock.state.subscribedEvents = [ - startProgress.promise, - ...["todowrite", "bash"].map( - (tool) => - ({ - id: `evt-${tool}`, - type: "message.part.updated", - properties: { - sessionID, - time: 2, - part: { - id: `part-${tool}`, - sessionID, - messageID: "msg-tools", - type: "tool", - callID: `call-${tool}`, - tool, - state: { - status: "completed", - input: tool === "bash" ? { command: "pwd" } : { todos }, - output: tool === "bash" ? "/repo\n" : "Tasks updated", - title: tool === "bash" ? "Working directory" : "Tasks updated", + const messageID = "empty-reasoning-message"; + const reasoningPart = (id: string, text: string, time: { start: number; end?: number }) => ({ + type: "message.part.updated", + properties: { + sessionID, + part: { id, sessionID, messageID, type: "reasoning", text, time }, + }, + }); + const toolPart = (status: "running" | "completed") => ({ + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-bash", + sessionID, + messageID, + type: "tool", + callID: "call-bash", + tool: "bash", + state: { + status, + input: { command: "pwd" }, + ...(status === "running" + ? { title: "Working directory", time: { start: 150 } } + : { + output: "/repo\n", + title: "Working directory", metadata: {}, - time: { start: 1, end: 2 }, - }, - }, - }, - }) satisfies OpenCodeEvent, - ), - finishTurn.promise, - lateProgress.promise, - { id: "evt-progress-drained", type: "session.compacted", properties: { sessionID } }, + time: { start: 150, end: 180 }, + }), + }, + }, + }, + }); + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }, + // MiMo-style models report reasoning parts with no readable text. + reasoningPart("reasoning-1", "", { start: 100 }), + toolPart("running"), + toolPart("completed"), + reasoningPart("reasoning-1", "", { start: 100, end: 200 }), + reasoningPart("reasoning-2", "", { start: 300 }), + { type: "session.compacted", properties: { sessionID } }, ]; const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter( - (event) => - event.threadId === threadId && - (event.type === "turn.plan.updated" || event.type === "item.completed"), - ), - Stream.take(3), + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), Stream.runCollect, Effect.forkChild, ); @@ -7190,115 +7238,878 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { threadId, runtimeMode: "full-access", }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "Work through the task list", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode"), - "opencode/kimi-k3", - ), - }); - startProgress.resolve(todoEvent); + const events = yield* Fiber.join(eventsFiber); - const plan = events.find((event) => event.type === "turn.plan.updated"); - NodeAssert.equal(plan?.turnId, turn.turnId); - NodeAssert.deepEqual(plan?.payload.plan, [ - { step: "Read files", status: "completed" }, - { step: "Fix OpenCode", status: "inProgress" }, - { step: "Run tests", status: "pending" }, - ]); - const tools = events.filter((event) => event.type === "item.completed"); - NodeAssert.equal(tools[0]?.payload.itemType, "dynamic_tool_call"); - NodeAssert.equal(tools[1]?.payload.title, "Working directory"); - NodeAssert.partialDeepStrictEqual(tools[1]?.payload.data, { - command: "pwd", - result: "/repo\n", - }); - const completedFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), - Stream.runHead, - Effect.forkChild, + const lifecycle = events.filter(isItemLifecycleForTest); + // The running tool finalizes the open thought before its own + // lifecycle lands, so the tool supersedes the thought in event order. + NodeAssert.deepEqual( + lifecycle.map((event) => [ + event.type, + event.itemId, + event.payload.itemType, + event.payload.status, + ]), + [ + ["item.updated", "reasoning-1", "reasoning", "inProgress"], + ["item.completed", "reasoning-1", "reasoning", "completed"], + ["item.updated", "call-bash", "command_execution", "inProgress"], + ["item.completed", "call-bash", "command_execution", "completed"], + ["item.updated", "reasoning-2", "reasoning", "inProgress"], + ], ); - finishTurn.resolve({ - id: "evt-progress-completed", - type: "session.status", - properties: { sessionID, status: { type: "idle" } }, - }); - yield* Fiber.join(completedFiber); - const lateEventsFiber = yield* adapter.streamEvents.pipe( + // Structural boundaries only: no reasoning text is fabricated into + // lifecycle events, and empty reasoning emits no text deltas either. + for (const event of lifecycle.filter((event) => event.payload.itemType === "reasoning")) { + NodeAssert.equal(event.payload.title, "Thinking"); + NodeAssert.equal(event.payload.detail, undefined); + } + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => [event.payload.streamKind, event.payload.delta]), + [], + ); + const firstReasoning = lifecycle[0]!; + const reasoningCompletion = lifecycle[1]!; + NodeAssert.equal(firstReasoning.createdAt, "1970-01-01T00:00:00.100Z"); + // Eager finalization stamps the observation time: the thought ended + // when the tool started, before any native end arrived. + NodeAssert.equal(typeof reasoningCompletion.createdAt, "string"); + const stoppedEventsFiber = yield* adapter.streamEvents.pipe( Stream.filter((event) => event.threadId === threadId), - Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.takeUntil((event) => event.type === "session.exited"), Stream.runCollect, Effect.forkChild, ); - lateProgress.resolve({ ...todoEvent, id: "evt-late-todos" }); + yield* adapter.stopSession(threadId); + const stoppedEvents = Array.from(yield* Fiber.join(stoppedEventsFiber)); NodeAssert.deepEqual( - (yield* Fiber.join(lateEventsFiber)).map((event) => event.type), - ["thread.state.changed"], + stoppedEvents.map((event) => event.type), + ["item.completed", "session.exited"], ); - yield* adapter.stopSession(threadId); - }), + NodeAssert.equal( + stoppedEvents[0]?.type === "item.completed" && stoppedEvents[0].itemId, + "reasoning-2", + ); + }).pipe(Effect.scoped), ); - it.effect("warns on disconnection and recovers a completion missed during reconnect", () => + it.effect("preserves readable OpenCode reasoning text on lifecycle detail", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-reconnect-completion"); - const reconnect = promiseWithResolvers(); - runtimeMock.state.subscribedEvents = [reconnect.promise]; - runtimeMock.state.sessionStatus = "busy"; + const threadId = asThreadId("thread-opencode-readable-reasoning"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "readable-reasoning-message"; + const finalText = + "The user wants to know their opencode version. I should run the command to check."; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-deepseek", + sessionID, + messageID, + type: "reasoning", + text: "The user wants to know their opencode version.", + time: { start: 100 }, + }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-deepseek", + sessionID, + messageID, + type: "reasoning", + text: finalText, + time: { start: 100 }, + }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-deepseek", + sessionID, + messageID, + type: "reasoning", + text: finalText, + time: { start: 100, end: 250 }, + }, + }, + }, + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId, runtimeMode: "full-access", }); - const turn = yield* adapter.sendTurn({ - threadId, - input: "Work", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode"), - "opencode/kimi-k3", - ), - }); - const warningFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), - Stream.runHead, - Effect.forkChild, - ); - runtimeMock.state.eventStreamError?.(new Error("socket closed")); - const warning = Option.getOrThrow(yield* Fiber.join(warningFiber)); - NodeAssert.ok(warning.type === "runtime.warning"); - NodeAssert.equal(warning.payload.message, "OpenCode connection lost. Reconnecting."); - const completedFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), - Stream.runHead, - Effect.forkChild, + + const events = yield* Fiber.join(eventsFiber); + const reasoningLifecycle = events + .filter(isItemLifecycleForTest) + .filter((event) => event.payload.itemType === "reasoning"); + NodeAssert.deepEqual( + reasoningLifecycle.map((event) => [event.type, event.payload.status, event.payload.detail]), + [ + ["item.updated", "inProgress", "The user wants to know their opencode version."], + // Growth is incremental, not the full cumulative body. + ["item.updated", "inProgress", " I should run the command to check."], + ["item.completed", "completed", finalText], + ], ); - runtimeMock.state.sessionStatus = "idle"; - reconnect.resolve({ - id: "evt-reconnected", - type: "server.connected", - properties: {}, - } satisfies OpenCodeEvent); - NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(completedFiber)).turnId, turn.turnId); - NodeAssert.equal( - (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status, - "ready", + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => [event.payload.streamKind, event.payload.delta]), + [ + ["reasoning_text", "The user wants to know their opencode version."], + ["reasoning_text", " I should run the command to check."], + ], ); yield* adapter.stopSession(threadId); - }), + }).pipe(Effect.scoped), ); - it.effect( - "ends a running session on clean stream closure without discarding unresolved permissions", - () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-stream-closed"); - const endStream = promiseWithResolvers(); - const request = permissionRequest("per_disconnect", "http://127.0.0.1:9999/session"); - runtimeMock.state.pendingPermissions = [request]; - runtimeMock.state.subscribedEvents = [endStream.promise]; + it.effect("streams delta-only reasoning growth onto lifecycle detail", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-deltas"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "delta-reasoning-message"; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-delta", + sessionID, + messageID, + type: "reasoning", + text: "Start", + time: { start: 100 }, + }, + }, + }, + { + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID: "reasoning-delta", + field: "text", + delta: " middle", + }, + }, + { + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID: "reasoning-delta", + field: "text", + delta: " end", + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-bash", + sessionID, + messageID, + type: "tool", + callID: "call-bash", + tool: "bash", + state: { + status: "running", + input: { command: "pwd" }, + title: "Working directory", + time: { start: 200 }, + }, + }, + }, + }, + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = yield* Fiber.join(eventsFiber); + const reasoningLifecycle = events + .filter(isItemLifecycleForTest) + .filter((event) => event.payload.itemType === "reasoning"); + NodeAssert.deepEqual( + reasoningLifecycle.map((event) => [event.type, event.payload.status, event.payload.detail]), + [ + ["item.updated", "inProgress", "Start"], + ["item.updated", "inProgress", " middle"], + ["item.updated", "inProgress", " end"], + ["item.completed", "completed", "Start middle end"], + ], + ); + // Incremental chunks only — never the cumulative prefixes. + for (const event of reasoningLifecycle.filter((entry) => entry.type === "item.updated")) { + NodeAssert.ok( + event.payload.detail === undefined || + event.payload.detail === "Start" || + event.payload.detail === " middle" || + event.payload.detail === " end", + ); + NodeAssert.notEqual(event.payload.detail, "Start middle"); + NodeAssert.notEqual(event.payload.detail, "Start middle end"); + } + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("finalizes the open thought when commentary starts flowing", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-commentary"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "commentary-message"; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-1", + sessionID, + messageID, + type: "reasoning", + text: "", + time: { start: 100 }, + }, + }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "text-1", + sessionID, + messageID, + type: "text", + text: "", + time: { start: 150 }, + }, + }, + }, + { + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID: "text-1", + field: "text", + delta: "Working on it", + }, + }, + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = yield* Fiber.join(eventsFiber); + const types = events + .map((event) => event.type) + .filter( + (type) => + type === "item.updated" || type === "item.completed" || type === "content.delta", + ); + // The thought closes before its own commentary delta lands. + NodeAssert.deepEqual(types, ["item.updated", "item.completed", "content.delta"]); + const completed = events.find((event) => event.type === "item.completed")!; + NodeAssert.equal(completed.itemId, "reasoning-1"); + NodeAssert.equal(completed.payload.itemType, "reasoning"); + const delta = events.find((event) => event.type === "content.delta")!; + NodeAssert.deepEqual( + [delta.payload.streamKind, delta.payload.delta], + ["assistant_text", "Working on it"], + ); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("finalizes open thoughts before part and message removal", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-removal"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "removal-message"; + const reasoningPart = (id: string, start: number) => ({ + type: "message.part.updated", + properties: { + sessionID, + part: { id, sessionID, messageID, type: "reasoning", text: "", time: { start } }, + }, + }); + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }, + reasoningPart("reasoning-part-removed", 100), + { + type: "message.part.removed", + properties: { sessionID, messageID, partID: "reasoning-part-removed" }, + }, + reasoningPart("reasoning-message-removed", 200), + { type: "message.removed", properties: { sessionID, messageID } }, + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = yield* Fiber.join(eventsFiber); + NodeAssert.deepEqual( + events + .filter( + (event) => + (event.type === "item.updated" || event.type === "item.completed") && + event.payload.itemType === "reasoning", + ) + .map((event) => [event.type, event.itemId]), + [ + ["item.updated", "reasoning-part-removed"], + ["item.completed", "reasoning-part-removed"], + ["item.updated", "reasoning-message-removed"], + ["item.completed", "reasoning-message-removed"], + ], + ); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("closes the previous thought when a new reasoning part starts", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-succession"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "succession-message"; + const reasoningPart = (id: string, start: number) => ({ + type: "message.part.updated", + properties: { + sessionID, + part: { id, sessionID, messageID, type: "reasoning", text: "", time: { start } }, + }, + }); + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }, + reasoningPart("reasoning-1", 100), + reasoningPart("reasoning-2", 200), + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = yield* Fiber.join(eventsFiber); + const lifecycle = events.filter(isItemLifecycleForTest); + // reasoning-1 never reports a native end; sighting reasoning-2 ends it. + NodeAssert.deepEqual( + lifecycle.map((event) => [event.type, event.itemId]), + [ + ["item.updated", "reasoning-1"], + ["item.completed", "reasoning-1"], + ["item.updated", "reasoning-2"], + ], + ); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("finalizes the open thought when the turn completes", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-turn-complete"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "turn-complete-message"; + const start = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + start.promise, + { + type: "message.updated", + properties: { sessionID, info: { id: messageID, role: "assistant" } }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-1", + sessionID, + messageID, + type: "reasoning", + text: "", + time: { start: 100 }, + }, + }, + }, + { + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Think quietly", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + start.resolve({ + id: "evt-turn-complete-busy", + type: "session.status", + properties: { sessionID, status: { type: "busy" } }, + }); + + const events = yield* Fiber.join(eventsFiber); + const reasoningCompletedIndex = events.findIndex( + (event) => event.type === "item.completed" && event.payload.itemType === "reasoning", + ); + const turnCompletedIndex = events.findIndex((event) => event.type === "turn.completed"); + NodeAssert.ok(reasoningCompletedIndex !== -1); + NodeAssert.ok(turnCompletedIndex !== -1); + NodeAssert.ok(reasoningCompletedIndex < turnCompletedIndex); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("finalizes an open thought on session error", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-error"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "error-message"; + const start = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + start.promise, + { + type: "message.updated", + properties: { sessionID, info: { id: messageID, role: "assistant" } }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-1", + sessionID, + messageID, + type: "reasoning", + text: "", + time: { start: 100 }, + }, + }, + }, + { + id: "evt-reasoning-error", + type: "session.error", + properties: { + sessionID, + error: { name: "UnknownError", data: { message: "failed" } }, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil( + (event) => event.type === "item.completed" && event.payload.itemType === "reasoning", + ), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Think quietly", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + start.resolve({ + id: "evt-error-busy", + type: "session.status", + properties: { sessionID, status: { type: "busy" } }, + }); + + const events = yield* Fiber.join(eventsFiber); + const reasoningCompleted = events.find( + (event) => event.type === "item.completed" && event.payload.itemType === "reasoning", + ); + NodeAssert.equal(reasoningCompleted?.turnId, turn.turnId); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("finalizes the open thought when the turn is interrupted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reasoning-interrupt"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "interrupt-message"; + const start = promiseWithResolvers(); + const reasoningOpened = yield* Deferred.make(); + runtimeMock.state.subscribedEvents = [start.promise]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.tap((event) => + event.type === "item.updated" && event.payload.itemType === "reasoning" + ? Deferred.succeed(reasoningOpened, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Stream.takeUntil((event) => event.type === "turn.aborted"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Think quietly", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + start.resolve({ + id: "evt-interrupt-busy", + type: "session.status", + properties: { sessionID, status: { type: "busy" } }, + }); + runtimeMock.state.subscribedEvents.push( + { + type: "message.updated", + properties: { sessionID, info: { id: messageID, role: "assistant" } }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "reasoning-1", + sessionID, + messageID, + type: "reasoning", + text: "", + time: { start: 100 }, + }, + }, + }, + ); + yield* Deferred.await(reasoningOpened); + yield* adapter.interruptTurn(threadId, turn.turnId); + + const events = yield* Fiber.join(eventsFiber); + const reasoningCompletedIndex = events.findIndex( + (event) => event.type === "item.completed" && event.payload.itemType === "reasoning", + ); + const turnAbortedIndex = events.findIndex((event) => event.type === "turn.aborted"); + NodeAssert.ok(reasoningCompletedIndex !== -1); + NodeAssert.ok(turnAbortedIndex !== -1); + NodeAssert.ok(reasoningCompletedIndex < turnAbortedIndex); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("maps native task progress only while a turn is active", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-native-progress"); + const sessionID = "http://127.0.0.1:9999/session"; + const startProgress = promiseWithResolvers(); + const finishTurn = promiseWithResolvers(); + const lateProgress = promiseWithResolvers(); + const todos = [ + { content: "Read files", status: "completed", priority: "high" }, + { content: "Fix OpenCode", status: "in_progress", priority: "high" }, + { content: "Run tests", status: "pending", priority: "medium" }, + { content: "Old task", status: "cancelled", priority: "low" }, + ]; + const todoEvent = { + id: "evt-todos", + type: "todo.updated", + properties: { sessionID, todos }, + } satisfies OpenCodeEvent; + runtimeMock.state.subscribedEvents = [ + startProgress.promise, + ...["todowrite", "bash"].map( + (tool) => + ({ + id: `evt-${tool}`, + type: "message.part.updated", + properties: { + sessionID, + time: 2, + part: { + id: `part-${tool}`, + sessionID, + messageID: "msg-tools", + type: "tool", + callID: `call-${tool}`, + tool, + state: { + status: "completed", + input: tool === "bash" ? { command: "pwd" } : { todos }, + output: tool === "bash" ? "/repo\n" : "Tasks updated", + title: tool === "bash" ? "Working directory" : "Tasks updated", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + }, + }) satisfies OpenCodeEvent, + ), + finishTurn.promise, + lateProgress.promise, + { id: "evt-progress-drained", type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.plan.updated" || event.type === "item.completed"), + ), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work through the task list", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + startProgress.resolve(todoEvent); + const events = yield* Fiber.join(eventsFiber); + const plan = events.find((event) => event.type === "turn.plan.updated"); + NodeAssert.equal(plan?.turnId, turn.turnId); + NodeAssert.deepEqual(plan?.payload.plan, [ + { step: "Read files", status: "completed" }, + { step: "Fix OpenCode", status: "inProgress" }, + { step: "Run tests", status: "pending" }, + ]); + const tools = events.filter((event) => event.type === "item.completed"); + NodeAssert.equal(tools[0]?.payload.itemType, "dynamic_tool_call"); + NodeAssert.equal(tools[1]?.payload.title, "Working directory"); + NodeAssert.partialDeepStrictEqual(tools[1]?.payload.data, { + command: "pwd", + result: "/repo\n", + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + finishTurn.resolve({ + id: "evt-progress-completed", + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + yield* Fiber.join(completedFiber); + const lateEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + lateProgress.resolve({ ...todoEvent, id: "evt-late-todos" }); + NodeAssert.deepEqual( + (yield* Fiber.join(lateEventsFiber)).map((event) => event.type), + ["thread.state.changed"], + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("warns on disconnection and recovers a completion missed during reconnect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-reconnect-completion"); + const reconnect = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [reconnect.promise]; + runtimeMock.state.sessionStatus = "busy"; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const warningFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.eventStreamError?.(new Error("socket closed")); + const warning = Option.getOrThrow(yield* Fiber.join(warningFiber)); + NodeAssert.ok(warning.type === "runtime.warning"); + NodeAssert.equal(warning.payload.message, "OpenCode connection lost. Reconnecting."); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.sessionStatus = "idle"; + reconnect.resolve({ + id: "evt-reconnected", + type: "server.connected", + properties: {}, + } satisfies OpenCodeEvent); + NodeAssert.equal(Option.getOrThrow(yield* Fiber.join(completedFiber)).turnId, turn.turnId); + NodeAssert.equal( + (yield* adapter.listSessions()).find((session) => session.threadId === threadId)?.status, + "ready", + ); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect( + "ends a running session on clean stream closure without discarding unresolved permissions", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stream-closed"); + const endStream = promiseWithResolvers(); + const request = permissionRequest("per_disconnect", "http://127.0.0.1:9999/session"); + runtimeMock.state.pendingPermissions = [request]; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: request.sessionID, + part: { + id: "reasoning-before-disconnect", + sessionID: request.sessionID, + messageID: "message-before-disconnect", + type: "reasoning", + text: "", + time: { start: 100 }, + }, + }, + }, + endStream.promise, + ]; const openedFiber = yield* adapter.streamEvents.pipe( Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), Stream.runHead, @@ -7334,6 +8145,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { properties: { sessionID: request.sessionID, status: { type: "busy" } }, }); const exited = yield* Fiber.join(exitedFiber); + const exitedTypes = Array.from(exited, (event) => event.type); + NodeAssert.ok(exitedTypes.indexOf("item.completed") < exitedTypes.indexOf("runtime.error")); NodeAssert.equal( exited.some((event) => event.type === "request.resolved"), false, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 3d216bb1167b..972656785c69 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -329,6 +329,7 @@ type OpenCodeTextPartState = Pick, "id" | "tokens">; @@ -350,6 +351,14 @@ interface OpenCodeSessionContext { // OpenCode permits edits to completed parts. Keep text for snapshot comparison // until native removal or session teardown, but do not retain other part payloads. readonly textPartsByMessageId: Map>; + /** + * The single currently open thought. A session thinks one thought at a + * time: sighting a new reasoning part, acting, commenting, or settling the + * turn closes whatever is open, so historical segments can never linger as + * live rows. Direct lookup only — never iterate retained parts here (see + * the history-visits guard in OpenCodeAdapter.test.ts). + */ + openReasoningPart: { messageID: string; id: string } | undefined; turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; activeTurnId: TurnId | undefined; activeAgent: string | undefined; @@ -597,6 +606,18 @@ function resolveTextStreamKind(part: Pick): "assistant_text" | "re return part.type === "reasoning" ? "reasoning_text" : "assistant_text"; } +/** Provider-supplied reasoning body text only — never synthesize content. */ +function openCodeReasoningDetail( + part: Pick, +): string | undefined { + const text = part.emittedText ?? part.text; + if (text === undefined) { + return undefined; + } + const trimmed = text.trim(); + return trimmed.length > 0 ? text : undefined; +} + function retainOpenCodeTextPart( context: OpenCodeSessionContext, part: OpenCodeTextPart, @@ -612,6 +633,7 @@ function retainOpenCodeTextPart( ...(part.time !== undefined ? { time: part.time } : {}), emittedText: previous?.emittedText, completed: previous?.completed ?? false, + reasoningStarted: previous?.reasoningStarted ?? false, }; parts.set(part.id, state); context.textPartsByMessageId.set(part.messageID, parts); @@ -1059,7 +1081,7 @@ export function makeOpenCodeAdapter( // the remaining cleanups. yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => Effect.ignoreCause(finalizeAndStopOpenCodeContext(context)), { concurrency: "unbounded", discard: true }, ); // Close the logger AFTER session teardown so any final lifecycle @@ -1149,6 +1171,7 @@ export function makeOpenCodeAdapter( yield* Fiber.interrupt(pendingIdleReconciliation.fiber); } yield* schedulePendingRequestRecovery(context); + yield* completeOpenReasoningSegment(context, turnId, raw); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1514,6 +1537,7 @@ export function makeOpenCodeAdapter( ); } yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); + yield* completeOpenReasoningSegment(context, turnId, raw); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1561,6 +1585,7 @@ export function makeOpenCodeAdapter( // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; // closing that scope triggers the fiber-interrupt finalizer, so any // subsequent yield point would unwind and silently drop these emits. + yield* completeOpenReasoningSegment(context, turnId, undefined); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1591,6 +1616,130 @@ export function makeOpenCodeAdapter( yield* Scope.close(context.sessionScope, Exit.void); }); + /** + * Close the open thought, if any. A thought ends as soon as the model + * acts on it: a tool call starts, commentary flows, a new thought + * begins, or the turn settles. The end is stamped with the native time + * when the provider reported one, otherwise with the observation time — + * never left dangling as a perpetual live row. + */ + const completeOpenReasoningSegment = Effect.fn("completeOpenReasoningSegment")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + raw: unknown, + ) { + const open = context.openReasoningPart; + if (!open) { + return; + } + context.openReasoningPart = undefined; + const state = context.textPartsByMessageId.get(open.messageID)?.get(open.id); + if (!state || state.type !== "reasoning" || !state.reasoningStarted || state.completed) { + return; + } + state.completed = true; + const detail = openCodeReasoningDetail(state); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: state.id, + createdAt: state.time?.end !== undefined ? isoFromEpochMs(state.time.end) : undefined, + raw, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Thinking", + ...(detail !== undefined ? { detail } : {}), + }, + }); + }); + + const finalizeAndStopOpenCodeContext = Effect.fn("finalizeAndStopOpenCodeContext")(function* ( + context: OpenCodeSessionContext, + ) { + yield* completeOpenReasoningSegment(context, context.activeTurnId, undefined); + return yield* stopOpenCodeContext(context); + }); + + /** Start a reasoning lifecycle segment (completion happens after text merge). */ + const emitReasoningSegmentEvent = Effect.fn("emitReasoningSegmentEvent")(function* ( + context: OpenCodeSessionContext, + part: OpenCodeTextPartState, + turnId: TurnId | undefined, + raw: unknown, + ) { + if (part.type !== "reasoning") { + return; + } + // Capability-driven: native part identity/time always give structural + // thought/tool/thought boundaries. When the provider also supplies + // readable reasoning text, carry it on lifecycle `detail` so clients can + // render it through the existing work-entry path. Empty reasoning stays + // structural only — never fabricate text. (content.delta reasoning_text + // is still emitted for adapter parity, but ingestion drops non-assistant + // deltas; detail is the surviving carrier.) + if (!part.reasoningStarted) { + part.reasoningStarted = true; + yield* completeOpenReasoningSegment(context, turnId, raw); + context.openReasoningPart = { messageID: part.messageID, id: part.id }; + const detail = openCodeReasoningDetail(part); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: part.time !== undefined ? isoFromEpochMs(part.time.start) : undefined, + raw, + })), + type: "item.updated", + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Thinking", + ...(detail !== undefined ? { detail } : {}), + }, + }); + } + }); + + const completeReasoningSegmentPart = Effect.fn("completeReasoningSegmentPart")(function* ( + context: OpenCodeSessionContext, + part: OpenCodeTextPartState, + turnId: TurnId | undefined, + raw: unknown, + ) { + if (part.type !== "reasoning" || part.completed || part.time?.end === undefined) { + return; + } + part.completed = true; + if ( + context.openReasoningPart?.messageID === part.messageID && + context.openReasoningPart.id === part.id + ) { + context.openReasoningPart = undefined; + } + const detail = openCodeReasoningDetail(part); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Thinking", + ...(detail !== undefined ? { detail } : {}), + }, + }); + }); + /** Emit content.delta and item.completed events for an assistant text part. */ const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( context: OpenCodeSessionContext, @@ -1598,12 +1747,22 @@ export function makeOpenCodeAdapter( turnId: TurnId | undefined, raw: unknown, ) { + const reasoningAlreadyStarted = part.reasoningStarted; + const reasoningAlreadyCompleted = part.completed; + yield* emitReasoningSegmentEvent(context, part, turnId, raw); if (part.text === undefined) { + // Native end can arrive without a new text body; still finalize. + yield* completeReasoningSegmentPart(context, part, turnId, raw); return; } const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(part.emittedText, part.text); part.emittedText = latestText; part.text = latestText; + // Flowing commentary ends any open thought before the words land, so a + // thought never stays live underneath the model's own answer. + if (part.type === "text" && latestText.length > 0) { + yield* completeOpenReasoningSegment(context, turnId, raw); + } if (deltaToEmit.length > 0) { yield* emit({ ...(yield* buildEventBase({ @@ -1619,6 +1778,68 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); + // Stream reasoning growth as an incremental lifecycle detail chunk — + // not the full cumulative body. Clients concatenate inProgress updates; + // completion carries the final full text. That keeps live/persist + // transfer O(N) instead of O(N²) cumulative prefixes. Skip the first + // sighting when emitReasoningSegmentEvent already carried opening detail. + if ( + part.type === "reasoning" && + !part.completed && + latestText.trim().length > 0 && + reasoningAlreadyStarted + ) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: part.time !== undefined ? isoFromEpochMs(part.time.start) : undefined, + raw, + })), + type: "item.updated", + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Thinking", + detail: deltaToEmit, + }, + }); + } + } + + // Complete after merge so lifecycle detail has the latest provider text. + yield* completeReasoningSegmentPart(context, part, turnId, raw); + // OpenCode can edit a completed reasoning part later (reconnect/history). + // Refresh terminal detail without reopening the segment as live. + if ( + part.type === "reasoning" && + reasoningAlreadyCompleted && + part.reasoningStarted && + deltaToEmit.length > 0 && + latestText.trim().length > 0 + ) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + part.time?.end !== undefined + ? isoFromEpochMs(part.time.end) + : part.time !== undefined + ? isoFromEpochMs(part.time.start) + : undefined, + raw, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Thinking", + detail: latestText, + }, + }); } if (part.type === "text" && part.time?.end !== undefined && !part.completed) { @@ -2370,12 +2591,21 @@ export function makeOpenCodeAdapter( } case "message.removed": { + if (context.openReasoningPart?.messageID === event.properties.messageID) { + yield* completeOpenReasoningSegment(context, turnId, event); + } context.messageRoleById.delete(event.properties.messageID); context.textPartsByMessageId.delete(event.properties.messageID); break; } case "message.part.removed": { + if ( + context.openReasoningPart?.messageID === event.properties.messageID && + context.openReasoningPart.id === event.properties.partID + ) { + yield* completeOpenReasoningSegment(context, turnId, event); + } const parts = context.textPartsByMessageId.get(event.properties.messageID); parts?.delete(event.properties.partID); if (parts?.size === 0) { @@ -2405,6 +2635,9 @@ export function makeOpenCodeAdapter( if (deltaToEmit.length === 0) { break; } + if (existingPart.type === "text") { + yield* completeOpenReasoningSegment(context, turnId, event); + } existingPart.emittedText = nextText; existingPart.text = nextText; yield* emit({ @@ -2420,6 +2653,62 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); + // Delta-only reasoning streams never revisit message.part.updated, so + // mirror the snapshot path: push an incremental lifecycle detail chunk + // (ingestion drops reasoning_text content.delta). + if ( + existingPart.type === "reasoning" && + existingPart.reasoningStarted && + !existingPart.completed && + nextText.trim().length > 0 + ) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + createdAt: + existingPart.time !== undefined + ? isoFromEpochMs(existingPart.time.start) + : undefined, + raw: event, + })), + type: "item.updated", + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Thinking", + detail: deltaToEmit, + }, + }); + } else if ( + existingPart.type === "reasoning" && + existingPart.reasoningStarted && + existingPart.completed && + nextText.trim().length > 0 + ) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + createdAt: + existingPart.time?.end !== undefined + ? isoFromEpochMs(existingPart.time.end) + : existingPart.time !== undefined + ? isoFromEpochMs(existingPart.time.start) + : undefined, + raw: event, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Thinking", + detail: nextText, + }, + }); + } break; } @@ -2460,6 +2749,9 @@ export function makeOpenCodeAdapter( } if (part.type === "tool") { + // Acting ends thinking: close any open thought before the tool + // lifecycle lands so the tool supersedes it in event order. + yield* completeOpenReasoningSegment(context, turnId, event); const itemType = toToolLifecycleItemType(part.tool); const title = part.state.status === "running" || part.state.status === "completed" @@ -2639,6 +2931,9 @@ export function makeOpenCodeAdapter( break; } } + if (activeTurnId) { + yield* completeOpenReasoningSegment(context, activeTurnId, event); + } yield* cancelIdleReconciliation(context); const terminalCancellation = activeTurnId !== undefined && cancellation?.turnId === activeTurnId @@ -2816,7 +3111,7 @@ export function makeOpenCodeAdapter( if (existing.session.status === "connecting" && !(yield* Ref.get(existing.stopped))) { return (yield* awaitOpenCodeContextReady(existing)).session; } - yield* stopOpenCodeContext(existing); + yield* finalizeAndStopOpenCodeContext(existing); deleteContextIfCurrent(existing); } @@ -2994,6 +3289,7 @@ export function makeOpenCodeAdapter( pendingQuestions: new Map(), textPartsByMessageId: new Map(), messageRoleById: new Map(), + openReasoningPart: undefined, turnTokenUsage: undefined, activeTurnId: undefined, activeAgent: undefined, @@ -3752,7 +4048,7 @@ export function makeOpenCodeAdapter( threadId, }); } - const stopped = yield* stopOpenCodeContext(context); + const stopped = yield* finalizeAndStopOpenCodeContext(context); deleteContextIfCurrent(context); if (!stopped) { return; @@ -3911,7 +4207,7 @@ export function makeOpenCodeAdapter( // interrupt the sibling fibers. Same pattern as the layer finalizer. yield* Effect.forEach( contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + (context) => Effect.ignoreCause(finalizeAndStopOpenCodeContext(context)), { concurrency: "unbounded", discard: true }, ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 59b4a0c9856b..dc90a5bd1425 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -10,6 +10,7 @@ import { ThreadId, TurnId, type OrchestrationThread, + type OrchestrationThreadActivity, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { @@ -39,6 +40,7 @@ import { createMessageAttachmentPreviewProjector, deriveTimelineEntries, deriveTimelineEntriesWithState, + deriveWorkLogEntries, type WorkLogEntry, type TimelineEntriesProjection, } from "../../session-logic"; @@ -3355,3 +3357,607 @@ describe("computeStableMessagesTimelineRows", () => { expect(reordered.result).toEqual([initial.result[1], initial.result[0]]); }); }); + +describe("reasoning segments", () => { + const turnId = TurnId.make("segment-turn"); + const time = (second: number) => new Date(Date.UTC(2026, 8, 8, 0, 0, second)).toISOString(); + const thinkingActivity = ( + id: string, + kind: "tool.updated" | "tool.completed", + second: number, + toolCallId = id, + activityTurnId = turnId, + ): OrchestrationThreadActivity => + ({ + id: EventId.make(id), + tone: "tool", + kind, + summary: "Thinking", + payload: { + itemType: "reasoning", + toolCallId, + status: kind === "tool.completed" ? "completed" : "inProgress", + title: "Thinking", + }, + turnId: activityTurnId, + createdAt: time(second), + }) as unknown as OrchestrationThreadActivity; + const toolActivity = (id: string, second: number, command = "git status") => + ({ + id: EventId.make(id), + tone: "tool", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + toolCallId: `call-${id}`, + status: "completed", + title: "Ran command", + command, + }, + turnId, + createdAt: time(second), + }) as unknown as OrchestrationThreadActivity; + const toolLifecycleActivity = ( + id: string, + toolCallId: string, + kind: "tool.updated" | "tool.completed", + second: number, + ) => + ({ + ...toolActivity(id, second), + kind, + payload: { + itemType: "command_execution", + toolCallId, + status: kind === "tool.completed" ? "completed" : "inProgress", + title: kind === "tool.completed" ? "Ran command" : "Running command", + command: "git status", + }, + }) as unknown as OrchestrationThreadActivity; + const userMessage: ChatMessage = { + id: MessageId.make("segment-user"), + role: "user", + text: "Inspect", + turnId: null, + createdAt: time(0), + updatedAt: time(0), + streaming: false, + }; + const assistantMessage = (id: string, second: number, streaming = false): ChatMessage => ({ + id: MessageId.make(id), + role: "assistant", + text: "Noted", + turnId, + createdAt: time(second), + updatedAt: time(second), + streaming, + }); + const settledInput = (work: WorkLogEntry[], expanded = true) => ({ + timelineEntries: deriveTimelineEntries([], [], work), + latestTurn: { turnId, state: "completed" as const, startedAt: time(0), completedAt: time(30) }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + ...(expanded ? { expandedTurnIds: new Set([turnId]) } : {}), + }); + const liveInput = (messages: ChatMessage[], work: WorkLogEntry[]) => ({ + timelineEntries: deriveTimelineEntries(messages, [], work), + latestTurn: { turnId, state: "running" as const, startedAt: time(0), completedAt: null }, + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + it("splits tool groups at thinking boundaries instead of one giant pile", () => { + const work = deriveWorkLogEntries([ + thinkingActivity("thought-1-updated", "tool.updated", 0, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), + toolActivity("tool-1", 5), + toolActivity("tool-2", 6), + toolActivity("tool-3", 7), + thinkingActivity("thought-2-updated", "tool.updated", 8, "thought-2"), + thinkingActivity("thought-2-completed", "tool.completed", 15, "thought-2"), + toolActivity("tool-4", 16), + toolActivity("tool-5", 17), + ]); + // Lifecycle pairs merge regardless of adjacency, so interleaved tools + // can never split a thought's timing. + expect(work).toHaveLength(7); + const rows = deriveMessagesTimelineRows(settledInput(work)); + + expect(rows.map((row) => row.kind)).toEqual([ + "turn-fold", + "work", + "work-toggle", + "work", + "work-toggle", + ]); + expect( + rows + .filter((row) => row.kind === "work" || row.kind === "work-toggle") + .map((row) => (row.kind === "work" ? row.displayLabel : row.summary)), + ).toEqual(["Thought for 4.0s", "Ran 3 commands", "Thought for 7.0s", "Ran 2 commands"]); + }); + + it("renders text-bearing reasoning as inline markdown between tools while live", () => { + const textA = "The user wants to know their opencode version."; + const textB = "I should run the command to check."; + const withDetail = ( + activity: OrchestrationThreadActivity, + detail: string, + ): OrchestrationThreadActivity => + ({ + ...activity, + payload: { + ...(activity.payload as Record), + detail, + }, + }) as OrchestrationThreadActivity; + const work = deriveWorkLogEntries([ + withDetail(thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), textA), + withDetail(thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), textA), + toolActivity("tool-1", 5), + toolActivity("tool-2", 6), + withDetail(thinkingActivity("thought-2-updated", "tool.updated", 7, "thought-2"), textB), + ]); + const rows = deriveMessagesTimelineRows( + liveInput([userMessage, assistantMessage("live-commentary", 8, true)], work), + ); + + expect( + rows + .filter( + (row) => + row.kind === "reasoning-markdown" || + row.kind === "work" || + row.kind === "work-toggle" || + row.kind === "work-live", + ) + .map((row) => { + if (row.kind === "reasoning-markdown") { + return { kind: row.kind, text: row.text, streaming: row.streaming }; + } + if (row.kind === "work") return { kind: row.kind, label: row.displayLabel }; + if (row.kind === "work-toggle") return { kind: row.kind, label: row.summary }; + return { kind: row.kind, label: "live" }; + }), + ).toEqual([ + { kind: "reasoning-markdown", text: textA, streaming: false }, + { kind: "work-toggle", label: "Ran 2 commands" }, + { kind: "reasoning-markdown", text: textB, streaming: true }, + ]); + expect(rows.some((row) => row.kind === "work" && row.displayLabel?.startsWith("Thought"))).toBe( + false, + ); + }); + + it("keeps a stable reasoning-markdown row id across streaming updates", () => { + const withDetail = ( + activity: OrchestrationThreadActivity, + detail: string, + ): OrchestrationThreadActivity => + ({ + ...activity, + payload: { + ...(activity.payload as Record), + detail, + }, + }) as OrchestrationThreadActivity; + const rowsFor = (activities: OrchestrationThreadActivity[]) => + deriveMessagesTimelineRows( + liveInput( + [userMessage, assistantMessage("live-commentary", 8, true)], + deriveWorkLogEntries(activities), + ), + ); + const first = rowsFor([ + withDetail(thinkingActivity("thought-1-a", "tool.updated", 1, "thought-1"), "Start"), + ]); + const second = rowsFor([ + withDetail(thinkingActivity("thought-1-a", "tool.updated", 1, "thought-1"), "Start"), + withDetail(thinkingActivity("thought-1-b", "tool.updated", 2, "thought-1"), " middle"), + withDetail(thinkingActivity("thought-1-c", "tool.updated", 3, "thought-1"), " end"), + ]); + const firstRow = first.find((row) => row.kind === "reasoning-markdown"); + const secondRow = second.find((row) => row.kind === "reasoning-markdown"); + expect(firstRow).toMatchObject({ + kind: "reasoning-markdown", + id: `reasoning-markdown:${turnId}:thought-1`, + text: "Start", + streaming: true, + }); + expect(secondRow).toMatchObject({ + kind: "reasoning-markdown", + id: `reasoning-markdown:${turnId}:thought-1`, + text: "Start middle end", + streaming: true, + }); + expect(secondRow?.id).toBe(firstRow?.id); + }); + + it("folds text-bearing reasoning into one Worked-for turn fold when settled", () => { + const text = + "The user wants to know their opencode version. I should run the command to check."; + const withDetail = ( + activity: OrchestrationThreadActivity, + detail: string, + ): OrchestrationThreadActivity => + ({ + ...activity, + payload: { + ...(activity.payload as Record), + detail, + }, + }) as OrchestrationThreadActivity; + const work = deriveWorkLogEntries([ + withDetail(thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), text), + withDetail(thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), text), + toolActivity("tool-1", 5), + toolActivity("tool-2", 6), + withDetail( + thinkingActivity("thought-2-updated", "tool.updated", 7, "thought-2"), + "Next step.", + ), + withDetail( + thinkingActivity("thought-2-completed", "tool.completed", 10, "thought-2"), + "Next step.", + ), + ]); + const terminal = assistantMessage("segment-final", 12); + const timelineEntries = deriveTimelineEntries([userMessage, terminal], [], work); + const collapsed = deriveMessagesTimelineRows({ + timelineEntries, + latestTurn: { + turnId, + state: "completed", + startedAt: time(0), + completedAt: time(12), + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(collapsed.filter((row) => row.kind === "turn-fold")).toHaveLength(1); + expect(collapsed.find((row) => row.kind === "turn-fold")).toMatchObject({ + kind: "turn-fold", + label: expect.stringMatching(/^Worked for /), + }); + expect(collapsed.some((row) => row.kind === "reasoning-markdown")).toBe(false); + + const expanded = deriveMessagesTimelineRows({ + timelineEntries, + latestTurn: { + turnId, + state: "completed", + startedAt: time(0), + completedAt: time(12), + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + expandedTurnIds: new Set([turnId]), + }); + expect( + expanded + .filter((row) => row.kind === "reasoning-markdown" || row.kind === "work-toggle") + .map((row) => + row.kind === "reasoning-markdown" + ? row.text + : row.kind === "work-toggle" + ? row.summary + : null, + ), + ).toEqual([text, "Ran 2 commands", "Next step."]); + }); + + it("keeps settled history monotonic while the live tail alternates", () => { + const commentary = assistantMessage("commentary", 3); + const steps: OrchestrationThreadActivity[] = [ + thinkingActivity("thought-a-start", "tool.updated", 1, "thought-a"), + thinkingActivity("thought-a-end", "tool.completed", 2, "thought-a"), + toolLifecycleActivity("tool-a-start", "tool-a", "tool.updated", 4), + toolLifecycleActivity("tool-a-end", "tool-a", "tool.completed", 5), + toolLifecycleActivity("tool-b-start", "tool-b", "tool.updated", 6), + toolLifecycleActivity("tool-b-end", "tool-b", "tool.completed", 7), + thinkingActivity("thought-b-start", "tool.updated", 8, "thought-b"), + thinkingActivity("thought-b-end", "tool.completed", 9, "thought-b"), + toolLifecycleActivity("tool-c-start", "tool-c", "tool.updated", 10), + toolLifecycleActivity("tool-c-end", "tool-c", "tool.completed", 11), + thinkingActivity("thought-c-start", "tool.updated", 12, "thought-c"), + ]; + const stages = [ + { activityCount: 1, commentary: false }, + { activityCount: 2, commentary: false }, + { activityCount: 2, commentary: true }, + ...steps.slice(2).map((_, index) => ({ activityCount: index + 3, commentary: true })), + ]; + let settledHistory: string[] = []; + let finalRows: ReturnType = []; + + for (const stage of stages) { + const messages = stage.commentary ? [userMessage, commentary] : [userMessage]; + finalRows = deriveMessagesTimelineRows( + liveInput(messages, deriveWorkLogEntries(steps.slice(0, stage.activityCount))), + ); + const history = finalRows + .filter( + (row) => + row.kind === "work" || + row.kind === "work-toggle" || + (row.kind === "message" && row.message.role === "assistant"), + ) + .map((row) => + row.kind === "work" + ? `${row.id}:${row.displayLabel}` + : row.kind === "work-toggle" + ? `${row.id}:${row.summary}` + : row.kind === "message" + ? `${row.id}:${row.message.text}` + : row.id, + ); + expect(history.slice(0, settledHistory.length)).toEqual(settledHistory); + settledHistory = history; + expect( + finalRows.filter((row) => row.kind === "work-live" || row.kind === "thinking"), + ).toHaveLength(1); + } + + expect( + finalRows + .filter((row) => row.kind === "work" || row.kind === "work-toggle") + .map((row) => (row.kind === "work" ? row.displayLabel : row.summary)), + ).toEqual(["Thought for 1.0s", "Ran 2 commands", "Thought for 1.0s", "Ran command"]); + + const restoredRows = deriveMessagesTimelineRows(settledInput(deriveWorkLogEntries(steps))); + expect( + restoredRows + .filter((row) => row.kind === "work" || row.kind === "work-toggle") + .map((row) => (row.kind === "work" ? row.displayLabel : row.summary)), + ).toEqual(["Thought for 1.0s", "Ran 2 commands", "Thought for 1.0s", "Ran command", "Thought"]); + }); + + it("keeps twenty-plus tool calls in small groups when thoughts intervene", () => { + const activities: OrchestrationThreadActivity[] = []; + for (let segment = 0; segment < 4; segment += 1) { + const base = segment * 10; + activities.push( + thinkingActivity(`thought-${segment}-updated`, "tool.updated", base, `thought-${segment}`), + thinkingActivity( + `thought-${segment}-completed`, + "tool.completed", + base + 2, + `thought-${segment}`, + ), + ); + for (let call = 0; call < 6; call += 1) { + activities.push(toolActivity(`tool-${segment}-${call}`, base + 3 + call)); + } + } + const rows = deriveMessagesTimelineRows(settledInput(deriveWorkLogEntries(activities))); + const toggles = rows.filter((row) => row.kind === "work-toggle"); + const thoughts = rows.filter((row) => row.kind === "work"); + + expect(rows.filter((row) => row.kind !== "turn-fold")).toHaveLength(8); + expect(thoughts).toHaveLength(4); + expect(toggles).toHaveLength(4); + for (const toggle of toggles) { + expect(toggle.kind === "work-toggle" && toggle.hiddenCount).toBe(6); + } + for (const thought of thoughts) { + expect(thought.kind === "work" && thought.displayLabel).toBe("Thought for 2.0s"); + } + }); + + it("shows the live thought while reasoning runs and hides the generic fallback", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage], + deriveWorkLogEntries([ + toolActivity("tool-1", 6), + thinkingActivity("thought-live", "tool.updated", 7, "thought-live"), + ]), + ), + ); + + const live = rows.filter((row) => row.kind === "work-live"); + expect(live).toHaveLength(1); + expect(live[0]).toMatchObject({ + active: true, + entry: { id: expect.any(String), toolLifecycleStatus: "inProgress" }, + }); + // Segment-scoped timing: the live thought counts from the native + // reasoning start, not the turn start. + expect(live[0]?.kind === "work-live" && live[0].entry.createdAt).toBe(time(7)); + expect( + live[0]?.kind === "work-live" && liveWorkEntryLabel(live[0].entry, undefined, true), + ).toBe("Thinking"); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); + }); + + it("keeps completed thoughts static while the turn keeps working", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage, assistantMessage("assistant-1", 8)], + deriveWorkLogEntries([ + thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 5, "thought-1"), + toolActivity("tool-1", 9), + ]), + ), + ); + + // Exactly one live row (the running tool); the finished thought is + // history even though the turn is still working. + const live = rows.filter((row) => row.kind === "work-live"); + expect(live).toHaveLength(1); + expect(live[0]).toMatchObject({ entry: { toolCallId: "call-tool-1" } }); + const thoughts = rows.filter((row) => row.kind === "work"); + expect(thoughts.map((row) => row.kind === "work" && row.displayLabel)).toEqual([ + "Thought for 4.0s", + ]); + }); + + it("keeps an adjacent completed thought beside the live tool row", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage], + deriveWorkLogEntries([ + thinkingActivity("thought-updated", "tool.updated", 1, "thought"), + thinkingActivity("thought-completed", "tool.completed", 5, "thought"), + toolActivity("tool-1", 9), + ]), + ), + ); + + expect(rows.filter((row) => row.kind === "work-live")).toMatchObject([ + { entry: { toolCallId: "call-tool-1" } }, + ]); + expect(rows.filter((row) => row.kind === "work")).toMatchObject([ + { displayLabel: "Thought for 4.0s" }, + ]); + }); + + it("does not let completed reasoning claim the live tool row", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage], + deriveWorkLogEntries([ + thinkingActivity("thought-updated", "tool.updated", 1, "thought"), + thinkingActivity("thought-completed", "tool.completed", 5, "thought"), + ]), + ), + ); + + expect(rows.filter((row) => row.kind === "work-live")).toHaveLength(0); + expect(rows.filter((row) => row.kind === "work")).toMatchObject([ + { displayLabel: "Thought for 4.0s" }, + ]); + expect(rows.filter((row) => row.kind === "thinking")).toHaveLength(1); + }); + + it("scopes terminal reasoning identity to the unsettled turn", () => { + const oldTurnId = TurnId.make("older-segment-turn"); + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage], + deriveWorkLogEntries([ + thinkingActivity("old-completed", "tool.completed", 1, "shared-id", oldTurnId), + thinkingActivity("current-open", "tool.updated", 2, "shared-id"), + ]), + ), + ); + + expect(rows.filter((row) => row.kind === "work-live")).toMatchObject([ + { active: true, entry: { toolCallId: "shared-id", turnId } }, + ]); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); + }); + + it("does not reactivate an older thought after a later thought completes", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage], + deriveWorkLogEntries([ + thinkingActivity("thought-a-open", "tool.updated", 1, "thought-a"), + thinkingActivity("thought-b-open", "tool.updated", 2, "thought-b"), + thinkingActivity("thought-b-done", "tool.completed", 3, "thought-b"), + ]), + ), + ); + + expect(rows.filter((row) => row.kind === "work-live")).toHaveLength(0); + expect(rows.filter((row) => row.kind === "thinking")).toHaveLength(1); + }); + + it("lets only the latest open thought animate", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage, assistantMessage("assistant-1", 2)], + deriveWorkLogEntries([ + thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), + thinkingActivity("thought-2-updated", "tool.updated", 3, "thought-2"), + ]), + ), + ); + + // Two open thoughts separated by commentary (delayed completions): the + // older one renders as a static Thought, only the current one animates, + // and the generic fallback stays hidden. + const live = rows.filter((row) => row.kind === "work-live"); + expect(live).toHaveLength(1); + expect(live[0]).toMatchObject({ active: true }); + expect( + live[0]?.kind === "work-live" && liveWorkEntryLabel(live[0].entry, undefined, true), + ).toBe("Thinking"); + const thoughts = rows.filter((row) => row.kind === "work"); + expect(thoughts.map((row) => row.kind === "work" && row.displayLabel)).toEqual(["Thought"]); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); + }); + + it("folds adjacent open thoughts into a single live row", () => { + const rows = deriveMessagesTimelineRows( + liveInput( + [userMessage], + deriveWorkLogEntries([ + thinkingActivity("thought-1-updated", "tool.updated", 1, "thought-1"), + thinkingActivity("thought-2-updated", "tool.updated", 2, "thought-2"), + ]), + ), + ); + + // Back-to-back open thoughts share the trailing live slot instead of + // animating as two rows. + expect(rows.filter((row) => row.kind === "work-live")).toHaveLength(1); + expect(rows.filter((row) => row.kind === "work")).toHaveLength(0); + }); + + it("renders an interrupted thought as a static Thought", () => { + const rows = deriveMessagesTimelineRows( + settledInput( + deriveWorkLogEntries([ + toolActivity("tool-1", 5), + thinkingActivity("thought-live", "tool.updated", 6, "thought-live"), + ]), + ), + ).filter((row) => row.kind === "work"); + + expect(rows.map((row) => row.kind)).toEqual(["work", "work"]); + expect(rows[1]).toMatchObject({ kind: "work", displayLabel: "Thought" }); + }); + + it("folds settled thoughts away with their turn", () => { + const rows = deriveMessagesTimelineRows( + settledInput( + deriveWorkLogEntries([ + thinkingActivity("thought-1-updated", "tool.updated", 0, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), + toolActivity("tool-1", 5), + ]), + false, + ), + ); + + expect(rows.map((row) => row.kind)).toEqual(["turn-fold"]); + }); + + it("projects canonical reasoning activities into thought rows without text", () => { + const entries = deriveWorkLogEntries([ + thinkingActivity("reasoning-e2e-updated", "tool.updated", 0, "reasoning-e2e"), + thinkingActivity("reasoning-e2e-completed", "tool.completed", 4, "reasoning-e2e"), + ]); + expect(entries).toHaveLength(1); + const rows = deriveMessagesTimelineRows(settledInput(entries)); + + expect( + rows + .filter((row) => row.kind === "work") + .map((row) => (row.kind === "work" ? row.displayLabel : null)), + ).toEqual(["Thought for 4.0s"]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3d7b1e12284e..17eabeadc417 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -6,6 +6,10 @@ import { liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, + formatThinkingSegmentLabel, + isReasoningSegmentEntry, + reasoningHasVisibleText, + reasoningSegmentSpanForEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, @@ -55,7 +59,9 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string const toolPresentation = resolveWorkEntryToolPresentation(entry); if (toolPresentation) return toolPresentation.displayName; if (entry.command) return entry.command; - if (entry.detail) return entry.detail; + // Readable reasoning uses reasoning-markdown rows; never promote detail to + // the Thought/tool label. + if (entry.detail && !isReasoningSegmentEntry(entry)) return entry.detail; const [firstPath] = entry.changedFiles ?? []; if (firstPath) { const path = formatWorkspaceRelativePath(firstPath, workspaceRoot); @@ -72,6 +78,11 @@ export function liveWorkEntryLabel( workspaceRoot: string | undefined, active: boolean, ) { + // Structural thoughts stay labeled "Thinking"; readable text uses the + // reasoning-markdown row instead of this label path. + if (isReasoningSegmentEntry(entry)) { + return "Thinking"; + } const status = liveActivityToolStatus(entry.toolLifecycleStatus, active); const toolPresentation = resolveWorkEntryToolPresentation({ ...entry, @@ -400,6 +411,17 @@ export type MessagesTimelineRow = id: string; createdAt: string | null; snapshot: WorktreeSetupSnapshot; + } + | { + /** + * Provider-supplied readable reasoning rendered as chronological + * Markdown (Codex-style), not a Thought-for-Xs accordion. + */ + kind: "reasoning-markdown"; + id: string; + createdAt: string; + text: string; + streaming: boolean; }; export interface StableMessagesTimelineRowsState { @@ -940,6 +962,13 @@ export function deriveMessagesTimelineRows(input: { ) { break; } + const laterEntry = activeToolEntries[0]?.entry; + if ( + laterEntry && + isReasoningSegmentEntry(entry.entry) !== isReasoningSegmentEntry(laterEntry) + ) { + break; + } activeToolEntries.unshift(entry); } const visibleActiveToolEntries = omitSupersededLifecycleMarkers( @@ -970,7 +999,13 @@ export function deriveMessagesTimelineRows(input: { !workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry)))); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = - activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed + activeWorkAnchor && + latestVisibleToolEntry && + !latestToolFailed && + !( + isReasoningSegmentEntry(latestVisibleToolEntry.entry) && + latestVisibleToolEntry.entry.toolLifecycleStatus !== "inProgress" + ) ? (() => { const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry); return { @@ -988,8 +1023,48 @@ export function deriveMessagesTimelineRows(input: { })() : null; const activeWorkEntryIds = new Set( - activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [], + activeWorkRow !== null || latestToolFailed + ? activeToolEntries + .filter( + ({ entry }) => + !isReasoningSegmentEntry(entry) || entry.toolLifecycleStatus === "inProgress", + ) + .map((entry) => entry.id) + : [], ); + // Only the latest open reasoning entry in the unsettled turn may be live; + // terminal siblings suppress stale updates delivered out of order. + const liveReasoningWorkEntryId = (() => { + if (!input.isWorking || unsettledTurnId === null) return null; + const terminalReasoningIds = new Set(); + for (const timelineEntry of input.timelineEntries) { + if (timelineEntry.kind !== "work") continue; + const entry = timelineEntry.entry; + if (entry.turnId !== unsettledTurnId || !isReasoningSegmentEntry(entry)) continue; + if ( + entry.toolLifecycleStatus !== undefined && + entry.toolLifecycleStatus !== "inProgress" && + entry.toolCallId !== undefined + ) { + terminalReasoningIds.add(entry.toolCallId); + } + } + let designated: string | null = null; + for (const timelineEntry of input.timelineEntries) { + if (timelineEntry.kind !== "work") continue; + const entry = timelineEntry.entry; + if (!isReasoningSegmentEntry(entry)) continue; + if (entry.turnId !== unsettledTurnId) continue; + if (entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress") { + designated = null; + continue; + } + if (entry.toolLifecycleStatus !== "inProgress") continue; + if (entry.toolCallId !== undefined && terminalReasoningIds.has(entry.toolCallId)) continue; + designated = entry.id; + } + return designated; + })(); const appendWorkingRow = () => { const latestUserMessage = input.timelineEntries[lastUserMessageIndex(input.timelineEntries)]; const visualResponseStartedAt = @@ -1104,6 +1179,23 @@ export function deriveMessagesTimelineRows(input: { ) { break; } + // A thinking segment ends the tool group before it: thought and + // action stay in separate compact rows instead of one giant pile. + // Distinct thoughts split too, so a superseded thought renders + // statically beside the live one instead of hiding inside it. + const previousEntry = groupedEntries[groupedEntries.length - 1]!; + const previousReasoning = isReasoningSegmentEntry(previousEntry); + const nextReasoning = isReasoningSegmentEntry(nextEntry.entry); + if (previousReasoning !== nextReasoning) { + break; + } + if ( + previousReasoning && + nextReasoning && + previousEntry.toolCallId !== nextEntry.entry.toolCallId + ) { + break; + } groupedEntries.push(nextEntry.entry); cursor += 1; } @@ -1115,7 +1207,12 @@ export function deriveMessagesTimelineRows(input: { ); if (visibleGroupedEntries.length > 0) { const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun); - if (activeInProgressToolEntries.length > 0) { + // Reasoning groups take the designated-live branch below: entry-level + // in-progress status alone must never animate a superseded thought. + if ( + activeInProgressToolEntries.length > 0 && + !visibleGroupedEntries.every(isReasoningSegmentEntry) + ) { const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; @@ -1135,6 +1232,64 @@ export function deriveMessagesTimelineRows(input: { expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), ); } + } else if (visibleGroupedEntries.every(isReasoningSegmentEntry)) { + // One live thought per turn at most: the designated open segment + // animates while every other thought renders statically beside it. + // Text-bearing segments render as chronological Markdown (no Thought + // accordion). Boundary-only segments keep Thinking / Thought for Xs. + const liveEntry = + activeWorkRow?.active === true + ? undefined + : visibleGroupedEntries.find((entry) => entry.id === liveReasoningWorkEntryId); + for (const entry of visibleGroupedEntries) { + const span = reasoningSegmentSpanForEntry(entry); + const text = entry.detail?.trim() ?? ""; + const isLive = liveEntry !== undefined && entry.id === liveEntry.id; + if (reasoningHasVisibleText(entry) && text.length > 0) { + // Stable across streaming lifecycle merges: turn + reasoning identity, + // not the transient activity/event id that changes on every update. + const reasoningIdentity = + entry.toolCallId !== undefined + ? `${entry.turnId ?? timelineEntry.id}:${entry.toolCallId}` + : `${timelineEntry.id}:${entry.id}`; + nextRows.push({ + kind: "reasoning-markdown", + id: `reasoning-markdown:${reasoningIdentity}`, + createdAt: span.startedAt ?? entry.createdAt, + text, + streaming: isLive, + }); + if (isLive) { + hasActivityRow = true; + } + continue; + } + if (isLive) { + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + nextRows.push({ + kind: "work-live", + id: `work-live:${workGroupIdentity(timelineEntry.id, timelineEntry.entry)}`, + createdAt: timelineEntry.createdAt, + entry: liveEntry!, + groupedEntries: visibleGroupedEntries.filter( + (candidate) => candidate.id === entry.id, + ), + groupId, + expanded: false, + active: true, + }); + hasActivityRow = true; + continue; + } + nextRows.push({ + kind: "work", + id: `thinking-segment:${timelineEntry.id}:${entry.id}`, + createdAt: span.startedAt ?? entry.createdAt, + groupedEntries: [entry], + isExpandedToolGroup: false, + displayLabel: formatThinkingSegmentLabel(span), + }); + } } else if ( visibleGroupedEntries.length === 1 && workLogEntryIsToolLike(visibleGroupedEntries[0]!) @@ -1405,6 +1560,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "worktree-setup": return a.snapshot === (b as typeof a).snapshot; + case "reasoning-markdown": { + const br = b as typeof a; + return a.createdAt === br.createdAt && a.text === br.text && a.streaming === br.streaming; + } + case "assistant-meta": { const bm = b as typeof a; return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 272ad320ad2b..fed907e562e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1409,7 +1409,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time row.kind === "work-live" || row.kind === "work-toggle" || row.kind === "thinking" || - row.kind === "worktree-setup" + row.kind === "worktree-setup" || + row.kind === "reasoning-markdown" ? "pb-2" : "pb-4", (row.kind === "message" && row.message.role === "assistant") || @@ -1445,6 +1446,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "working" ? : null} {row.kind === "thinking" ? : null} {row.kind === "worktree-setup" ? : null} + {row.kind === "reasoning-markdown" ? : null} ); }); @@ -2525,6 +2527,29 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract; +}) { + const ctx = use(TimelineRowCtx); + return ( +
+ +
+ ); +} + function toolGroupSummaryIconName( kind: Extract["summaryKind"], ): WorkEntryIconName { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2dbcaeeb7929..643c51681720 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2497,3 +2497,222 @@ describe("session activity performance", () => { }); }); }); + +describe("reasoning segment derivation", () => { + const reasoningActivity = ( + id: string, + kind: "tool.updated" | "tool.completed", + createdAt: string, + extras?: { detail?: string }, + ) => + makeActivity({ + id, + kind, + summary: "Thinking", + turnId: "turn-reasoning", + createdAt, + payload: { + itemType: "reasoning", + toolCallId: "reasoning-1", + status: kind === "tool.completed" ? "completed" : "inProgress", + title: "Thinking", + ...(extras?.detail !== undefined ? { detail: extras.detail } : {}), + }, + }); + + it("derives thinking tone and merges the lifecycle pair keeping its start", () => { + const entries = deriveWorkLogEntries([ + reasoningActivity("reasoning-updated", "tool.updated", "2026-02-23T00:00:01.000Z"), + reasoningActivity("reasoning-completed", "tool.completed", "2026-02-23T00:00:05.000Z"), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + tone: "thinking", + label: "Thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "completed", + segmentStartedAt: "2026-02-23T00:00:01.000Z", + createdAt: "2026-02-23T00:00:05.000Z", + }); + // Empty/boundary-only reasoning stays structural — no fabricated body. + expect(entries[0]?.detail).toBeUndefined(); + }); + + it("keeps provider reasoning text on the merged thinking segment", () => { + const text = + "The user wants to know their opencode version. I should run the command to check."; + const entries = deriveWorkLogEntries([ + reasoningActivity("reasoning-updated", "tool.updated", "2026-02-23T00:00:01.000Z", { + detail: "The user wants", + }), + reasoningActivity("reasoning-completed", "tool.completed", "2026-02-23T00:00:05.000Z", { + detail: text, + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + tone: "thinking", + label: "Thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "completed", + segmentStartedAt: "2026-02-23T00:00:01.000Z", + createdAt: "2026-02-23T00:00:05.000Z", + detail: text, + }); + }); + + it("concatenates incremental in-progress reasoning detail chunks", () => { + const entries = deriveWorkLogEntries([ + reasoningActivity("reasoning-updated-1", "tool.updated", "2026-02-23T00:00:01.000Z", { + detail: "Start", + }), + reasoningActivity("reasoning-updated-2", "tool.updated", "2026-02-23T00:00:02.000Z", { + detail: " middle", + }), + reasoningActivity("reasoning-updated-3", "tool.updated", "2026-02-23T00:00:03.000Z", { + detail: " end", + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "reasoning-updated-1", + tone: "thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "inProgress", + detail: "Start middle end", + segmentStartedAt: "2026-02-23T00:00:01.000Z", + }); + }); + + it("refreshes completed reasoning detail on a corrective completion", () => { + const entries = deriveWorkLogEntries([ + reasoningActivity("reasoning-updated", "tool.updated", "2026-02-23T00:00:01.000Z", { + detail: "Thinking", + }), + reasoningActivity("reasoning-completed", "tool.completed", "2026-02-23T00:00:05.000Z", { + detail: "Thinking", + }), + reasoningActivity("reasoning-corrected", "tool.completed", "2026-02-23T00:00:06.000Z", { + detail: "Thinking more", + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "reasoning-updated", + toolLifecycleStatus: "completed", + detail: "Thinking more", + segmentStartedAt: "2026-02-23T00:00:01.000Z", + }); + }); + + it("does not invent thought rows from status-less Codex-style reasoning payloads", () => { + // Mirrors Codex summaryPartAdded after ingestion gating: no lifecycle status. + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "codex-reasoning", + kind: "tool.updated", + summary: "Thinking", + turnId: "turn-reasoning", + createdAt: "2026-02-23T00:00:01.000Z", + payload: { + itemType: "reasoning", + toolCallId: "codex-reasoning-1", + data: { text: "summary part" }, + }, + }), + makeActivity({ + id: "codex-tool", + kind: "tool.completed", + summary: "Ran command", + turnId: "turn-reasoning", + createdAt: "2026-02-23T00:00:02.000Z", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "completed", + title: "Ran command", + command: "git status", + }, + }), + ]); + + expect(entries.map((entry) => entry.tone)).toEqual(["thinking", "tool"]); + // Without lifecycle status the Codex-shaped row cannot become the live + // designated segment; timeline presentation treats it as non-live structure. + expect(entries[0]?.toolLifecycleStatus).toBeUndefined(); + expect(entries[0]?.detail).toBeUndefined(); + }); + + it("pairs reasoning timing across interleaved tool activity", () => { + const tool = (id: string, createdAt: string) => + makeActivity({ + id, + kind: "tool.completed", + summary: "Ran command", + turnId: "turn-reasoning", + createdAt, + payload: { + itemType: "command_execution", + toolCallId: `call-${id}`, + status: "completed", + title: "Ran command", + command: "git status", + }, + }); + const entries = deriveWorkLogEntries([ + reasoningActivity("reasoning-updated", "tool.updated", "2026-02-23T00:00:01.000Z"), + tool("tool-1", "2026-02-23T00:00:03.000Z"), + reasoningActivity("reasoning-completed", "tool.completed", "2026-02-23T00:00:05.000Z"), + ]); + + const thought = entries.find((entry) => entry.tone === "thinking"); + expect(thought).toMatchObject({ + toolLifecycleStatus: "completed", + segmentStartedAt: "2026-02-23T00:00:01.000Z", + createdAt: "2026-02-23T00:00:05.000Z", + }); + }); + + it("does not hide reasoning segments as neutral tool rows", () => { + const entries = deriveWorkLogEntries([ + reasoningActivity("reasoning-completed", "tool.completed", "2026-02-23T00:00:05.000Z"), + ]); + + expect(entries).toHaveLength(1); + expect(workEntryIndicatesToolNeutralStatus(entries[0]!)).toBe(false); + }); + + it("still collapses ordinary tool updates into their completion", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "tool-updated", + kind: "tool.updated", + turnId: "turn-reasoning", + payload: { + itemType: "command_execution", + toolCallId: "tool-1", + status: "inProgress", + title: "Render", + }, + }), + makeActivity({ + id: "tool-completed", + kind: "tool.completed", + turnId: "turn-reasoning", + payload: { + itemType: "command_execution", + toolCallId: "tool-1", + status: "completed", + title: "Render", + }, + }), + ]); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ id: "tool-completed", toolLifecycleStatus: "completed" }); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 6a0920681bc3..932c4ef53c36 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -13,6 +13,8 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, extractWorkLogToolLifecycleStatus, + isReasoningItemPayload, + isReasoningSegmentEntry, isWorktreeSetupActivity, workEntryIndicatesToolFailure, workEntryIndicatesToolSuccess, @@ -76,6 +78,12 @@ export interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; /** From runtime item / task payload `status` when present (e.g. tool.updated). */ toolLifecycleStatus?: WorkLogToolLifecycleStatus; + /** + * Reasoning segment start, preserved when lifecycle updates merge into + * their completion. Lets the timeline bound "Thought for Xs" even when + * tool activity interleaves between the segment's start and end. + */ + segmentStartedAt?: string; /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */ sourceActivityKind?: OrchestrationThreadActivity["kind"]; /** Grouping key for subagent lifecycle rows (one row per agent). */ @@ -177,6 +185,12 @@ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolea if (entry.agentSpawn !== undefined) { return false; } + // Reasoning segments are structural boundaries, not tool output: a + // completed thought renders its duration, and an interrupted one keeps the + // honest "Thinking" row where the turn stopped. + if (isReasoningSegmentEntry(entry)) { + return false; + } if (!workLogEntryIsToolLike(entry)) { return false; } @@ -563,14 +577,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo ? payload.detail : null; const taskLabel = taskSummary || taskDetailAsLabel; - const detail = isTaskActivity - ? !taskDetailAsLabel && - payload && - typeof payload.detail === "string" && - payload.detail.length > 0 - ? stripTrailingExitCode(payload.detail).output + // Reasoning lifecycle rides the tool activity kinds with a `reasoning` + // item type; clients render it as thinking segments, never as tool rows. + const isReasoningSegment = isReasoningItemPayload(payload); + // Provider reasoning text is opaque — do not run command-output stripping. + const detail = isReasoningSegment + ? typeof payload?.detail === "string" && payload.detail.length > 0 + ? payload.detail : null - : extractToolDetail(payload, title ?? activity.summary); + : isTaskActivity + ? !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ? stripTrailingExitCode(payload.detail).output + : null + : extractToolDetail(payload, title ?? activity.summary); const toolCallId = isTaskActivity ? null : extractToolCallId(payload); const entry: DerivedWorkLogEntry = { id: activity.id, @@ -578,7 +600,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo turnId: activity.turnId, label: taskLabel || activity.summary, tone: - activity.kind === "task.progress" + activity.kind === "task.progress" || isReasoningSegment ? "thinking" : activity.tone === "approval" ? "info" @@ -822,7 +844,14 @@ function shouldCollapseToolLifecycleEntries( return false; } if (previous.sourceActivityKind === "tool.completed") { - return false; + // Allow corrective reasoning completions (OpenCode can edit a finished + // reasoning part on reconnect) to refresh terminal detail in place. + return ( + isReasoningSegmentEntry(previous) && + isReasoningSegmentEntry(next) && + previous.toolCallId !== undefined && + previous.toolCallId === next.toolCallId + ); } if ( previous[workLogCollapseKey] !== undefined && @@ -844,7 +873,18 @@ function mergeDerivedWorkLogEntries( next: DerivedWorkLogEntry, ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); - const detail = next.detail ?? previous.detail; + // OpenCode streams inProgress reasoning detail as incremental chunks. + // Concatenate those; completions (and corrective edits) replace with the + // full terminal body. + const reasoningMerge = isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next); + const detail = + reasoningMerge && + previous.toolLifecycleStatus === "inProgress" && + next.toolLifecycleStatus === "inProgress" && + previous.detail !== undefined && + next.detail !== undefined + ? `${previous.detail}${next.detail}` + : (next.detail ?? previous.detail); const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; @@ -858,9 +898,19 @@ function mergeDerivedWorkLogEntries( const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; const toolData = next.toolData ?? previous.toolData; + // A reasoning completion never carries its segment's start, so keep the + // earliest observed time across the merge. Interleaved tool activity can't + // break the pairing: collapse keys on identity, not adjacency. + const segmentStartedAt = + next.segmentStartedAt ?? + previous.segmentStartedAt ?? + (reasoningMerge ? previous.createdAt : undefined); return { ...previous, ...next, + // Keep a stable id across reasoning merges so list virtualization does + // not remount the Markdown row on every incremental chunk. + ...(reasoningMerge ? { id: previous.id } : {}), ...(detail ? { detail } : {}), ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), @@ -876,6 +926,7 @@ function mergeDerivedWorkLogEntries( ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(segmentStartedAt ? { segmentStartedAt } : {}), }; } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index a0909117ad39..849675d90ec7 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -5,11 +5,16 @@ import { ThreadId } from "@t3tools/contracts"; import { commandDetailRepeatsCommand, extractCommandOutputText, + formatThinkingSegmentLabel, + isReasoningSegmentEntry, + reasoningSegmentElapsedMs, + reasoningSegmentSpanForEntry, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, + type ReasoningSegmentEntryLike, type WorkLogPresentationEntry, workEntryViewedImagePath, workEntryIndicatesToolFailure, @@ -710,3 +715,93 @@ describe("device group summaries", () => { ).toBe("Used 1 tool"); }); }); + +describe("reasoning segments", () => { + const thinking = ( + overrides: Partial & + Partial & { label: string }, + ): WorkLogPresentationEntry & ReasoningSegmentEntryLike => ({ + tone: "thinking", + sourceActivityKind: "tool.completed", + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }); + + it("recognizes thinking tool lifecycle rows but not task progress rows", () => { + expect(isReasoningSegmentEntry(thinking({ label: "Thinking" }))).toBe(true); + expect( + isReasoningSegmentEntry(thinking({ label: "Thinking", sourceActivityKind: "tool.updated" })), + ).toBe(true); + // Subagent progress shares the thinking tone but is a different narrative. + expect( + isReasoningSegmentEntry( + thinking({ label: "Reasoning update", sourceActivityKind: "task.progress" }), + ), + ).toBe(false); + expect(isReasoningSegmentEntry({ tone: "tool", sourceActivityKind: "tool.completed" })).toBe( + false, + ); + }); + + it("bounds one entry's segment from its merged timing", () => { + // Lifecycle pairs merge before the timeline: the terminal entry carries + // the preserved start plus its own end. + expect( + reasoningSegmentSpanForEntry( + thinking({ + label: "Thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "completed", + createdAt: "2026-01-01T00:00:04.000Z", + segmentStartedAt: "2026-01-01T00:00:00.000Z", + }), + ), + ).toEqual({ + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:00:04.000Z", + completed: true, + }); + // A dangling in-progress entry is an open thought with no known end. + expect( + reasoningSegmentSpanForEntry( + thinking({ + label: "Thinking", + sourceActivityKind: "tool.updated", + toolCallId: "reasoning-2", + toolLifecycleStatus: "inProgress", + createdAt: "2026-01-01T00:00:10.000Z", + }), + ), + ).toEqual({ + startedAt: "2026-01-01T00:00:10.000Z", + endedAt: null, + completed: false, + }); + }); + + it("measures elapsed time only between parseable timestamps", () => { + expect(reasoningSegmentElapsedMs("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:04.000Z")).toBe( + 4000, + ); + expect(reasoningSegmentElapsedMs(null, "2026-01-01T00:00:04.000Z")).toBeNull(); + expect(reasoningSegmentElapsedMs("not-a-date", "2026-01-01T00:00:04.000Z")).toBeNull(); + }); + + it("labels completed segments with durations and static Thoughts otherwise", () => { + expect( + formatThinkingSegmentLabel({ + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:00:04.000Z", + }), + ).toBe("Thought for 4.0s"); + // Same-flush lifecycle pairs and untimed providers carry no real elapsed + // time; history never renders as live "Thinking". + expect( + formatThinkingSegmentLabel({ + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:00:00.200Z", + }), + ).toBe("Thought"); + expect(formatThinkingSegmentLabel({ startedAt: null, endedAt: null })).toBe("Thought"); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index bfb04eda3247..501cc62999c9 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -10,6 +10,7 @@ import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-im import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; @@ -358,6 +359,102 @@ export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); } +/** + * Raw activity payloads carry the canonical item type. Reasoning lifecycle + * arrives through the tool activity kinds with itemType "reasoning". + */ +export function isReasoningItemPayload(payload: unknown): boolean { + return ( + payload !== null && + typeof payload === "object" && + !Array.isArray(payload) && + (payload as Record).itemType === "reasoning" + ); +} + +/** + * Provider thinking is surfaced as lifecycle activity. Adapters emit it + * through the tool activity kinds with a `reasoning` item type, and the + * clients derive the thinking tone from that. Optional provider-supplied + * reasoning text rides lifecycle `detail` when present; empty reasoning stays + * structural only. Subagent progress rows share the thinking tone but ride + * `task.progress`, so the kind check keeps them out of segment handling. + */ +export function isReasoningSegmentEntry( + entry: Pick, +): boolean { + return ( + entry.tone === "thinking" && + (entry.sourceActivityKind === "tool.updated" || entry.sourceActivityKind === "tool.completed") + ); +} + +/** Provider-supplied readable reasoning body (not fabricated, not structural-only). */ +export function reasoningHasVisibleText( + entry: Pick, +): boolean { + return isReasoningSegmentEntry(entry) && Boolean(entry.detail?.trim()); +} + +export interface ReasoningSegmentSpan { + /** Segment start: native thinking start when observed, else first sighting. */ + readonly startedAt: string | null; + /** Segment end when a terminal lifecycle update arrived. */ + readonly endedAt: string | null; + readonly completed: boolean; +} + +/** Minimum shape for reasoning segment timing. */ +export interface ReasoningSegmentEntryLike { + readonly createdAt: string; + readonly toolCallId?: string | undefined; + readonly toolLifecycleStatus?: string | undefined; + /** Preserved across lifecycle merges: the segment's first observed time. */ + readonly segmentStartedAt?: string | undefined; +} + +/** + * Bounds one reasoning entry's segment. Lifecycle pairs merge before they + * reach the timeline, so a terminal entry carries both ends; a dangling + * in-progress entry is an open thought with no known end. + */ +export function reasoningSegmentSpanForEntry( + entry: ReasoningSegmentEntryLike, +): ReasoningSegmentSpan { + const completed = + entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress"; + return { + startedAt: entry.segmentStartedAt ?? entry.createdAt, + endedAt: completed ? entry.createdAt : null, + completed, + }; +} + +/** Elapsed milliseconds between two ISO timestamps, or null when unparseable. */ +export function reasoningSegmentElapsedMs( + startedAt: string | null, + endedAt: string | null, +): number | null { + if (!startedAt || !endedAt) return null; + const start = Date.parse(startedAt); + const end = Date.parse(endedAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return Math.max(0, end - start); +} + +/** + * Compact settled label for a thinking segment. Only "Thinking" is ever + * live; history renders "Thought for Xs" with real timing, or a static + * "Thought" when no accurate duration exists. + */ +export function formatThinkingSegmentLabel( + span: Pick, +): string { + const elapsedMs = reasoningSegmentElapsedMs(span.startedAt, span.endedAt); + if (elapsedMs === null || elapsedMs < 1_000) return "Thought"; + return `Thought for ${formatDuration(elapsedMs)}`; +} + /** Maps item and task status to the status shown on a work-log row. */ export function extractWorkLogToolLifecycleStatus( payloadValue: unknown,