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