From 135dd4a7e066ccd1f5ad4cf072c12f984d094312 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:27:24 -0700 Subject: [PATCH 01/14] fix(chat): preserve OpenCode reasoning segments as timeline boundaries OpenCode reasoning parts only ever surfaced as reasoning_text deltas, which shared ingestion intentionally drops, so long agent turns collapsed into one opaque Thinking state plus a giant tool pile - even though native part identity and time metadata already describe thought/tool/thought structure (notably for models with empty reasoning text). The OpenCode adapter now emits the existing canonical reasoning item lifecycle (item.updated inProgress on first sight, item.completed on native time.end, never text), ingestion projects reasoning items to tool-kind activities provider-neutrally, snapshots keep the update/completion pairs reloads need for durations, and web/mobile break tool groups at thinking boundaries rendering compact Thought for Ns rows plus a live Thinking state. --- apps/mobile/src/lib/threadActivity.test.ts | 171 ++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 106 +++++++++- .../ActivityPayloadProjection.test.ts | 64 +++++- .../ActivityPayloadProjection.ts | 8 + .../ProviderRuntimeIngestion.activity.test.ts | 77 +++++++ .../Layers/ProviderRuntimeIngestion.ts | 15 +- .../provider/Layers/OpenCodeAdapter.test.ts | 152 +++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 55 +++++ .../chat/MessagesTimeline.logic.test.ts | 193 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 26 +++ apps/web/src/session-logic.test.ts | 83 ++++++++ apps/web/src/session-logic.ts | 25 ++- .../src/work-log/presentation.test.ts | 123 +++++++++++ .../src/work-log/presentation.ts | 128 ++++++++++++ 14 files changed, 1218 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4e301288c94f..e119a4a95079 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3451,3 +3451,174 @@ it("keeps attachment-only question answers expandable outside mobile work groups expect(running[1]).toBe(group); expect(running[2]?.type).toBe("work-toggle"); }); + +describe("reasoning segments", () => { + 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, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: "Thinking", + tone: "tool", + createdAt: at(second), + turnId, + 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("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); + expect(details[0]?.type === "activity-group" && details[0].activities).toHaveLength(2); + }); + + 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", "Thinking"]); + }); + + 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..291e1148dfdb 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -19,10 +19,15 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, extractWorkLogToolLifecycleStatus, + formatThinkingSegmentLabel, + groupReasoningSegmentEntries, + isReasoningItemPayload, + isReasoningSegmentEntry, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, + representativeReasoningSegmentEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, @@ -494,7 +499,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" @@ -809,6 +814,11 @@ function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undef ) { return undefined; } + // Reasoning updates pair with their completion in the feed for durations; + // merging them here would erase the segment start (mirrors web). + if (isReasoningSegmentEntry(entry)) { + return undefined; + } return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; } @@ -816,6 +826,9 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { + if (isReasoningSegmentEntry(previous) || isReasoningSegmentEntry(next)) { + return false; + } if ( previous.sourceActivityKind !== "tool.updated" && previous.sourceActivityKind !== "tool.completed" @@ -903,6 +916,10 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return undefined; } + // See toolLifecycleCollapseMapKey: reasoning pairs must reach the feed. + if (isReasoningSegmentEntry(entry)) { + return undefined; + } if (entry.toolCallId) { return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; } @@ -1902,6 +1919,9 @@ function appendActivityGroupRows( 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), @@ -1928,6 +1948,15 @@ function appendActivityGroupRows( 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). + const reasoning = isReasoningSegmentEntry(activity.workEntry); + if ( + groupableRun.length > 0 && + isReasoningSegmentEntry(groupableRun.at(-1)!.workEntry) !== reasoning + ) { + flushGroupableRun(false); + } groupableRun.push(activity); continue; } @@ -1973,6 +2002,19 @@ function appendToolGroupRows( : activities[0]!.id; const groupId = `work-group:${identity}`; const expanded = expandedWorkGroupIds.has(groupId); + if (activities.every((activity) => isReasoningSegmentEntry(activity.workEntry))) { + appendThinkingSegmentRows( + result, + sourceGroup, + activities, + groupId, + expanded, + unsettledTurnId, + isWorking, + activeTail, + ); + return; + } const latestActiveActivity = activities.findLast( (activity) => isWorking && @@ -2069,6 +2111,68 @@ function appendToolGroupRows( }); } +/** + * Settled thinking renders one compact row per thought ("Thought for 4s"); + * the 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, + unsettledTurnId: TurnId | null, + isWorking: boolean, + activeTail: boolean, +): void { + const segments = groupReasoningSegmentEntries(activities.map((activity) => activity.workEntry)); + segments.forEach((segment, segmentIndex) => { + const representative = representativeReasoningSegmentEntry(segment.entries); + const activity = activities.find((candidate) => candidate.workEntry === representative)!; + const live = + isWorking && + segment.entries.some( + (entry) => entry.toolLifecycleStatus === "inProgress" && entry.turnId === unsettledTurnId, + ); + const shimmer = live && activeTail && segmentIndex === segments.length - 1; + 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}:${segment.span.toolCallId ?? activity.id}`, + createdAt: segment.span.startedAt ?? activity.createdAt, + turnId: sourceGroup.turnId, + groupId, + hiddenCount: segment.entries.length, + expanded, + summary: live ? "Thinking" : formatThinkingSegmentLabel(segment.span), + summaryKind: toolGroupSummaryKind(segment.entries), + hasFailure: false, + live, + shimmer, + }); + if (!expanded) { + return; + } + result.push({ + type: "activity-group", + id: `work-details:${groupId}:${segment.span.toolCallId ?? activity.id}`, + createdAt: segment.entries[0]!.createdAt, + turnId: segment.entries[0]!.turnId, + activities: segment.entries.map((entry) => ({ + ...activities.find((candidate) => candidate.workEntry === entry)!, + groupedToolDetail: true, + live: + isWorking && + entry.toolLifecycleStatus === "inProgress" && + entry.turnId === unsettledTurnId, + })), + }); + }); +} + 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..49db9ccd8762 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,62 @@ 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 reasoning updates their 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 pair must + // survive reloads: clients bound segment durations from the update. + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + "reasoning-updated", + "tool-completed", + "reasoning-completed", + ]); + }); +}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0525aae7b72b..9ac26ddcbc14 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -634,6 +634,14 @@ function dropSupersededToolUpdatedActivities( if (activity.kind !== "tool.updated") { return true; } + // Reasoning updates are structural, not streaming noise: clients pair + // each update with its completion to bound the thinking segment's + // duration, and the completion alone carries no start time. Segments are + // rare (one pair per thought) next to per-chunk tool updates, so keeping + // them costs nothing. + if (asRecord(activity.payload)?.itemType === "reasoning") { + return true; + } const identity = toolLifecycleIdentity(activity); if (!identity) { return true; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts index 27564eb572ca..e07fe3c0fbbf 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,79 @@ 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("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([]); + } + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d7ae589047bf..0b50ec31b01c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -848,7 +848,14 @@ 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. Reasoning text itself never arrives + // here (content.delta drops non-assistant text above), and adapters must + // not put it in lifecycle detail either. + if ( + !isToolLifecycleItemType(event.payload.itemType) && + event.payload.itemType !== "reasoning" + ) { return []; } // A streaming update's `data` carries the full tool output accumulated @@ -887,7 +894,11 @@ export function runtimeEventToActivities( } case "item.completed": { - if (!isToolLifecycleItemType(event.payload.itemType)) { + // See item.updated above: reasoning lifecycle becomes thinking activity. + if ( + !isToolLifecycleItemType(event.payload.itemType) && + event.payload.itemType !== "reasoning" + ) { return []; } return [ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index a6636fcd69d0..aaf5164ffdef 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"; @@ -7116,11 +7117,156 @@ 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", undefined], + ["assistant_message", "Hello world"], + ["assistant_message", "Fresh"], + ["assistant_message", "Second"], + ["reasoning", undefined], + ["assistant_message", "New"], + ], ); + // Reasoning parts project lifecycle boundaries without leaking text: + // one in-progress update per part sighting, then a completion. The + // post-removal replay emits a second pair for the fresh part state. + const reasoningUpdates = events + .filter((event) => event.type === "item.updated") + .filter((event) => event.payload.itemType === "reasoning"); + NodeAssert.equal(reasoningUpdates.length, 2); + 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.payload.detail, undefined); + 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.equal(reasoningCompletions.length, 2); + 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.payload.detail, undefined); + NodeAssert.equal(completed.createdAt, "1970-01-01T00:00:00.002Z"); + } yield* adapter.stopSession(threadId); - }), + }).pipe(Effect.scoped), + ); + + it.effect("emits reasoning segment boundaries for empty reasoning text around a tool", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-empty-reasoning"); + const sessionID = "http://127.0.0.1:9999/session"; + 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: 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), + 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 isItemLifecycle = ( + 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 lifecycle = events.filter(isItemLifecycle); + NodeAssert.deepEqual( + lifecycle.map((event) => [ + event.type, + event.itemId, + event.payload.itemType, + event.payload.status, + ]), + [ + ["item.updated", "reasoning-1", "reasoning", "inProgress"], + ["item.updated", "call-bash", "command_execution", "inProgress"], + ["item.completed", "call-bash", "command_execution", "completed"], + ["item.completed", "reasoning-1", "reasoning", "completed"], + ["item.updated", "reasoning-2", "reasoning", "inProgress"], + ], + ); + // 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[3]!; + NodeAssert.equal(firstReasoning.createdAt, "1970-01-01T00:00:00.100Z"); + NodeAssert.equal(reasoningCompletion.createdAt, "1970-01-01T00:00:00.200Z"); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), ); it.effect("maps native task progress only while a turn is active", () => diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 3d216bb1167b..3cb9177eeefc 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">; @@ -612,6 +613,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); @@ -1591,6 +1593,58 @@ export function makeOpenCodeAdapter( yield* Scope.close(context.sessionScope, Exit.void); }); + /** Emit reasoning lifecycle (item.updated/item.completed) for a reasoning part. */ + const emitReasoningSegmentEvent = Effect.fn("emitReasoningSegmentEvent")(function* ( + context: OpenCodeSessionContext, + part: OpenCodeTextPartState, + turnId: TurnId | undefined, + raw: unknown, + ) { + if (part.type !== "reasoning") { + return; + } + // Lifecycle only, never text: reasoning content stays provider-private + // (see the reasoning_text drop in ProviderRuntimeIngestion). Native part + // identity and time metadata still give clients structural boundaries — + // thought/tool/thought — including for models with empty reasoning text. + if (!part.reasoningStarted) { + part.reasoningStarted = true; + 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", + }, + }); + } + if (part.time?.end !== undefined && !part.completed) { + part.completed = true; + 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", + }, + }); + } + }); + /** Emit content.delta and item.completed events for an assistant text part. */ const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( context: OpenCodeSessionContext, @@ -1598,6 +1652,7 @@ export function makeOpenCodeAdapter( turnId: TurnId | undefined, raw: unknown, ) { + yield* emitReasoningSegmentEvent(context, part, turnId, raw); if (part.text === undefined) { return; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 59b4a0c9856b..7e45f0fa7ecf 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,194 @@ 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 thinking = ( + id: string, + kind: "updated" | "completed", + second: number, + toolCallId = id, + ): WorkLogEntry => ({ + id, + createdAt: time(second), + turnId, + label: "Thinking", + tone: "thinking", + toolCallId, + toolLifecycleStatus: kind === "updated" ? "inProgress" : "completed", + sourceActivityKind: kind === "updated" ? "tool.updated" : "tool.completed", + }); + const tool = (id: string, second: number): WorkLogEntry => ({ + id, + createdAt: time(second), + turnId, + tone: "tool", + label: "Ran command", + command: "git status", + toolCallId: `call-${id}`, + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + 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]) } : {}), + }); + + it("splits tool groups at thinking boundaries instead of one giant pile", () => { + const rows = deriveMessagesTimelineRows( + settledInput([ + thinking("thought-1-updated", "updated", 0, "thought-1"), + thinking("thought-1-completed", "completed", 4, "thought-1"), + tool("tool-1", 5), + tool("tool-2", 6), + tool("tool-3", 7), + thinking("thought-2-updated", "updated", 8, "thought-2"), + thinking("thought-2-completed", "completed", 15, "thought-2"), + tool("tool-4", 16), + tool("tool-5", 17), + ]), + ); + + 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("keeps twenty-plus tool calls in small groups when thoughts intervene", () => { + const work: WorkLogEntry[] = []; + for (let segment = 0; segment < 4; segment += 1) { + const base = segment * 10; + work.push( + thinking(`thought-${segment}-updated`, "updated", base, `thought-${segment}`), + thinking(`thought-${segment}-completed`, "completed", base + 2, `thought-${segment}`), + ); + for (let call = 0; call < 6; call += 1) { + work.push(tool(`tool-${segment}-${call}`, base + 3 + call)); + } + } + const rows = deriveMessagesTimelineRows(settledInput(work)); + 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 userMessage: ChatMessage = { + id: MessageId.make("segment-user"), + role: "user", + text: "Inspect", + turnId: null, + createdAt: time(0), + updatedAt: time(0), + streaming: false, + }; + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntries( + [userMessage], + [], + [tool("tool-1", 6), thinking("thought-live", "updated", 7, "thought-live")], + ), + latestTurn: { turnId, state: "running", startedAt: time(0), completedAt: null }, + runningTurnId: turnId, + isWorking: true, + activeTurnStartedAt: time(0), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + const live = rows.find((row) => row.kind === "work-live"); + expect(live).toMatchObject({ + active: true, + entry: { id: "thought-live", toolLifecycleStatus: "inProgress" }, + }); + // Segment-scoped timing: the live thought counts from the native + // reasoning start, not the turn start. + expect(live?.kind === "work-live" && live.entry.createdAt).toBe(time(7)); + expect(live?.kind === "work-live" && liveWorkEntryLabel(live.entry, undefined, true)).toBe( + "Thinking", + ); + expect(rows.some((row) => row.kind === "thinking")).toBe(false); + }); + + it("renders an interrupted thought without inventing a duration", () => { + const rows = deriveMessagesTimelineRows( + settledInput([tool("tool-1", 5), thinking("thought-live", "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: "Thinking" }); + }); + + it("folds settled thoughts away with their turn", () => { + const rows = deriveMessagesTimelineRows( + settledInput( + [ + thinking("thought-1-updated", "updated", 0, "thought-1"), + thinking("thought-1-completed", "completed", 4, "thought-1"), + tool("tool-1", 5), + ], + false, + ), + ); + + expect(rows.map((row) => row.kind)).toEqual(["turn-fold"]); + }); + + it("projects canonical reasoning activities into thought rows without text", () => { + const activity = ( + id: string, + kind: "tool.updated" | "tool.completed", + second: number, + ): OrchestrationThreadActivity => + ({ + id: EventId.make(id), + tone: "tool", + kind, + summary: "Thinking", + payload: { + itemType: "reasoning", + toolCallId: "reasoning-e2e", + status: kind === "tool.completed" ? "completed" : "inProgress", + title: "Thinking", + }, + turnId, + createdAt: time(second), + }) as unknown as OrchestrationThreadActivity; + const entries = deriveWorkLogEntries([ + activity("reasoning-e2e-updated", "tool.updated", 0), + activity("reasoning-e2e-completed", "tool.completed", 4), + ]); + 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..30550f8dab2d 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, + groupReasoningSegmentEntries, + isReasoningSegmentEntry, + representativeReasoningSegmentEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, @@ -1104,6 +1108,14 @@ 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. + if ( + isReasoningSegmentEntry(groupedEntries[groupedEntries.length - 1]!) !== + isReasoningSegmentEntry(nextEntry.entry) + ) { + break; + } groupedEntries.push(nextEntry.entry); cursor += 1; } @@ -1135,6 +1147,20 @@ export function deriveMessagesTimelineRows(input: { expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), ); } + } else if (visibleGroupedEntries.every(isReasoningSegmentEntry)) { + // Settled thinking, one compact row per thought: "Thought for 4s". + // Live thoughts take the work-live branch above instead. + for (const segment of groupReasoningSegmentEntries(visibleGroupedEntries)) { + const representative = representativeReasoningSegmentEntry(segment.entries); + nextRows.push({ + kind: "work", + id: `thinking-segment:${timelineEntry.id}:${segment.span.toolCallId ?? representative.id}`, + createdAt: segment.span.startedAt ?? representative.createdAt, + groupedEntries: [representative], + isExpandedToolGroup: false, + displayLabel: formatThinkingSegmentLabel(segment.span), + }); + } } else if ( visibleGroupedEntries.length === 1 && workLogEntryIsToolLike(visibleGroupedEntries[0]!) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2dbcaeeb7929..e353de1ab829 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2497,3 +2497,86 @@ describe("session activity performance", () => { }); }); }); + +describe("reasoning segment derivation", () => { + const reasoningActivity = ( + id: string, + kind: "tool.updated" | "tool.completed", + createdAt: string, + ) => + makeActivity({ + id, + kind, + summary: "Thinking", + turnId: "turn-reasoning", + createdAt, + payload: { + itemType: "reasoning", + toolCallId: "reasoning-1", + status: kind === "tool.completed" ? "completed" : "inProgress", + title: "Thinking", + }, + }); + + it("derives thinking tone and keeps the lifecycle pair uncollapsed", () => { + 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(2); + expect(entries[0]).toMatchObject({ + tone: "thinking", + label: "Thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "inProgress", + }); + expect(entries[1]).toMatchObject({ + tone: "thinking", + toolLifecycleStatus: "completed", + }); + // No reasoning text may hitch a ride: labels and details stay structural. + for (const entry of entries) { + expect(entry.detail).toBeUndefined(); + } + }); + + 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..49e022c214d9 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, @@ -177,6 +179,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; } @@ -572,13 +580,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : null : extractToolDetail(payload, title ?? activity.summary); const toolCallId = isTaskActivity ? null : extractToolCallId(payload); + // 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); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, turnId: activity.turnId, label: taskLabel || activity.summary, tone: - activity.kind === "task.progress" + activity.kind === "task.progress" || isReasoningSegment ? "thinking" : activity.tone === "approval" ? "info" @@ -707,6 +718,11 @@ function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undef ) { return undefined; } + // Reasoning updates pair with their completion in the timeline for + // durations; merging them here would erase the segment start. + if (isReasoningSegmentEntry(entry)) { + return undefined; + } return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; } @@ -809,6 +825,9 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { + if (isReasoningSegmentEntry(previous) || isReasoningSegmentEntry(next)) { + return false; + } if ( previous.sourceActivityKind !== "tool.updated" && previous.sourceActivityKind !== "tool.completed" @@ -905,6 +924,10 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return undefined; } + // See toolLifecycleCollapseMapKey: reasoning pairs must reach the timeline. + if (isReasoningSegmentEntry(entry)) { + return undefined; + } if (entry.toolCallId) { return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index a0909117ad39..3e2d2dc2f0d1 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -5,11 +5,17 @@ import { ThreadId } from "@t3tools/contracts"; import { commandDetailRepeatsCommand, extractCommandOutputText, + formatThinkingSegmentLabel, + groupReasoningSegmentEntries, + isReasoningSegmentEntry, + reasoningSegmentElapsedMs, + representativeReasoningSegmentEntry, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, + type ReasoningSegmentEntryLike, type WorkLogPresentationEntry, workEntryViewedImagePath, workEntryIndicatesToolFailure, @@ -710,3 +716,120 @@ 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("pairs in-progress and completed updates by provider identity", () => { + const segments = groupReasoningSegmentEntries([ + thinking({ + label: "Thinking", + sourceActivityKind: "tool.updated", + toolCallId: "reasoning-1", + toolLifecycleStatus: "inProgress", + createdAt: "2026-01-01T00:00:00.000Z", + }), + thinking({ + label: "Thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "completed", + createdAt: "2026-01-01T00:00:04.000Z", + }), + thinking({ + label: "Thinking", + sourceActivityKind: "tool.updated", + toolCallId: "reasoning-2", + toolLifecycleStatus: "inProgress", + createdAt: "2026-01-01T00:00:10.000Z", + }), + ]); + expect(segments).toHaveLength(2); + expect(segments[0]!.span).toEqual({ + toolCallId: "reasoning-1", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:00:04.000Z", + completed: true, + }); + expect(segments[1]!.span).toEqual({ + toolCallId: "reasoning-2", + startedAt: "2026-01-01T00:00:10.000Z", + endedAt: null, + completed: false, + }); + }); + + it("keeps identity-less entries from fusing segments", () => { + const segments = groupReasoningSegmentEntries([ + thinking({ label: "Thinking", createdAt: "2026-01-01T00:00:00.000Z" }), + thinking({ label: "Thinking", createdAt: "2026-01-01T00:00:01.000Z" }), + ]); + expect(segments).toHaveLength(2); + }); + + it("renders a segment from its completion when one arrived", () => { + const updated = thinking({ + label: "Thinking", + sourceActivityKind: "tool.updated", + toolCallId: "reasoning-1", + toolLifecycleStatus: "inProgress", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const completed = thinking({ + label: "Thinking", + toolCallId: "reasoning-1", + toolLifecycleStatus: "completed", + createdAt: "2026-01-01T00:00:04.000Z", + }); + expect(representativeReasoningSegmentEntry([updated, completed])).toBe(completed); + expect(representativeReasoningSegmentEntry([updated])).toBe(updated); + }); + + 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 hides sub-second noise", () => { + 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. + expect( + formatThinkingSegmentLabel({ + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:00:00.200Z", + }), + ).toBe("Thinking"); + expect(formatThinkingSegmentLabel({ startedAt: null, endedAt: null })).toBe("Thinking"); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index bfb04eda3247..19d59111296a 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,133 @@ 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 surfaced as lifecycle only, never text. Adapters emit it + * through the tool activity kinds with a `reasoning` item type, and the + * clients derive the thinking tone from that. 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") + ); +} + +export interface ReasoningSegmentSpan { + /** Native segment identity when the provider reported one. */ + readonly toolCallId: string | undefined; + /** Earliest observed segment time (native thinking start when known). */ + readonly startedAt: string | null; + /** Segment end when a terminal lifecycle update arrived. */ + readonly endedAt: string | null; + readonly completed: boolean; +} + +/** Minimum shape for pairing lifecycle updates into reasoning segments. */ +export interface ReasoningSegmentEntryLike { + readonly createdAt: string; + readonly toolCallId?: string | undefined; + readonly toolLifecycleStatus?: string | undefined; +} + +/** + * Pairs a reasoning group's entries into per-segment spans by provider + * identity, preserving order. Entries without an identity stand alone so a + * missing id can never fuse two segments' timing. + */ +export function groupReasoningSegmentEntries( + entries: ReadonlyArray, +): Array<{ readonly entries: T[]; readonly span: ReasoningSegmentSpan }> { + const grouped: T[][] = []; + const indexByToolCallId = new Map(); + for (const entry of entries) { + const key = entry.toolCallId; + const index = key === undefined ? undefined : indexByToolCallId.get(key); + if (index === undefined) { + if (key !== undefined) indexByToolCallId.set(key, grouped.length); + grouped.push([entry]); + continue; + } + grouped[index]!.push(entry); + } + return grouped.map((groupEntries) => ({ + entries: groupEntries, + span: spanForReasoningSegmentEntries(groupEntries), + })); +} + +function spanForReasoningSegmentEntries( + entries: ReadonlyArray, +): ReasoningSegmentSpan { + const terminal = entries.findLast( + (entry) => + entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress", + ); + return { + toolCallId: entries[0]?.toolCallId, + startedAt: entries[0]?.createdAt ?? null, + endedAt: terminal?.createdAt ?? null, + completed: terminal !== undefined, + }; +} + +/** + * The entry a settled segment row renders: its completion when one arrived, + * else its latest update. Segments are never empty. + */ +export function representativeReasoningSegmentEntry( + entries: ReadonlyArray, +): T { + return ( + entries.findLast( + (entry) => + entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress", + ) ?? entries.at(-1)! + ); +} + +/** 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. Durations below a second + * stay a plain "Thinking": same-flush lifecycle pairs and untimed providers + * carry no meaningful elapsed time, and sub-second thoughts need no duration. + */ +export function formatThinkingSegmentLabel( + span: Pick, +): string { + const elapsedMs = reasoningSegmentElapsedMs(span.startedAt, span.endedAt); + if (elapsedMs === null || elapsedMs < 1_000) return "Thinking"; + return `Thought for ${formatDuration(elapsedMs)}`; +} + /** Maps item and task status to the status shown on a work-log row. */ export function extractWorkLogToolLifecycleStatus( payloadValue: unknown, From 945bfea52da920adc205fdb980f48b435b08ee68 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:34:14 -0700 Subject: [PATCH 02/14] fix(chat): finalize superseded reasoning segments instead of leaving them live Historical thoughts rendered as animated Thinking rows: completions only arrived on native time.end (often turn end), and any in-progress entry of the working turn took the live branch, so commentary-split turns animated several thoughts at once. The adapter now closes the single open thought when a tool starts, commentary flows, a new thought begins, or the turn settles (native end preferred, observation time otherwise). Lifecycle pairs merge by identity preserving the segment start, so interleaved tools cannot split timing; timelines designate exactly one live thought and render history statically as Thought for Xs (or Thought without accurate timing). --- apps/mobile/src/lib/threadActivity.test.ts | 118 ++++++- apps/mobile/src/lib/threadActivity.ts | 205 ++++++++---- .../provider/Layers/OpenCodeAdapter.test.ts | 304 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 69 ++++ .../chat/MessagesTimeline.logic.test.ts | 272 ++++++++++------ .../components/chat/MessagesTimeline.logic.ts | 100 +++++- apps/web/src/session-logic.test.ts | 44 ++- apps/web/src/session-logic.ts | 28 +- .../src/work-log/presentation.test.ts | 92 ++---- .../src/work-log/presentation.ts | 75 ++--- 10 files changed, 991 insertions(+), 316 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e119a4a95079..28a4873818b6 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3557,7 +3557,8 @@ describe("reasoning segments", () => { expect(summaries(rows)).toEqual(["Thought for 4.0s"]); const details = rows.filter((row) => row.type === "activity-group"); expect(details).toHaveLength(1); - expect(details[0]?.type === "activity-group" && details[0].activities).toHaveLength(2); + // 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", () => { @@ -3602,7 +3603,120 @@ describe("reasoning segments", () => { new Set([turnId]), ); - expect(summaries(rows)).toEqual(["Ran command", "Thinking"]); + 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("folds settled thoughts away with their turn", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 291e1148dfdb..a5f5e84f4478 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -20,14 +20,13 @@ import { extractCommandOutputText, extractWorkLogToolLifecycleStatus, formatThinkingSegmentLabel, - groupReasoningSegmentEntries, isReasoningItemPayload, isReasoningSegmentEntry, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, - representativeReasoningSegmentEntry, + reasoningSegmentSpanForEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, @@ -106,6 +105,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 @@ -251,6 +256,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; } >(); @@ -814,11 +821,6 @@ function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undef ) { return undefined; } - // Reasoning updates pair with their completion in the feed for durations; - // merging them here would erase the segment start (mirrors web). - if (isReasoningSegmentEntry(entry)) { - return undefined; - } return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; } @@ -826,9 +828,6 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (isReasoningSegmentEntry(previous) || isReasoningSegmentEntry(next)) { - return false; - } if ( previous.sourceActivityKind !== "tool.updated" && previous.sourceActivityKind !== "tool.completed" @@ -875,11 +874,23 @@ 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 ?? + (isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next) + ? 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. + const reasoningMerge = isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next); return { ...previous, ...next, - id: previous.id, - createdAt: previous.createdAt, + ...(!reasoningMerge ? { id: previous.id, createdAt: previous.createdAt } : {}), ...(detail ? { detail } : {}), ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), @@ -895,6 +906,7 @@ function mergeDerivedWorkLogEntries( ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), ...(toolCallId ? { toolCallId } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(segmentStartedAt ? { segmentStartedAt } : {}), }; } @@ -916,10 +928,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return undefined; } - // See toolLifecycleCollapseMapKey: reasoning pairs must reach the feed. - if (isReasoningSegmentEntry(entry)) { - return undefined; - } if (entry.toolCallId) { return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; } @@ -1773,6 +1781,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)) { @@ -1822,6 +1835,7 @@ export function deriveThreadFeedPresentation( unsettledTurnId, isWorking, isActiveTailGroup, + thinkingLiveScope, ); } } @@ -1861,6 +1875,63 @@ 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 ( + workEntry.toolLifecycleStatus !== undefined && + workEntry.toolLifecycleStatus !== "inProgress" && + workEntry.toolCallId !== undefined + ) { + terminalReasoningIds.add(workEntry.toolCallId); + } + } else if (activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId) { + hasLiveToolActivity = true; + } + } + } + for (const entry of feed) { + if (entry.type !== "activity-group") continue; + for (const activity of entry.activities) { + const workEntry = activity.workEntry; + if (!isReasoningSegmentEntry(workEntry)) continue; + if (activity.turnId !== unsettledTurnId) continue; + if (activity.lifecycleStatus !== "inProgress") continue; + if (workEntry.toolCallId !== undefined && terminalReasoningIds.has(workEntry.toolCallId)) { + continue; + } + designatedThinkingActivityId = activity.id; + } + } + return { designatedThinkingActivityId, hasLiveToolActivity }; +} + function appendPresentedFeedEntry( result: ThreadFeedEntry[], entry: Exclude, @@ -1868,6 +1939,7 @@ function appendPresentedFeedEntry( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + thinkingLiveScope: ThinkingLiveScope, ): void { if (entry.type !== "activity-group") { result.push(entry); @@ -1884,6 +1956,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) || @@ -1898,8 +1972,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) { @@ -1914,6 +1996,7 @@ function appendActivityGroupRows( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + thinkingLiveScope: ThinkingLiveScope, ): void { const activities = omitSupersededLifecycleMarkers( entry.activities.filter( @@ -1931,6 +2014,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; @@ -1942,6 +2029,9 @@ function appendActivityGroupRows( unsettledTurnId, isWorking, activeTail && isTrailingRun, + groupableRun.every((activity) => isReasoningSegmentEntry(activity.workEntry)) + ? { designatedThinkingActivityId, hasLiveToolActivity } + : undefined, ); groupableRun = []; }; @@ -1950,12 +2040,18 @@ function appendActivityGroupRows( 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 && - isReasoningSegmentEntry(groupableRun.at(-1)!.workEntry) !== reasoning - ) { - flushGroupableRun(false); + 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; @@ -1995,6 +2091,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 @@ -2002,7 +2099,7 @@ function appendToolGroupRows( : activities[0]!.id; const groupId = `work-group:${identity}`; const expanded = expandedWorkGroupIds.has(groupId); - if (activities.every((activity) => isReasoningSegmentEntry(activity.workEntry))) { + if (thinkingLive !== undefined) { appendThinkingSegmentRows( result, sourceGroup, @@ -2012,6 +2109,7 @@ function appendToolGroupRows( unsettledTurnId, isWorking, activeTail, + thinkingLive, ); return; } @@ -2112,8 +2210,9 @@ function appendToolGroupRows( } /** - * Settled thinking renders one compact row per thought ("Thought for 4s"); - * the live thought shimmers in the turn's live slot like a running tool. + * 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[], @@ -2124,53 +2223,51 @@ function appendThinkingSegmentRows( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + thinkingLive: { designatedThinkingActivityId: string | null; hasLiveToolActivity: boolean }, ): void { - const segments = groupReasoningSegmentEntries(activities.map((activity) => activity.workEntry)); - segments.forEach((segment, segmentIndex) => { - const representative = representativeReasoningSegmentEntry(segment.entries); - const activity = activities.find((candidate) => candidate.workEntry === representative)!; + for (const activity of activities) { + const entry = activity.workEntry; + const span = reasoningSegmentSpanForEntry(entry); const live = - isWorking && - segment.entries.some( - (entry) => entry.toolLifecycleStatus === "inProgress" && entry.turnId === unsettledTurnId, - ); - const shimmer = live && activeTail && segmentIndex === segments.length - 1; + activity.id === thinkingLive.designatedThinkingActivityId && + !thinkingLive.hasLiveToolActivity; + const shimmer = live && activeTail; 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}:${segment.span.toolCallId ?? activity.id}`, - createdAt: segment.span.startedAt ?? activity.createdAt, + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `work-toggle:${groupId}:${activity.id}`, + createdAt: span.startedAt ?? activity.createdAt, turnId: sourceGroup.turnId, groupId, - hiddenCount: segment.entries.length, + hiddenCount: 1, expanded, - summary: live ? "Thinking" : formatThinkingSegmentLabel(segment.span), - summaryKind: toolGroupSummaryKind(segment.entries), + summary: live ? "Thinking" : formatThinkingSegmentLabel(span), + summaryKind: toolGroupSummaryKind([entry]), hasFailure: false, live, shimmer, }); if (!expanded) { - return; + continue; } result.push({ type: "activity-group", - id: `work-details:${groupId}:${segment.span.toolCallId ?? activity.id}`, - createdAt: segment.entries[0]!.createdAt, - turnId: segment.entries[0]!.turnId, - activities: segment.entries.map((entry) => ({ - ...activities.find((candidate) => candidate.workEntry === entry)!, - groupedToolDetail: true, - live: - isWorking && - entry.toolLifecycleStatus === "inProgress" && - entry.turnId === unsettledTurnId, - })), + id: `work-details:${groupId}:${activity.id}`, + createdAt: activity.createdAt, + turnId: activity.turnId, + activities: [ + { + ...activity, + groupedToolDetail: true, + live: + isWorking && + activity.lifecycleStatus === "inProgress" && + activity.turnId === unsettledTurnId, + }, + ], }); - }); + } } function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index aaf5164ffdef..88dc34351f12 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -631,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, @@ -7224,16 +7232,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); const events = yield* Fiber.join(eventsFiber); - const isItemLifecycle = ( - 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 lifecycle = events.filter(isItemLifecycle); + 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, @@ -7243,9 +7244,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ]), [ ["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.completed", "reasoning-1", "reasoning", "completed"], ["item.updated", "reasoning-2", "reasoning", "inProgress"], ], ); @@ -7262,9 +7263,286 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { [], ); const firstReasoning = lifecycle[0]!; - const reasoningCompletion = lifecycle[3]!; + const reasoningCompletion = lifecycle[1]!; NodeAssert.equal(firstReasoning.createdAt, "1970-01-01T00:00:00.100Z"); - NodeAssert.equal(reasoningCompletion.createdAt, "1970-01-01T00:00:00.200Z"); + // Eager finalization stamps the observation time: the thought ended + // when the tool started, before any native end arrived. + NodeAssert.equal(typeof reasoningCompletion.createdAt, "string"); + 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: "Working on it", + time: { start: 150 }, + }, + }, + }, + { 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("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 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(); + runtimeMock.state.subscribedEvents = [start.promise]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + 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" } }, + }); + const reasoningOpened = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + event.type === "item.updated" && + event.payload.itemType === "reasoning", + ), + Stream.runHead, + Effect.forkChild, + ); + 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* Fiber.join(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), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 3cb9177eeefc..0cea36d8280d 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -351,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; @@ -1151,6 +1159,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, @@ -1516,6 +1525,7 @@ export function makeOpenCodeAdapter( ); } yield* clearPendingOpenCodeRequests(context, { type: "session.abort" }); + yield* completeOpenReasoningSegment(context, turnId, raw); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1593,6 +1603,45 @@ 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; + 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", + }, + }); + }); + /** Emit reasoning lifecycle (item.updated/item.completed) for a reasoning part. */ const emitReasoningSegmentEvent = Effect.fn("emitReasoningSegmentEvent")(function* ( context: OpenCodeSessionContext, @@ -1609,6 +1658,8 @@ export function makeOpenCodeAdapter( // thought/tool/thought — including for models with empty reasoning text. if (!part.reasoningStarted) { part.reasoningStarted = true; + yield* completeOpenReasoningSegment(context, turnId, raw); + context.openReasoningPart = { messageID: part.messageID, id: part.id }; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1627,6 +1678,9 @@ export function makeOpenCodeAdapter( } if (part.time?.end !== undefined && !part.completed) { part.completed = true; + if (context.openReasoningPart?.id === part.id) { + context.openReasoningPart = undefined; + } yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1659,6 +1713,11 @@ export function makeOpenCodeAdapter( 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({ @@ -2427,6 +2486,9 @@ export function makeOpenCodeAdapter( case "message.removed": { context.messageRoleById.delete(event.properties.messageID); context.textPartsByMessageId.delete(event.properties.messageID); + if (context.openReasoningPart?.messageID === event.properties.messageID) { + context.openReasoningPart = undefined; + } break; } @@ -2436,6 +2498,9 @@ export function makeOpenCodeAdapter( if (parts?.size === 0) { context.textPartsByMessageId.delete(event.properties.messageID); } + if (context.openReasoningPart?.id === event.properties.partID) { + context.openReasoningPart = undefined; + } break; } @@ -2515,6 +2580,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" @@ -3049,6 +3117,7 @@ export function makeOpenCodeAdapter( pendingQuestions: new Map(), textPartsByMessageId: new Map(), messageRoleById: new Map(), + openReasoningPart: undefined, turnTokenUsage: undefined, activeTurnId: undefined, activeAgent: undefined, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7e45f0fa7ecf..11ff393db276 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3361,31 +3361,59 @@ describe("computeStableMessagesTimelineRows", () => { 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 thinking = ( + const thinkingActivity = ( id: string, - kind: "updated" | "completed", + kind: "tool.updated" | "tool.completed", second: number, toolCallId = id, - ): WorkLogEntry => ({ - id, - createdAt: time(second), + ): OrchestrationThreadActivity => + ({ + id: EventId.make(id), + tone: "tool", + kind, + summary: "Thinking", + payload: { + itemType: "reasoning", + toolCallId, + status: kind === "tool.completed" ? "completed" : "inProgress", + title: "Thinking", + }, + turnId, + 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 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, - label: "Thinking", - tone: "thinking", - toolCallId, - toolLifecycleStatus: kind === "updated" ? "inProgress" : "completed", - sourceActivityKind: kind === "updated" ? "tool.updated" : "tool.completed", - }); - const tool = (id: string, second: number): WorkLogEntry => ({ - id, createdAt: time(second), - turnId, - tone: "tool", - label: "Ran command", - command: "git status", - toolCallId: `call-${id}`, - toolLifecycleStatus: "completed", - sourceActivityKind: "tool.completed", + updatedAt: time(second), + streaming, }); const settledInput = (work: WorkLogEntry[], expanded = true) => ({ timelineEntries: deriveTimelineEntries([], [], work), @@ -3396,21 +3424,32 @@ describe("reasoning segments", () => { 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 rows = deriveMessagesTimelineRows( - settledInput([ - thinking("thought-1-updated", "updated", 0, "thought-1"), - thinking("thought-1-completed", "completed", 4, "thought-1"), - tool("tool-1", 5), - tool("tool-2", 6), - tool("tool-3", 7), - thinking("thought-2-updated", "updated", 8, "thought-2"), - thinking("thought-2-completed", "completed", 15, "thought-2"), - tool("tool-4", 16), - tool("tool-5", 17), - ]), - ); + 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", @@ -3427,18 +3466,23 @@ describe("reasoning segments", () => { }); it("keeps twenty-plus tool calls in small groups when thoughts intervene", () => { - const work: WorkLogEntry[] = []; + const activities: OrchestrationThreadActivity[] = []; for (let segment = 0; segment < 4; segment += 1) { const base = segment * 10; - work.push( - thinking(`thought-${segment}-updated`, "updated", base, `thought-${segment}`), - thinking(`thought-${segment}-completed`, "completed", base + 2, `thought-${segment}`), + 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) { - work.push(tool(`tool-${segment}-${call}`, base + 3 + call)); + activities.push(toolActivity(`tool-${segment}-${call}`, base + 3 + call)); } } - const rows = deriveMessagesTimelineRows(settledInput(work)); + const rows = deriveMessagesTimelineRows(settledInput(deriveWorkLogEntries(activities))); const toggles = rows.filter((row) => row.kind === "work-toggle"); const thoughts = rows.filter((row) => row.kind === "work"); @@ -3454,60 +3498,118 @@ describe("reasoning segments", () => { }); it("shows the live thought while reasoning runs and hides the generic fallback", () => { - const userMessage: ChatMessage = { - id: MessageId.make("segment-user"), - role: "user", - text: "Inspect", - turnId: null, - createdAt: time(0), - updatedAt: time(0), - streaming: false, - }; - const rows = deriveMessagesTimelineRows({ - timelineEntries: deriveTimelineEntries( + const rows = deriveMessagesTimelineRows( + liveInput( [userMessage], - [], - [tool("tool-1", 6), thinking("thought-live", "updated", 7, "thought-live")], + deriveWorkLogEntries([ + toolActivity("tool-1", 6), + thinkingActivity("thought-live", "tool.updated", 7, "thought-live"), + ]), ), - latestTurn: { turnId, state: "running", startedAt: time(0), completedAt: null }, - runningTurnId: turnId, - isWorking: true, - activeTurnStartedAt: time(0), - turnDiffSummaries: [], - supportsConversationRollback: false, - }); + ); - const live = rows.find((row) => row.kind === "work-live"); - expect(live).toMatchObject({ + const live = rows.filter((row) => row.kind === "work-live"); + expect(live).toHaveLength(1); + expect(live[0]).toMatchObject({ active: true, - entry: { id: "thought-live", toolLifecycleStatus: "inProgress" }, + 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?.kind === "work-live" && live.entry.createdAt).toBe(time(7)); - expect(live?.kind === "work-live" && liveWorkEntryLabel(live.entry, undefined, true)).toBe( - "Thinking", + 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("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("renders an interrupted thought without inventing a duration", () => { + 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([tool("tool-1", 5), thinking("thought-live", "updated", 6, "thought-live")]), + 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: "Thinking" }); + expect(rows[1]).toMatchObject({ kind: "work", displayLabel: "Thought" }); }); it("folds settled thoughts away with their turn", () => { const rows = deriveMessagesTimelineRows( settledInput( - [ - thinking("thought-1-updated", "updated", 0, "thought-1"), - thinking("thought-1-completed", "completed", 4, "thought-1"), - tool("tool-1", 5), - ], + deriveWorkLogEntries([ + thinkingActivity("thought-1-updated", "tool.updated", 0, "thought-1"), + thinkingActivity("thought-1-completed", "tool.completed", 4, "thought-1"), + toolActivity("tool-1", 5), + ]), false, ), ); @@ -3516,29 +3618,11 @@ describe("reasoning segments", () => { }); it("projects canonical reasoning activities into thought rows without text", () => { - const activity = ( - id: string, - kind: "tool.updated" | "tool.completed", - second: number, - ): OrchestrationThreadActivity => - ({ - id: EventId.make(id), - tone: "tool", - kind, - summary: "Thinking", - payload: { - itemType: "reasoning", - toolCallId: "reasoning-e2e", - status: kind === "tool.completed" ? "completed" : "inProgress", - title: "Thinking", - }, - turnId, - createdAt: time(second), - }) as unknown as OrchestrationThreadActivity; const entries = deriveWorkLogEntries([ - activity("reasoning-e2e-updated", "tool.updated", 0), - activity("reasoning-e2e-completed", "tool.completed", 4), + 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( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 30550f8dab2d..b4da62e8b48f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -7,9 +7,8 @@ import { normalizeCompactToolLabel, omitSupersededLifecycleMarkers, formatThinkingSegmentLabel, - groupReasoningSegmentEntries, isReasoningSegmentEntry, - representativeReasoningSegmentEntry, + reasoningSegmentSpanForEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, toolGroupAction, @@ -994,6 +993,38 @@ export function deriveMessagesTimelineRows(input: { const activeWorkEntryIds = new Set( activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [], ); + // At most one thought is ever live: the latest still-open reasoning entry + // of the unsettled turn. Completed siblings disqualify stale in-progress + // updates delivered out of order. Groups render live only for the + // designated entry; the trailing live scan owns it when it already + // claimed the live slot, and everything else renders statically. + 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 (!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 !== "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 = @@ -1110,9 +1141,18 @@ export function deriveMessagesTimelineRows(input: { } // 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 ( - isReasoningSegmentEntry(groupedEntries[groupedEntries.length - 1]!) !== - isReasoningSegmentEntry(nextEntry.entry) + previousReasoning && + nextReasoning && + previousEntry.toolCallId !== nextEntry.entry.toolCallId ) { break; } @@ -1127,7 +1167,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)!; @@ -1148,18 +1193,43 @@ export function deriveMessagesTimelineRows(input: { ); } } else if (visibleGroupedEntries.every(isReasoningSegmentEntry)) { - // Settled thinking, one compact row per thought: "Thought for 4s". - // Live thoughts take the work-live branch above instead. - for (const segment of groupReasoningSegmentEntries(visibleGroupedEntries)) { - const representative = representativeReasoningSegmentEntry(segment.entries); + // One live thought per turn at most: the designated open segment + // animates while every other thought renders statically beside it. + const liveEntry = + activeWorkRow?.active === true + ? undefined + : visibleGroupedEntries.find((entry) => entry.id === liveReasoningWorkEntryId); + if (liveEntry !== undefined) { + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; nextRows.push({ - kind: "work", - id: `thinking-segment:${timelineEntry.id}:${segment.span.toolCallId ?? representative.id}`, - createdAt: segment.span.startedAt ?? representative.createdAt, - groupedEntries: [representative], - isExpandedToolGroup: false, - displayLabel: formatThinkingSegmentLabel(segment.span), + kind: "work-live", + id: `work-live:${workGroupIdentity(timelineEntry.id, timelineEntry.entry)}`, + createdAt: timelineEntry.createdAt, + entry: liveEntry, + groupedEntries: visibleGroupedEntries, + groupId, + expanded, + active: true, }); + hasActivityRow = true; + if (expanded) { + nextRows.push( + expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), + ); + } + } else { + for (const entry of visibleGroupedEntries) { + const span = reasoningSegmentSpanForEntry(entry); + 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 && diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e353de1ab829..de3e066afbb9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2518,27 +2518,53 @@ describe("reasoning segment derivation", () => { }, }); - it("derives thinking tone and keeps the lifecycle pair uncollapsed", () => { + 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(2); + expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ tone: "thinking", label: "Thinking", toolCallId: "reasoning-1", - toolLifecycleStatus: "inProgress", - }); - expect(entries[1]).toMatchObject({ - tone: "thinking", toolLifecycleStatus: "completed", + segmentStartedAt: "2026-02-23T00:00:01.000Z", + createdAt: "2026-02-23T00:00:05.000Z", }); // No reasoning text may hitch a ride: labels and details stay structural. - for (const entry of entries) { - expect(entry.detail).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", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 49e022c214d9..88d2374f8657 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -78,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). */ @@ -718,11 +724,6 @@ function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undef ) { return undefined; } - // Reasoning updates pair with their completion in the timeline for - // durations; merging them here would erase the segment start. - if (isReasoningSegmentEntry(entry)) { - return undefined; - } return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; } @@ -825,9 +826,6 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (isReasoningSegmentEntry(previous) || isReasoningSegmentEntry(next)) { - return false; - } if ( previous.sourceActivityKind !== "tool.updated" && previous.sourceActivityKind !== "tool.completed" @@ -877,6 +875,15 @@ 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 ?? + (isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next) + ? previous.createdAt + : undefined); return { ...previous, ...next, @@ -895,6 +902,7 @@ function mergeDerivedWorkLogEntries( ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), ...(toolData !== undefined ? { toolData } : {}), + ...(segmentStartedAt ? { segmentStartedAt } : {}), }; } @@ -924,10 +932,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return undefined; } - // See toolLifecycleCollapseMapKey: reasoning pairs must reach the timeline. - if (isReasoningSegmentEntry(entry)) { - return undefined; - } if (entry.toolCallId) { return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 3e2d2dc2f0d1..849675d90ec7 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -6,10 +6,9 @@ import { commandDetailRepeatsCommand, extractCommandOutputText, formatThinkingSegmentLabel, - groupReasoningSegmentEntries, isReasoningSegmentEntry, reasoningSegmentElapsedMs, - representativeReasoningSegmentEntry, + reasoningSegmentSpanForEntry, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -744,70 +743,42 @@ describe("reasoning segments", () => { ); }); - it("pairs in-progress and completed updates by provider identity", () => { - const segments = groupReasoningSegmentEntries([ - thinking({ - label: "Thinking", - sourceActivityKind: "tool.updated", - toolCallId: "reasoning-1", - toolLifecycleStatus: "inProgress", - createdAt: "2026-01-01T00:00:00.000Z", - }), - thinking({ - label: "Thinking", - toolCallId: "reasoning-1", - toolLifecycleStatus: "completed", - createdAt: "2026-01-01T00:00:04.000Z", - }), - thinking({ - label: "Thinking", - sourceActivityKind: "tool.updated", - toolCallId: "reasoning-2", - toolLifecycleStatus: "inProgress", - createdAt: "2026-01-01T00:00:10.000Z", - }), - ]); - expect(segments).toHaveLength(2); - expect(segments[0]!.span).toEqual({ - toolCallId: "reasoning-1", + 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, }); - expect(segments[1]!.span).toEqual({ - toolCallId: "reasoning-2", + // 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("keeps identity-less entries from fusing segments", () => { - const segments = groupReasoningSegmentEntries([ - thinking({ label: "Thinking", createdAt: "2026-01-01T00:00:00.000Z" }), - thinking({ label: "Thinking", createdAt: "2026-01-01T00:00:01.000Z" }), - ]); - expect(segments).toHaveLength(2); - }); - - it("renders a segment from its completion when one arrived", () => { - const updated = thinking({ - label: "Thinking", - sourceActivityKind: "tool.updated", - toolCallId: "reasoning-1", - toolLifecycleStatus: "inProgress", - createdAt: "2026-01-01T00:00:00.000Z", - }); - const completed = thinking({ - label: "Thinking", - toolCallId: "reasoning-1", - toolLifecycleStatus: "completed", - createdAt: "2026-01-01T00:00:04.000Z", - }); - expect(representativeReasoningSegmentEntry([updated, completed])).toBe(completed); - expect(representativeReasoningSegmentEntry([updated])).toBe(updated); - }); - it("measures elapsed time only between parseable timestamps", () => { expect(reasoningSegmentElapsedMs("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:04.000Z")).toBe( 4000, @@ -816,20 +787,21 @@ describe("reasoning segments", () => { expect(reasoningSegmentElapsedMs("not-a-date", "2026-01-01T00:00:04.000Z")).toBeNull(); }); - it("labels completed segments with durations and hides sub-second noise", () => { + 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. + // 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("Thinking"); - expect(formatThinkingSegmentLabel({ startedAt: null, endedAt: null })).toBe("Thinking"); + ).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 19d59111296a..d01046769dfc 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -389,78 +389,39 @@ export function isReasoningSegmentEntry( } export interface ReasoningSegmentSpan { - /** Native segment identity when the provider reported one. */ - readonly toolCallId: string | undefined; - /** Earliest observed segment time (native thinking start when known). */ + /** 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 pairing lifecycle updates into reasoning segments. */ +/** 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; } /** - * Pairs a reasoning group's entries into per-segment spans by provider - * identity, preserving order. Entries without an identity stand alone so a - * missing id can never fuse two segments' timing. + * 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 groupReasoningSegmentEntries( - entries: ReadonlyArray, -): Array<{ readonly entries: T[]; readonly span: ReasoningSegmentSpan }> { - const grouped: T[][] = []; - const indexByToolCallId = new Map(); - for (const entry of entries) { - const key = entry.toolCallId; - const index = key === undefined ? undefined : indexByToolCallId.get(key); - if (index === undefined) { - if (key !== undefined) indexByToolCallId.set(key, grouped.length); - grouped.push([entry]); - continue; - } - grouped[index]!.push(entry); - } - return grouped.map((groupEntries) => ({ - entries: groupEntries, - span: spanForReasoningSegmentEntries(groupEntries), - })); -} - -function spanForReasoningSegmentEntries( - entries: ReadonlyArray, +export function reasoningSegmentSpanForEntry( + entry: ReasoningSegmentEntryLike, ): ReasoningSegmentSpan { - const terminal = entries.findLast( - (entry) => - entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress", - ); + const completed = + entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress"; return { - toolCallId: entries[0]?.toolCallId, - startedAt: entries[0]?.createdAt ?? null, - endedAt: terminal?.createdAt ?? null, - completed: terminal !== undefined, + startedAt: entry.segmentStartedAt ?? entry.createdAt, + endedAt: completed ? entry.createdAt : null, + completed, }; } -/** - * The entry a settled segment row renders: its completion when one arrived, - * else its latest update. Segments are never empty. - */ -export function representativeReasoningSegmentEntry( - entries: ReadonlyArray, -): T { - return ( - entries.findLast( - (entry) => - entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress", - ) ?? entries.at(-1)! - ); -} - /** Elapsed milliseconds between two ISO timestamps, or null when unparseable. */ export function reasoningSegmentElapsedMs( startedAt: string | null, @@ -474,15 +435,15 @@ export function reasoningSegmentElapsedMs( } /** - * Compact settled label for a thinking segment. Durations below a second - * stay a plain "Thinking": same-flush lifecycle pairs and untimed providers - * carry no meaningful elapsed time, and sub-second thoughts need no duration. + * 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 "Thinking"; + if (elapsedMs === null || elapsedMs < 1_000) return "Thought"; return `Thought for ${formatDuration(elapsedMs)}`; } From 84650527a6f6d4ad5690c13a9677d6e5053fb19b Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:17:09 -0700 Subject: [PATCH 03/14] fix(chat): harden reasoning lifecycle boundaries --- apps/mobile/src/lib/threadActivity.test.ts | 65 +++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 3 +- .../provider/Layers/OpenCodeAdapter.test.ts | 74 ++++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 23 ++++-- .../chat/MessagesTimeline.logic.test.ts | 39 +++++++++- .../components/chat/MessagesTimeline.logic.ts | 10 ++- 6 files changed, 201 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 28a4873818b6..fe672250d624 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -19,6 +19,7 @@ import { buildThreadFeed, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, + LIVE_ACTIVITY_ROW_ID, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, workEntryRowLabel, @@ -3460,6 +3461,7 @@ describe("reasoning segments", () => { kind: "tool.updated" | "tool.completed", second: number, toolCallId = id, + activityTurnId = turnId, ) => makeActivity({ id: EventId.make(id), @@ -3467,7 +3469,7 @@ describe("reasoning segments", () => { summary: "Thinking", tone: "tool", createdAt: at(second), - turnId, + turnId: activityTurnId, payload: { itemType: "reasoning", toolCallId, @@ -3719,6 +3721,67 @@ describe("reasoning segments", () => { 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); + }); + + 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("folds settled thoughts away with their turn", () => { const thread = makeThread({ id: ThreadId.make("segment-fold"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index a5f5e84f4478..8dbf6e8e73d7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1905,6 +1905,7 @@ function designateLiveThinkingScope( const workEntry = activity.workEntry; if (isReasoningSegmentEntry(workEntry)) { if ( + activity.turnId === unsettledTurnId && workEntry.toolLifecycleStatus !== undefined && workEntry.toolLifecycleStatus !== "inProgress" && workEntry.toolCallId !== undefined @@ -2231,7 +2232,7 @@ function appendThinkingSegmentRows( const live = activity.id === thinkingLive.designatedThinkingActivityId && !thinkingLive.hasLiveToolActivity; - const shimmer = live && activeTail; + const shimmer = live; result.push({ type: "work-toggle", // The shimmering row is the turn's live slot; it keeps that identity diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 88dc34351f12..e714f10fb4ea 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7309,11 +7309,21 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { sessionID, messageID, type: "text", - text: "Working on it", + 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( @@ -7349,6 +7359,68 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).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; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 0cea36d8280d..6ad41f9950b3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1678,7 +1678,10 @@ export function makeOpenCodeAdapter( } if (part.time?.end !== undefined && !part.completed) { part.completed = true; - if (context.openReasoningPart?.id === part.id) { + if ( + context.openReasoningPart?.messageID === part.messageID && + context.openReasoningPart.id === part.id + ) { context.openReasoningPart = undefined; } yield* emit({ @@ -2484,23 +2487,26 @@ export function makeOpenCodeAdapter( } case "message.removed": { - context.messageRoleById.delete(event.properties.messageID); - context.textPartsByMessageId.delete(event.properties.messageID); if (context.openReasoningPart?.messageID === event.properties.messageID) { - context.openReasoningPart = undefined; + 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) { context.textPartsByMessageId.delete(event.properties.messageID); } - if (context.openReasoningPart?.id === event.properties.partID) { - context.openReasoningPart = undefined; - } break; } @@ -2525,6 +2531,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({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 11ff393db276..c4b6b8eb0271 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3366,6 +3366,7 @@ describe("reasoning segments", () => { kind: "tool.updated" | "tool.completed", second: number, toolCallId = id, + activityTurnId = turnId, ): OrchestrationThreadActivity => ({ id: EventId.make(id), @@ -3378,7 +3379,7 @@ describe("reasoning segments", () => { status: kind === "tool.completed" ? "completed" : "inProgress", title: "Thinking", }, - turnId, + turnId: activityTurnId, createdAt: time(second), }) as unknown as OrchestrationThreadActivity; const toolActivity = (id: string, second: number, command = "git status") => @@ -3546,6 +3547,42 @@ describe("reasoning segments", () => { ]); }); + 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("lets only the latest open thought animate", () => { const rows = deriveMessagesTimelineRows( liveInput( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b4da62e8b48f..223a717250b3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -973,7 +973,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 { @@ -1004,7 +1010,7 @@ export function deriveMessagesTimelineRows(input: { for (const timelineEntry of input.timelineEntries) { if (timelineEntry.kind !== "work") continue; const entry = timelineEntry.entry; - if (!isReasoningSegmentEntry(entry)) continue; + if (entry.turnId !== unsettledTurnId || !isReasoningSegmentEntry(entry)) continue; if ( entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress" && From 00aff4e6bdf2370888fcdb2bbd5624776eb77222 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:32:16 -0700 Subject: [PATCH 04/14] fix(chat): close remaining reasoning lifecycle gaps --- apps/mobile/src/lib/threadActivity.test.ts | 53 +++++++++++ apps/mobile/src/lib/threadActivity.ts | 33 +++---- .../provider/Layers/OpenCodeAdapter.test.ts | 90 ++++++++++++++++--- .../src/provider/Layers/OpenCodeAdapter.ts | 3 + .../chat/MessagesTimeline.logic.test.ts | 20 +++++ .../components/chat/MessagesTimeline.logic.ts | 16 ++-- 6 files changed, 175 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index fe672250d624..4e96aa939da2 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3753,6 +3753,59 @@ describe("reasoning segments", () => { { 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", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8dbf6e8e73d7..85c963774044 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1912,8 +1912,6 @@ function designateLiveThinkingScope( ) { terminalReasoningIds.add(workEntry.toolCallId); } - } else if (activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId) { - hasLiveToolActivity = true; } } } @@ -1921,13 +1919,18 @@ function designateLiveThinkingScope( if (entry.type !== "activity-group") continue; for (const activity of entry.activities) { const workEntry = activity.workEntry; - if (!isReasoningSegmentEntry(workEntry)) continue; - if (activity.turnId !== unsettledTurnId) continue; - if (activity.lifecycleStatus !== "inProgress") continue; + if (activity.turnId !== unsettledTurnId || 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 }; @@ -2101,17 +2104,7 @@ function appendToolGroupRows( const groupId = `work-group:${identity}`; const expanded = expandedWorkGroupIds.has(groupId); if (thinkingLive !== undefined) { - appendThinkingSegmentRows( - result, - sourceGroup, - activities, - groupId, - expanded, - unsettledTurnId, - isWorking, - activeTail, - thinkingLive, - ); + appendThinkingSegmentRows(result, sourceGroup, activities, groupId, expanded, thinkingLive); return; } const latestActiveActivity = activities.findLast( @@ -2221,9 +2214,6 @@ function appendThinkingSegmentRows( activities: ReadonlyArray, groupId: string, expanded: boolean, - unsettledTurnId: TurnId | null, - isWorking: boolean, - activeTail: boolean, thinkingLive: { designatedThinkingActivityId: string | null; hasLiveToolActivity: boolean }, ): void { for (const activity of activities) { @@ -2261,10 +2251,7 @@ function appendThinkingSegmentRows( { ...activity, groupedToolDetail: true, - live: - isWorking && - activity.lifecycleStatus === "inProgress" && - activity.turnId === unsettledTurnId, + live, }, ], }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index e714f10fb4ea..a9da6ccea1be 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7542,6 +7542,78 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).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; @@ -7549,9 +7621,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { 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, @@ -7574,16 +7652,6 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { type: "session.status", properties: { sessionID, status: { type: "busy" } }, }); - const reasoningOpened = yield* adapter.streamEvents.pipe( - Stream.filter( - (event) => - event.threadId === threadId && - event.type === "item.updated" && - event.payload.itemType === "reasoning", - ), - Stream.runHead, - Effect.forkChild, - ); runtimeMock.state.subscribedEvents.push( { type: "message.updated", @@ -7604,7 +7672,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, }, ); - yield* Fiber.join(reasoningOpened); + yield* Deferred.await(reasoningOpened); yield* adapter.interruptTurn(threadId, turn.turnId); const events = yield* Fiber.join(eventsFiber); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 6ad41f9950b3..5fa1df96518c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2771,6 +2771,9 @@ export function makeOpenCodeAdapter( break; } } + if (activeTurnId) { + yield* completeOpenReasoningSegment(context, activeTurnId, event); + } yield* cancelIdleReconciliation(context); const terminalCancellation = activeTurnId !== undefined && cancellation?.turnId === activeTurnId diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c4b6b8eb0271..b92b14be8a59 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3547,6 +3547,26 @@ describe("reasoning segments", () => { ]); }); + 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( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 223a717250b3..828a0fc8f65f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -997,13 +997,17 @@ 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) + : [], ); - // At most one thought is ever live: the latest still-open reasoning entry - // of the unsettled turn. Completed siblings disqualify stale in-progress - // updates delivered out of order. Groups render live only for the - // designated entry; the trailing live scan owns it when it already - // claimed the live slot, and everything else renders statically. + // 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(); From 83bf4aef717f012f8aaa03c7f214d0b2c0dc6d16 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:47:01 -0700 Subject: [PATCH 05/14] fix(chat): keep settled timeline groups stable --- apps/mobile/src/lib/threadActivity.test.ts | 95 +++++++++++++++++++ .../chat/MessagesTimeline.logic.test.ts | 83 ++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 7 ++ 3 files changed, 185 insertions(+) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4e96aa939da2..4f74cd4306a1 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3538,6 +3538,101 @@ describe("reasoning segments", () => { ]); }); + 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"), diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b92b14be8a59..01d2dfd40aeb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3398,6 +3398,23 @@ describe("reasoning segments", () => { 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", @@ -3466,6 +3483,72 @@ describe("reasoning segments", () => { ).toEqual(["Thought for 4.0s", "Ran 3 commands", "Thought for 7.0s", "Ran 2 commands"]); }); + 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) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 828a0fc8f65f..f5859a12d8f2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -943,6 +943,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( From c338e1aef0aaf22e22ae7c95343ef88743f2f95e Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:16:29 -0700 Subject: [PATCH 06/14] fix(chat): close final reasoning lifecycle gaps --- apps/mobile/src/lib/threadActivity.test.ts | 25 +++++++++++++ apps/mobile/src/lib/threadActivity.ts | 12 ++++++- .../provider/Layers/OpenCodeAdapter.test.ts | 35 ++++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 16 ++++++--- .../chat/MessagesTimeline.logic.test.ts | 16 +++++++++ .../components/chat/MessagesTimeline.logic.ts | 4 +++ 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4f74cd4306a1..27b883b327fd 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3930,6 +3930,31 @@ describe("reasoning segments", () => { 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"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 85c963774044..c6bbfdbe4da7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1919,9 +1919,19 @@ function designateLiveThinkingScope( if (entry.type !== "activity-group") continue; for (const activity of entry.activities) { const workEntry = activity.workEntry; - if (activity.turnId !== unsettledTurnId || activity.lifecycleStatus !== "inProgress") { + 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; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index a9da6ccea1be..015813878182 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7268,7 +7268,22 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { // 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 === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.stopSession(threadId); + const stoppedEvents = Array.from(yield* Fiber.join(stoppedEventsFiber)); + NodeAssert.deepEqual( + stoppedEvents.map((event) => event.type), + ["item.completed", "session.exited"], + ); + NodeAssert.equal( + stoppedEvents[0]?.type === "item.completed" && stoppedEvents[0].itemId, + "reasoning-2", + ); }).pipe(Effect.scoped), ); @@ -7862,7 +7877,23 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const endStream = promiseWithResolvers(); const request = permissionRequest("per_disconnect", "http://127.0.0.1:9999/session"); runtimeMock.state.pendingPermissions = [request]; - runtimeMock.state.subscribedEvents = [endStream.promise]; + 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, @@ -7898,6 +7929,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 5fa1df96518c..b6fd05e573b5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1069,7 +1069,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 @@ -1573,6 +1573,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, @@ -1642,6 +1643,13 @@ export function makeOpenCodeAdapter( }); }); + const finalizeAndStopOpenCodeContext = Effect.fn("finalizeAndStopOpenCodeContext")(function* ( + context: OpenCodeSessionContext, + ) { + yield* completeOpenReasoningSegment(context, context.activeTurnId, undefined); + return yield* stopOpenCodeContext(context); + }); + /** Emit reasoning lifecycle (item.updated/item.completed) for a reasoning part. */ const emitReasoningSegmentEvent = Effect.fn("emitReasoningSegmentEvent")(function* ( context: OpenCodeSessionContext, @@ -2951,7 +2959,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); } @@ -3888,7 +3896,7 @@ export function makeOpenCodeAdapter( threadId, }); } - const stopped = yield* stopOpenCodeContext(context); + const stopped = yield* finalizeAndStopOpenCodeContext(context); deleteContextIfCurrent(context); if (!stopped) { return; @@ -4047,7 +4055,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 01d2dfd40aeb..a80f5e66ab43 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3686,6 +3686,22 @@ describe("reasoning segments", () => { 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( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f5859a12d8f2..52350824dd69 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1036,6 +1036,10 @@ export function deriveMessagesTimelineRows(input: { 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; From c3d11e563365a620b427c9c472aa2c3cfcd3e07e Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:54:34 -0700 Subject: [PATCH 07/14] fix(opencode): preserve provider reasoning text on thought lifecycle OpenCode already emitted reasoning_text deltas, but this branch treated reasoning as lifecycle-only and never put text on the thought item, so DeepSeek-style readable reasoning collapsed to generic Thought for Xs. Carry non-empty provider reasoning on lifecycle detail (stream + complete), leave empty reasoning structural-only, and stop truncating reasoning detail in ingestion. No model-name hardcoding; Codex/Claude paths unchanged. --- .../ProviderRuntimeIngestion.activity.test.ts | 37 ++++++ .../Layers/ProviderRuntimeIngestion.ts | 23 +++- .../provider/Layers/OpenCodeAdapter.test.ts | 118 ++++++++++++++++-- .../src/provider/Layers/OpenCodeAdapter.ts | 118 +++++++++++++----- apps/web/src/session-logic.test.ts | 28 ++++- .../src/work-log/presentation.ts | 9 +- 6 files changed, 288 insertions(+), 45 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts index e07fe3c0fbbf..97ade3c41db6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts @@ -176,6 +176,43 @@ describe("runtimeEventToActivities reasoning lifecycle", () => { 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, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0b50ec31b01c..819be179fb63 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -849,9 +849,9 @@ export function runtimeEventToActivities( case "item.updated": { // Reasoning items project like tools so clients can render thinking - // segments as activity boundaries. Reasoning text itself never arrives - // here (content.delta drops non-assistant text above), and adapters must - // not put it in lifecycle detail either. + // segments as activity boundaries. When an adapter supplies provider + // reasoning text on lifecycle `detail`, preserve it (content.delta + // reasoning_text is still dropped above; detail is the carrier). if ( !isToolLifecycleItemType(event.payload.itemType) && event.payload.itemType !== "reasoning" @@ -865,6 +865,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, @@ -877,7 +884,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 } : {}), @@ -901,6 +908,12 @@ export function runtimeEventToActivities( ) { return []; } + const completedDetail = + event.payload.detail === undefined + ? undefined + : event.payload.itemType === "reasoning" + ? event.payload.detail + : truncateDetail(event.payload.detail); return [ { id: event.eventId, @@ -913,7 +926,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/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 015813878182..269e5b608090 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7127,37 +7127,44 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { .filter((event) => event.type === "item.completed") .map((event) => [event.payload.itemType, event.payload.detail]), [ - ["reasoning", undefined], + ["reasoning", "Thinking"], ["assistant_message", "Hello world"], ["assistant_message", "Fresh"], ["assistant_message", "Second"], - ["reasoning", undefined], + ["reasoning", "New thoughts"], ["assistant_message", "New"], ], ); - // Reasoning parts project lifecycle boundaries without leaking text: - // one in-progress update per part sighting, then a completion. The - // post-removal replay emits a second pair for the fresh part state. + // 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 (no mid-flight streaming updates). 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.payload.detail, undefined); 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.equal(reasoningCompletions.length, 2); + NodeAssert.deepEqual( + reasoningCompletions.map((event) => event.payload.detail), + ["Thinking", "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.payload.detail, undefined); NodeAssert.equal(completed.createdAt, "1970-01-01T00:00:00.002Z"); } yield* adapter.stopSession(threadId); @@ -7287,6 +7294,103 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); + it.effect("preserves readable OpenCode reasoning text on lifecycle detail", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + 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 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."], + ["item.updated", "inProgress", finalText], + ["item.completed", "completed", finalText], + ], + ); + 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("finalizes the open thought when commentary starts flowing", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b6fd05e573b5..2b8f5512e71c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -606,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, @@ -1626,6 +1638,7 @@ export function makeOpenCodeAdapter( return; } state.completed = true; + const detail = openCodeReasoningDetail(state); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1639,6 +1652,7 @@ export function makeOpenCodeAdapter( itemType: "reasoning", status: "completed", title: "Thinking", + ...(detail !== undefined ? { detail } : {}), }, }); }); @@ -1650,7 +1664,7 @@ export function makeOpenCodeAdapter( return yield* stopOpenCodeContext(context); }); - /** Emit reasoning lifecycle (item.updated/item.completed) for a reasoning part. */ + /** Start a reasoning lifecycle segment (completion happens after text merge). */ const emitReasoningSegmentEvent = Effect.fn("emitReasoningSegmentEvent")(function* ( context: OpenCodeSessionContext, part: OpenCodeTextPartState, @@ -1660,14 +1674,18 @@ export function makeOpenCodeAdapter( if (part.type !== "reasoning") { return; } - // Lifecycle only, never text: reasoning content stays provider-private - // (see the reasoning_text drop in ProviderRuntimeIngestion). Native part - // identity and time metadata still give clients structural boundaries — - // thought/tool/thought — including for models with empty reasoning text. + // 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, @@ -1681,33 +1699,45 @@ export function makeOpenCodeAdapter( itemType: "reasoning", status: "inProgress", title: "Thinking", + ...(detail !== undefined ? { detail } : {}), }, }); } - if (part.time?.end !== undefined && !part.completed) { - part.completed = true; - if ( - context.openReasoningPart?.messageID === part.messageID && - context.openReasoningPart.id === part.id - ) { - context.openReasoningPart = undefined; - } - 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", - }, - }); + }); + + 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. */ @@ -1717,8 +1747,11 @@ export function makeOpenCodeAdapter( turnId: TurnId | undefined, raw: unknown, ) { + const reasoningAlreadyStarted = part.reasoningStarted; 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); @@ -1744,8 +1777,37 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); + // Stream provider reasoning text onto the open thought item so clients + // can show growing detail without inventing a second channel. Skip the + // first sighting when emitReasoningSegmentEvent already carried 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: latestText, + }, + }); + } } + // Complete after merge so lifecycle detail has the latest provider text. + yield* completeReasoningSegmentPart(context, part, turnId, raw); + if (part.type === "text" && part.time?.end !== undefined && !part.completed) { part.completed = true; yield* emit({ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index de3e066afbb9..4f19df054ae3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2503,6 +2503,7 @@ describe("reasoning segment derivation", () => { id: string, kind: "tool.updated" | "tool.completed", createdAt: string, + extras?: { detail?: string }, ) => makeActivity({ id, @@ -2515,6 +2516,7 @@ describe("reasoning segment derivation", () => { toolCallId: "reasoning-1", status: kind === "tool.completed" ? "completed" : "inProgress", title: "Thinking", + ...(extras?.detail !== undefined ? { detail: extras.detail } : {}), }, }); @@ -2533,10 +2535,34 @@ describe("reasoning segment derivation", () => { segmentStartedAt: "2026-02-23T00:00:01.000Z", createdAt: "2026-02-23T00:00:05.000Z", }); - // No reasoning text may hitch a ride: labels and details stay structural. + // 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("pairs reasoning timing across interleaved tool activity", () => { const tool = (id: string, createdAt: string) => makeActivity({ diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index d01046769dfc..b66bbe8d0387 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -373,11 +373,12 @@ export function isReasoningItemPayload(payload: unknown): boolean { } /** - * Provider thinking surfaced as lifecycle only, never text. Adapters emit it + * 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. Subagent progress rows share - * the thinking tone but ride `task.progress`, so the kind check keeps them - * out of segment handling. + * 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, From 4d07fe132b2ce92938bec169f49da042f86535e8 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:24:28 -0700 Subject: [PATCH 08/14] fix(chat): show provider reasoning text inline under Thought rows Match Codex: when a thought has provider-supplied detail, open it by default on web and always surface the body on mobile instead of hiding it behind a closed disclosure. Empty/boundary-only thoughts stay label-only. --- apps/mobile/src/lib/threadActivity.test.ts | 59 +++++++++++++ apps/mobile/src/lib/threadActivity.ts | 7 +- .../src/components/chat/MessagesTimeline.tsx | 87 ++++++++++++------- 3 files changed, 118 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 27b883b327fd..434ba938f5d4 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3538,6 +3538,65 @@ describe("reasoning segments", () => { ]); }); + it("shows provider reasoning text under Thought rows without requiring expand", () => { + 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, + }, + }), + ], + }); + // No expanded work groups — text should still surface. + const rows = deriveThreadFeedPresentation( + buildThreadFeed(thread), + settledTurn, + new Set([turnId]), + ); + expect(summaries(rows)).toEqual(["Thought for 4.0s"]); + const detailGroup = rows.find( + (row) => row.type === "activity-group" && row.id.startsWith("work-details:"), + ); + expect(detailGroup?.type).toBe("activity-group"); + if (detailGroup?.type === "activity-group") { + expect(detailGroup.activities[0]?.workEntry.detail).toBe(text); + } + const toggle = rows.find((row) => row.type === "work-toggle"); + expect(toggle).toMatchObject({ type: "work-toggle", expanded: true }); + }); + it("keeps settled history monotonic while the live tail alternates", () => { const runningTurn = { ...settledTurn, state: "running" as const, completedAt: null }; const commentary = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c6bbfdbe4da7..f499abc81e9d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2233,6 +2233,9 @@ function appendThinkingSegmentRows( activity.id === thinkingLive.designatedThinkingActivityId && !thinkingLive.hasLiveToolActivity; const shimmer = live; + // Match web/Codex: provider reasoning text is visible under the Thought + // label by default, not hidden behind a closed disclosure. + const showBody = expanded || Boolean(entry.detail?.trim()); result.push({ type: "work-toggle", // The shimmering row is the turn's live slot; it keeps that identity @@ -2242,14 +2245,14 @@ function appendThinkingSegmentRows( turnId: sourceGroup.turnId, groupId, hiddenCount: 1, - expanded, + expanded: showBody, summary: live ? "Thinking" : formatThinkingSegmentLabel(span), summaryKind: toolGroupSummaryKind([entry]), hasFailure: false, live, shimmer, }); - if (!expanded) { + if (!showBody) { continue; } result.push({ diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 272ad320ad2b..1fd7358069b0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2487,41 +2487,50 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} - > - - {label} - - {getQuestionAnswerPreview(row.entry.questionAnswer)} +
+ + ) : ( + label + ) + } + iconName={workEntryIconName(row.entry)} + toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} + failed={failed} + active={row.active} + /> + + {thinkingText ? ( +
+
{thinkingText}
+
+ ) : null} +
); } @@ -4037,9 +4046,21 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); const groupView = use(WorkGroupViewCtx); + const thinkingText = workEntry.tone === "thinking" ? workEntry.detail?.trim() : undefined; + // Codex shows readable thought text inline under the Thought label. Default + // open when provider text exists; the user can still collapse it. const [expanded, setExpanded] = useState( - () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, + () => Boolean(thinkingText) || (groupView?.state.expandedEntries.has(workEntry.id) ?? false), ); + const hadThinkingTextRef = useRef(Boolean(thinkingText)); + useEffect(() => { + const hasText = Boolean(thinkingText); + if (hasText && !hadThinkingTextRef.current) { + setExpanded(true); + groupView?.state.expandedEntries.add(workEntry.id); + } + hadThinkingTextRef.current = hasText; + }, [groupView, thinkingText, workEntry.id]); const toggleExpanded = () => { const next = !expanded; if (groupView) { From d3e831de9dddd788c92b9f73301284c29b9c983c Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:42:23 -0700 Subject: [PATCH 09/14] fix(chat): render text-bearing reasoning as inline Markdown Match Codex UX: provider-supplied reasoning text streams as chronological Markdown between tools, without per-thought Thought-for-Xs accordions. Boundary-only segments keep Thinking / Thought timing. Settled turns still fold into the existing single Worked-for / You-stopped disclosure. --- .../src/features/threads/ThreadFeed.tsx | 20 +++ apps/mobile/src/lib/threadActivity.test.ts | 24 ++-- apps/mobile/src/lib/threadActivity.ts | 42 +++++- .../chat/MessagesTimeline.logic.test.ts | 129 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 93 +++++++++---- .../src/components/chat/MessagesTimeline.tsx | 112 +++++++-------- .../src/work-log/presentation.ts | 7 + 7 files changed, 327 insertions(+), 100 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 66c4359b8190..b3ae943ef2ff 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1410,6 +1410,24 @@ function renderFeedEntry( return ; } + if (entry.type === "reasoning-markdown") { + const styles = props.markdownStyles; + return ( + + + + + + ); + } + if (entry.type === "agent-spawn") { return ( { ]); }); - it("shows provider reasoning text under Thought rows without requiring expand", () => { + 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({ @@ -3577,24 +3577,24 @@ describe("reasoning segments", () => { detail: text, }, }), + toolActivity("tool-1", 5), ], }); - // No expanded work groups — text should still surface. const rows = deriveThreadFeedPresentation( buildThreadFeed(thread), settledTurn, new Set([turnId]), ); - expect(summaries(rows)).toEqual(["Thought for 4.0s"]); - const detailGroup = rows.find( - (row) => row.type === "activity-group" && row.id.startsWith("work-details:"), - ); - expect(detailGroup?.type).toBe("activity-group"); - if (detailGroup?.type === "activity-group") { - expect(detailGroup.activities[0]?.workEntry.detail).toBe(text); - } - const toggle = rows.find((row) => row.type === "work-toggle"); - expect(toggle).toMatchObject({ type: "work-toggle", expanded: true }); + 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 settled history monotonic while the live tail alternates", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f499abc81e9d..5a9f893f9b3f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -26,6 +26,7 @@ import { liveActivityToolStatus, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, + reasoningHasVisibleText, reasoningSegmentSpanForEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -203,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 @@ -1773,6 +1783,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( @@ -1948,7 +1959,17 @@ function designateLiveThinkingScope( 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, @@ -2233,9 +2254,18 @@ function appendThinkingSegmentRows( activity.id === thinkingLive.designatedThinkingActivityId && !thinkingLive.hasLiveToolActivity; const shimmer = live; - // Match web/Codex: provider reasoning text is visible under the Thought - // label by default, not hidden behind a closed disclosure. - const showBody = expanded || Boolean(entry.detail?.trim()); + const text = entry.detail?.trim() ?? ""; + if (reasoningHasVisibleText(entry) && text.length > 0) { + result.push({ + type: "reasoning-markdown", + id: `reasoning-markdown:${groupId}:${activity.id}`, + 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 @@ -2245,14 +2275,14 @@ function appendThinkingSegmentRows( turnId: sourceGroup.turnId, groupId, hiddenCount: 1, - expanded: showBody, + expanded, summary: live ? "Thinking" : formatThinkingSegmentLabel(span), summaryKind: toolGroupSummaryKind([entry]), hasFailure: false, live, shimmer, }); - if (!showBody) { + if (!expanded) { continue; } result.push({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index a80f5e66ab43..26d37547eb39 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3483,6 +3483,135 @@ describe("reasoning segments", () => { ).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("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[] = [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 52350824dd69..ee0c53737282 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -8,6 +8,7 @@ import { omitSupersededLifecycleMarkers, formatThinkingSegmentLabel, isReasoningSegmentEntry, + reasoningHasVisibleText, reasoningSegmentSpanForEntry, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -58,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); @@ -75,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, @@ -403,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 { @@ -1216,41 +1235,54 @@ export function deriveMessagesTimelineRows(input: { } 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); - if (liveEntry !== undefined) { - const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); - const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; - nextRows.push({ - kind: "work-live", - id: `work-live:${workGroupIdentity(timelineEntry.id, timelineEntry.entry)}`, - createdAt: timelineEntry.createdAt, - entry: liveEntry, - groupedEntries: visibleGroupedEntries, - groupId, - expanded, - active: true, - }); - hasActivityRow = true; - if (expanded) { - nextRows.push( - expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), - ); - } - } else { - for (const entry of visibleGroupedEntries) { - const span = reasoningSegmentSpanForEntry(entry); + 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) { nextRows.push({ - kind: "work", - id: `thinking-segment:${timelineEntry.id}:${entry.id}`, + kind: "reasoning-markdown", + id: `reasoning-markdown:${timelineEntry.id}:${entry.id}`, createdAt: span.startedAt ?? entry.createdAt, - groupedEntries: [entry], - isExpandedToolGroup: false, - displayLabel: formatThinkingSegmentLabel(span), + 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 && @@ -1522,6 +1554,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 1fd7358069b0..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} ); }); @@ -2487,49 +2489,63 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract - - {thinkingText ? ( -
-
{thinkingText}
-
- ) : null} +
+ ) : ( + label + ) + } + iconName={workEntryIconName(row.entry)} + toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} + failed={failed} + active={row.active} + /> + + ); +} + +function ReasoningMarkdownTimelineRow({ + row, +}: { + row: Extract; +}) { + const ctx = use(TimelineRowCtx); + return ( +
+
); } @@ -4046,21 +4062,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); const groupView = use(WorkGroupViewCtx); - const thinkingText = workEntry.tone === "thinking" ? workEntry.detail?.trim() : undefined; - // Codex shows readable thought text inline under the Thought label. Default - // open when provider text exists; the user can still collapse it. const [expanded, setExpanded] = useState( - () => Boolean(thinkingText) || (groupView?.state.expandedEntries.has(workEntry.id) ?? false), + () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, ); - const hadThinkingTextRef = useRef(Boolean(thinkingText)); - useEffect(() => { - const hasText = Boolean(thinkingText); - if (hasText && !hadThinkingTextRef.current) { - setExpanded(true); - groupView?.state.expandedEntries.add(workEntry.id); - } - hadThinkingTextRef.current = hasText; - }, [groupView, thinkingText, workEntry.id]); const toggleExpanded = () => { const next = !expanded; if (groupView) { diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index b66bbe8d0387..9fa7293e0a3e 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -389,6 +389,13 @@ export function isReasoningSegmentEntry( ); } +/** 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; From 9e3943446be5f0150d05e3927366dd16a012eb3d Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:42:07 -0700 Subject: [PATCH 10/14] fix(chat): drop intermediate reasoning text copies from snapshots Readable OpenCode reasoning streams full-body detail on repeated item.updated events. Snapshot projection kept every update, so long thoughts retained cumulative prefixes. Keep the first update for start timing (without partial detail once completed) and the latest/final text only. Live appends are unchanged. --- .../ActivityPayloadProjection.test.ts | 96 ++++++++++++++++++- .../ActivityPayloadProjection.ts | 81 ++++++++++++---- 2 files changed, 155 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 49db9ccd8762..94c8bf3e3c4c 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -371,7 +371,7 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { createdAt, }) as unknown as OrchestrationThreadActivity; - it("keeps reasoning updates their completion would otherwise supersede", () => { + it("keeps the reasoning start update that completion would otherwise supersede", () => { const snapshot = { snapshotSequence: 0, thread: { @@ -397,12 +397,102 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { } as unknown as Parameters[0]; const projected = projectThreadDetailSnapshot(snapshot); - // The ordinary tool update is slimmed away, but the reasoning pair must - // survive reloads: clients bound segment durations from the update. + // 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", () => { + const chunks = Array.from({ length: 40 }, (_, index) => `word${index} `); + const growing = chunks.map((_, index) => chunks.slice(0, index + 1).join("")); + const finalText = growing[growing.length - 1]!; + expect(finalText.length).toBeGreaterThan(180); + + const activities: OrchestrationThreadActivity[] = growing.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; + }); + 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 cumulative prefix. + 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 only the latest in-flight reasoning update for mid-turn reloads", () => { + const activities = [1, 2, 3].map((n) => { + const row = lifecycleActivity( + `reasoning-updated-${n}`, + "tool.updated", + "reasoning", + "reasoning-live", + `t${n}`, + ); + return { + ...row, + payload: { + ...(row.payload as Record), + detail: `partial-${n}`, + }, + } 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-3", + ]); + expect((projected.thread.activities[0]?.payload as Record).detail).toBe( + "partial-3", + ); + }); }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 9ac26ddcbc14..8778e44b00d3 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -609,45 +609,88 @@ 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) => { - if (activity.kind !== "tool.updated") { - return true; + // Completed thoughts: keep the first update for start timing only. + // In-flight thoughts: keep the latest update for current text. + // Intermediate streaming updates grow full-body detail and are dropped + // from snapshots (live clients already received them as appends). + const retainedReasoningUpdateIndices = new Set(); + const stripDetailFromReasoningUpdateIndices = new Set(); + for (const [key, updateIndices] of updateIndicesByKey) { + const firstUpdate = updateIndices[0]; + const lastUpdate = updateIndices[updateIndices.length - 1]; + if (firstUpdate === undefined || lastUpdate === undefined) { + continue; } - // Reasoning updates are structural, not streaming noise: clients pair - // each update with its completion to bound the thinking segment's - // duration, and the completion alone carries no start time. Segments are - // rare (one pair per thought) next to per-chunk tool updates, so keeping - // them costs nothing. - if (asRecord(activity.payload)?.itemType === "reasoning") { - return true; + const payload = asRecord(activities[firstUpdate]?.payload); + if (payload?.itemType !== "reasoning") { + continue; + } + const hasLaterCompletion = + completionIndicesByKey.get(key)?.some((index) => index > firstUpdate) ?? false; + if (hasLaterCompletion) { + retainedReasoningUpdateIndices.add(firstUpdate); + stripDetailFromReasoningUpdateIndices.add(firstUpdate); + } else { + retainedReasoningUpdateIndices.add(lastUpdate); + } + } + + return activities.flatMap((activity, index) => { + if (activity.kind !== "tool.updated") { + 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 []; + } + 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]; }); } From 7e14305d664701eed88428153ae489df56dd0ecc Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:04:04 -0700 Subject: [PATCH 11/14] fix(chat): retain reasoning start and latest text in mid-turn snapshots In-flight reasoning snapshots must keep the first update for segment start and the latest update for current detail. Strip partial detail from the start row when a later update or completion carries the body text. --- .../ActivityPayloadProjection.test.ts | 48 +++++++++++++++++-- .../ActivityPayloadProjection.ts | 16 ++++--- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 94c8bf3e3c4c..9ef8b8f582b4 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -466,14 +466,14 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { expect(detailBytes).toBe(finalText.length); }); - it("keeps only the latest in-flight reasoning update for mid-turn reloads", () => { - const activities = [1, 2, 3].map((n) => { + it("keeps first and latest in-flight reasoning updates across distinct createdAt values", () => { + const activities = [1, 2, 3, 4, 5].map((n) => { const row = lifecycleActivity( `reasoning-updated-${n}`, "tool.updated", "reasoning", "reasoning-live", - `t${n}`, + `2026-08-01T10:00:0${n}.000Z`, ); return { ...row, @@ -489,10 +489,48 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { } as unknown as Parameters[0]); expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ - "reasoning-updated-3", + "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"); + expect((projected.thread.activities[1]?.payload as Record).detail).toBe( + "partial-5", + ); + }); + + 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( - "partial-3", + "only-partial", ); }); }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 8778e44b00d3..71be95d12316 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -640,10 +640,11 @@ function dropSupersededToolUpdatedActivities( return activities; } - // Completed thoughts: keep the first update for start timing only. - // In-flight thoughts: keep the latest update for current text. - // Intermediate streaming updates grow full-body detail and are dropped - // from snapshots (live clients already received them as appends). + // Completed thoughts: first update (start timing, no partial detail) + + // completion (final text). In-flight thoughts: first update (start timing, + // no partial detail) + latest update (current text). 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(); for (const [key, updateIndices] of updateIndicesByKey) { @@ -658,11 +659,14 @@ function dropSupersededToolUpdatedActivities( } const hasLaterCompletion = completionIndicesByKey.get(key)?.some((index) => index > firstUpdate) ?? false; + retainedReasoningUpdateIndices.add(firstUpdate); if (hasLaterCompletion) { - retainedReasoningUpdateIndices.add(firstUpdate); stripDetailFromReasoningUpdateIndices.add(firstUpdate); - } else { + continue; + } + if (lastUpdate !== firstUpdate) { retainedReasoningUpdateIndices.add(lastUpdate); + stripDetailFromReasoningUpdateIndices.add(firstUpdate); } } From 2d9df7797a53a7d7b91c72c51a867a0f6eda6bf1 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:07:41 -0700 Subject: [PATCH 12/14] fix(mobile): use assistant markdown styles for reasoning rows Upstream typed AssistantMarkdownContent against MarkdownStyleSet; pass markdownStyles.assistant after the rebase instead of the style-set bag. --- apps/mobile/src/features/threads/ThreadFeed.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b3ae943ef2ff..6964386a546e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1411,13 +1411,12 @@ function renderFeedEntry( } if (entry.type === "reasoning-markdown") { - const styles = props.markdownStyles; return ( Date: Sun, 13 Sep 2026 23:24:36 -0700 Subject: [PATCH 13/14] fix(opencode): stream reasoning as incremental lifecycle detail Carry delta-only reasoning growth on item.updated so live Markdown updates without O(N^2) cumulative bodies, refresh completed-part edits, gate status-less Codex reasoning out of ingestion, and keep stable reasoning-markdown row ids. --- apps/mobile/src/lib/threadActivity.test.ts | 79 ++++++++++++ apps/mobile/src/lib/threadActivity.ts | 42 ++++-- .../ActivityPayloadProjection.test.ts | 26 +++- .../ActivityPayloadProjection.ts | 22 +++- .../ProviderRuntimeIngestion.activity.test.ts | 27 ++++ .../Layers/ProviderRuntimeIngestion.ts | 14 +- .../provider/Layers/OpenCodeAdapter.test.ts | 120 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 98 +++++++++++++- .../chat/MessagesTimeline.logic.test.ts | 44 +++++++ .../components/chat/MessagesTimeline.logic.ts | 8 +- apps/web/src/session-logic.test.ts | 84 ++++++++++++ apps/web/src/session-logic.ts | 52 ++++++-- 12 files changed, 568 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4e0b14c28c5a..da88069b4211 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3597,6 +3597,85 @@ describe("reasoning segments", () => { ).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 = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5a9f893f9b3f..427cd5b01e67 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -556,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, @@ -851,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; @@ -870,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; @@ -890,17 +909,15 @@ function mergeDerivedWorkLogEntries( const segmentStartedAt = next.segmentStartedAt ?? previous.segmentStartedAt ?? - (isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next) - ? previous.createdAt - : undefined); + (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. - const reasoningMerge = isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next); + // 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, - ...(!reasoningMerge ? { id: previous.id, createdAt: previous.createdAt } : {}), + ...(!reasoningMerge ? { id: previous.id, createdAt: previous.createdAt } : { id: previous.id }), ...(detail ? { detail } : {}), ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), @@ -2256,9 +2273,14 @@ function appendThinkingSegmentRows( 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:${groupId}:${activity.id}`, + id: `reasoning-markdown:${reasoningIdentity}`, createdAt: span.startedAt ?? activity.createdAt, turnId: sourceGroup.turnId, text, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 9ef8b8f582b4..ce109544f833 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -407,12 +407,12 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { }); 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 growing = chunks.map((_, index) => chunks.slice(0, index + 1).join("")); - const finalText = growing[growing.length - 1]!; + const finalText = chunks.join(""); expect(finalText.length).toBeGreaterThan(180); - const activities: OrchestrationThreadActivity[] = growing.map((detail, index) => { + const activities: OrchestrationThreadActivity[] = chunks.map((detail, index) => { const row = lifecycleActivity( `reasoning-updated-${index}`, "tool.updated", @@ -428,6 +428,15 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { }, } 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", @@ -458,7 +467,7 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { 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 cumulative prefix. + // 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); @@ -467,7 +476,9 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { }); it("keeps first and latest in-flight reasoning updates across distinct createdAt values", () => { - const activities = [1, 2, 3, 4, 5].map((n) => { + 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", @@ -479,7 +490,7 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { ...row, payload: { ...(row.payload as Record), - detail: `partial-${n}`, + detail, }, } as OrchestrationThreadActivity; }); @@ -498,8 +509,9 @@ describe("projectThreadDetailSnapshot reasoning retention", () => { (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( - "partial-5", + chunks.join(""), ); }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 71be95d12316..09f861444c2d 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -642,11 +642,13 @@ function dropSupersededToolUpdatedActivities( // Completed thoughts: first update (start timing, no partial detail) + // completion (final text). In-flight thoughts: first update (start timing, - // no partial detail) + latest update (current text). If first === latest, - // keep that single update with its detail. Intermediate streaming updates - // are dropped from snapshots (live clients already received them as appends). + // 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]; @@ -667,6 +669,15 @@ function dropSupersededToolUpdatedActivities( 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); + } } } @@ -683,6 +694,11 @@ function dropSupersededToolUpdatedActivities( 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]; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts index 97ade3c41db6..62b039bb0d3b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts @@ -255,4 +255,31 @@ describe("runtimeEventToActivities reasoning lifecycle", () => { ).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 819be179fb63..351eab77ae6c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -849,12 +849,15 @@ export function runtimeEventToActivities( case "item.updated": { // Reasoning items project like tools so clients can render thinking - // segments as activity boundaries. When an adapter supplies provider - // reasoning text on lifecycle `detail`, preserve it (content.delta - // reasoning_text is still dropped above; detail is the carrier). + // 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.itemType === "reasoning" && event.payload.status !== undefined) ) { return []; } @@ -902,9 +905,10 @@ export function runtimeEventToActivities( case "item.completed": { // 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.itemType === "reasoning" && event.payload.status !== undefined) ) { return []; } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 269e5b608090..b979af04c19a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7129,6 +7129,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { [ ["reasoning", "Thinking"], ["assistant_message", "Hello world"], + ["reasoning", "Thinking more"], ["assistant_message", "Fresh"], ["assistant_message", "Second"], ["reasoning", "New thoughts"], @@ -7138,7 +7139,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { // 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 (no mid-flight streaming updates). + // 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"); @@ -7156,10 +7158,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const reasoningCompletions = events .filter((event) => event.type === "item.completed") .filter((event) => event.payload.itemType === "reasoning"); - NodeAssert.equal(reasoningCompletions.length, 2); NodeAssert.deepEqual( reasoningCompletions.map((event) => event.payload.detail), - ["Thinking", "New thoughts"], + ["Thinking", "Thinking more", "New thoughts"], ); for (const completed of reasoningCompletions) { NodeAssert.equal(completed.itemId, "reasoning-part"); @@ -7374,7 +7375,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { reasoningLifecycle.map((event) => [event.type, event.payload.status, event.payload.detail]), [ ["item.updated", "inProgress", "The user wants to know their opencode version."], - ["item.updated", "inProgress", finalText], + // Growth is incremental, not the full cumulative body. + ["item.updated", "inProgress", " I should run the command to check."], ["item.completed", "completed", finalText], ], ); @@ -7391,6 +7393,116 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); + 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; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 2b8f5512e71c..972656785c69 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1748,6 +1748,7 @@ export function makeOpenCodeAdapter( 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. @@ -1777,9 +1778,11 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); - // Stream provider reasoning text onto the open thought item so clients - // can show growing detail without inventing a second channel. Skip the - // first sighting when emitReasoningSegmentEvent already carried detail. + // 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 && @@ -1799,7 +1802,7 @@ export function makeOpenCodeAdapter( itemType: "reasoning", status: "inProgress", title: "Thinking", - detail: latestText, + detail: deltaToEmit, }, }); } @@ -1807,6 +1810,37 @@ export function makeOpenCodeAdapter( // 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) { part.completed = true; @@ -2619,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; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 26d37547eb39..dc90a5bd1425 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3535,6 +3535,50 @@ describe("reasoning segments", () => { ); }); + 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."; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ee0c53737282..17eabeadc417 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1246,9 +1246,15 @@ export function deriveMessagesTimelineRows(input: { 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:${timelineEntry.id}:${entry.id}`, + id: `reasoning-markdown:${reasoningIdentity}`, createdAt: span.startedAt ?? entry.createdAt, text, streaming: isLive, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4f19df054ae3..643c51681720 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2563,6 +2563,90 @@ describe("reasoning segment derivation", () => { }); }); + 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({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 88d2374f8657..932c4ef53c36 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -577,18 +577,23 @@ 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 - : null - : extractToolDetail(payload, title ?? activity.summary); - const toolCallId = isTaskActivity ? null : extractToolCallId(payload); // 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 + : 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, createdAt: activity.createdAt, @@ -839,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 && @@ -861,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; @@ -881,12 +904,13 @@ function mergeDerivedWorkLogEntries( const segmentStartedAt = next.segmentStartedAt ?? previous.segmentStartedAt ?? - (isReasoningSegmentEntry(previous) && isReasoningSegmentEntry(next) - ? previous.createdAt - : undefined); + (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 } : {}), From 52e97e552c096dcc0d74090e48082560ceb2eb56 Mon Sep 17 00:00:00 2001 From: MONKE2525E <221282747+MONKE2525E@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:31:51 -0700 Subject: [PATCH 14/14] fix(server): keep incremental reasoning updates out of live coalescing Reasoning tool.updated events carry append-only detail chunks. Coalescing them to the latest update per toolCallId dropped intermediate text on the live wire. Pass reasoning updates through unchanged. --- .../ThreadLiveEventCoalescer.test.ts | 34 +++++++++++++++++-- .../orchestration/ThreadLiveEventCoalescer.ts | 16 ++++++++- .../src/work-log/presentation.ts | 4 +-- 3 files changed, 48 insertions(+), 6 deletions(-) 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/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 9fa7293e0a3e..501cc62999c9 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -377,8 +377,8 @@ export function isReasoningItemPayload(payload: unknown): boolean { * 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. + * 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,