diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20848cd2ad10..717d19b6283c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -149,7 +149,7 @@ import { } from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, - deriveUnsettledTurnId, + deriveActiveFeedTurnId, isContextCompactionActivityGroup, type ThreadFeedEntry, type ThreadFeedLatestTurn, @@ -1486,13 +1486,71 @@ function renderFeedEntry( const { message } = entry; if (message.role === "reasoning") { const messages = entry.reasoningMessages ?? [message]; + if (entry.reasoningKind === "summary") { + const chunks = messages.map((item) => item.text.trim()).filter(Boolean); + const text = chunks.join(" "); + if (text.length === 0) return null; + const longSummary = + text.length > 2_000 || + chunks.reduce((count, chunk) => count + chunk.split("\n").length, 0) > 25; + const expanded = props.expandedReasoningMessageIds.has(entry.id); + const preview = chunks + .slice(0, 25) + .join(" ") + .slice(0, 2_000) + .split("\n") + .slice(0, 25) + .join("\n"); + const summaryContent = ( + + + + + + ); + return ( + + {longSummary && expanded ? ( + + {summaryContent} + + ) : ( + summaryContent + )} + {longSummary ? ( + props.onToggleReasoning(entry.id)} + className="min-h-11 justify-center px-1" + > + + {expanded ? "Show less" : "Show more"} + + + ) : null} + + ); + } return ( 1 ? ` (×${messages.length})` : ""}`} - streaming={false} + streaming={messages.some( + (reasoningMessage) => + reasoningMessage.streaming && + (reasoningMessage.turnId + ? reasoningMessage.turnId === props.unsettledTurnId + : props.isWorking), + )} onToggle={() => props.onToggleReasoning(entry.id)} > { ), }); const initial = buildThreadFeed(thread); - expect(initial.map((row) => row.id)).toEqual(["work-1", "message-4", "work-5"]); + expect(initial.map((row) => row.id)).toEqual([ + "work-1", + "message-2", + "work-3", + "message-4", + "work-5", + ]); const split = buildThreadFeed({ ...thread, messages: [{ ...messages[0]!, text: "Now visible" }, messages[1]!], @@ -426,17 +433,18 @@ describe("buildThreadFeed", () => { "message-4", "work-5", ]); - expect(split[0]).not.toBe(initial[0]); + expect(split[0]).toBe(initial[0]); + expect(split[1]).not.toBe(initial[1]); expect(split.at(-1)).toBe(initial.at(-1)); - expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }, { id: "work-3" }] }); + expect(initial[0]).toMatchObject({ activities: [{ id: "work-1" }] }); const reordered = buildThreadFeed({ ...thread, messages: [messages[0]!, { ...messages[1]!, createdAt: "2026-04-01T00:00:06.000Z" }], }); - expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-4"]); - expect(reordered[0]).toMatchObject({ - activities: [{ id: "work-1" }, { id: "work-3" }, { id: "work-5" }], + expect(reordered.map((row) => row.id)).toEqual(["work-1", "message-2", "work-3", "message-4"]); + expect(reordered[2]).toMatchObject({ + activities: [{ id: "work-3" }, { id: "work-5" }], }); const olderMessage = { ...messages[1]!, @@ -452,6 +460,8 @@ describe("buildThreadFeed", () => { expect(prepended.map((row) => row.id)).toEqual([ "older-message", "work-1", + "message-2", + "work-3", "message-4", "work-5", ]); @@ -485,6 +495,48 @@ describe("buildThreadFeed", () => { ]); }); + it("keeps compaction visible beside a settled work fold", () => { + const turnId = TurnId.make("turn-compaction-with-work"); + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ + messages: [ + { + id: MessageId.make("answer"), + role: "assistant", + text: "Done", + turnId, + streaming: false, + createdAt: "2026-09-01T00:00:03.000Z", + updatedAt: "2026-09-01T00:00:03.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("compaction"), + kind: "context-compaction", + tone: "info", + summary: "Compacted context", + createdAt: "2026-09-01T00:00:01.000Z", + turnId, + }), + makeActivity({ + id: EventId.make("command"), + kind: "tool.completed", + tone: "tool", + summary: "Ran command", + createdAt: "2026-09-01T00:00:02.000Z", + turnId, + payload: { itemType: "command_execution", title: "Ran command", status: "completed" }, + }), + ], + }), + null, + new Set(), + ); + expect(rows.map((row) => row.type)).toEqual(["activity-group", "turn-fold", "message"]); + expect(rows[0]?.id).toBe("compaction"); + }); + it("keeps long Claude commands expandable without repeating them in full detail", () => { const command = `printf 'first line\nsecond line'\n&& printf done`; const thread = makeThread({ @@ -1834,6 +1886,21 @@ describe("buildThreadFeed", () => { label: "Worked for 17s", expanded: false, }); + const failedFeed = buildThreadFeed({ + ...thread, + messages: thread.messages.map((message) => + message.id === MessageId.make("assistant-final") + ? { ...message, streaming: true } + : message, + ), + }); + expect( + deriveThreadFeedPresentation( + failedFeed, + { ...thread.latestTurn!, state: "error" }, + new Set(), + ).map((entry) => entry.id), + ).toEqual(["assistant-first", "turn-fold:turn-1", "assistant-final"]); const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); expect(expanded.map((entry) => entry.id)).toEqual([ @@ -2318,9 +2385,9 @@ describe("buildThreadFeed", () => { it("groups ordered reasoning blocks, keeps the live slot, and restores the group after unfolding", () => { const turnId = TurnId.make("reasoning-group"); const messages: OrchestrationThread["messages"] = [1, 2, 3, 4].map((second) => ({ - id: MessageId.make(`reasoning-${second}`), + id: MessageId.make(`reasoning:raw:${second}`), role: "reasoning", - text: `**Step ${second}**\n\nCheck ${second}.`, + text: `**Step ${second}**\n\n${`Check ${second}. `.repeat(250)}`, turnId, streaming: second === 4, createdAt: `2026-04-01T00:00:0${second}.000Z`, @@ -2397,7 +2464,7 @@ describe("buildThreadFeed", () => { expect(toolRunning[0]).not.toMatchObject({ summary: "Thinking" }); const nextThought = { ...messages[3]!, - id: MessageId.make("reasoning-after-tool"), + id: MessageId.make("reasoning:raw:after-tool"), createdAt: "2026-04-01T00:00:06.000Z", updatedAt: "2026-04-01T00:00:06.000Z", }; @@ -2425,7 +2492,7 @@ describe("buildThreadFeed", () => { reasoningAgainFeed, thread.latestTurn, new Set(), - new Set([`activity-run:${messages[0]!.id}`]), + new Set(messages.map((message) => `activity-run:${message.id}`)), "now", ); expect(expandedLive.map((entry) => entry.type)).toEqual([ @@ -2578,7 +2645,7 @@ describe("buildThreadFeed", () => { if (boundary === "failed-tool") { // A failed call stays inside the run instead of splitting it. expect(rows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ - { hasFailure: true, hiddenCount: 3 }, + { hasFailure: true, hiddenCount: 1 }, ]); } expect(reasoningRows).toEqual( @@ -2587,11 +2654,515 @@ describe("buildThreadFeed", () => { id: message.id, createdAt: message.createdAt, message, + reasoningKind: "summary", })), ); }, ); + it("keeps short OpenCode progress inline and its raw trace separately expandable", () => { + const turnId = TurnId.make("reasoning-kinds"); + const messages: OrchestrationThread["messages"] = [ + ["reasoning:raw:first", "Private trace"], + ["assistant:progress", "Checking the review."], + ["reasoning:raw:second", "More private trace"], + ].map(([id, value], index) => ({ + id: MessageId.make(id!), + role: "reasoning" as const, + text: value!, + turnId, + streaming: false, + createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, + updatedAt: `2026-04-01T00:00:0${index + 1}.000Z`, + })); + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set([turnId]), + new Set([`activity-run:${messages[0]!.id}`]), + ).filter((row) => row.type === "message"); + expect(rows.map((row) => row.type === "message" && row.reasoningKind)).toEqual([ + "raw", + "summary", + "raw", + ]); + const collapsed = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set([turnId]), + ); + expect(collapsed.map((row) => row.type)).toEqual(["work-toggle", "message"]); + expect(collapsed[1]).toMatchObject({ message: messages[1], reasoningKind: "summary" }); + }); + + it("recognizes raw reasoning after an answer when a prior run has a summary", () => { + const turnId = TurnId.make("split-reasoning-kinds"); + const messages: OrchestrationThread["messages"] = [ + ["reasoning:summary:first", "Checking the review.", "reasoning"], + ["assistant:answer", "Initial answer", "assistant"], + ["reasoning:raw:later", "Short raw trace", "reasoning"], + ].map(([id, value, role], index) => ({ + id: MessageId.make(id!), + role: role as "reasoning" | "assistant", + text: value!, + turnId, + streaming: false, + createdAt: `2026-04-01T00:00:0${index + 1}.000Z`, + updatedAt: `2026-04-01T00:00:0${index + 1}.000Z`, + })); + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set([turnId]), + new Set([`activity-run:${messages[2]!.id}`]), + ); + expect(rows.find((row) => row.id === messages[2]!.id)).toMatchObject({ + type: "message", + reasoningKind: "raw", + }); + }); + + it("keeps a long provider summary inline and a short raw-only thought inline", () => { + const turnId = TurnId.make("reasoning-lengths"); + const summary = { + id: MessageId.make("reasoning:summary:long"), + role: "reasoning" as const, + text: "Summary ".repeat(300), + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:01.000Z", + }; + const shortRaw = { + ...summary, + id: MessageId.make("reasoning:raw:short"), + text: "Checking the result.", + turnId: TurnId.make("raw-only"), + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages: [summary, shortRaw], activities: [] }), + null, + new Set([turnId, shortRaw.turnId]), + ); + expect(rows.filter((row) => row.type === "message").map((row) => row.reasoningKind)).toEqual([ + "summary", + "summary", + ]); + }); + + it("folds reasoning-only work after a final answer", () => { + const turnId = TurnId.make("reasoning-only"); + const messages: OrchestrationThread["messages"] = [ + { + id: MessageId.make("reasoning:raw:trace"), + role: "reasoning", + text: "Checking the branch", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:01.000Z", + }, + { + id: MessageId.make("assistant:final"), + role: "assistant", + text: "Audit complete", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + ]; + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + { + turnId, + state: "completed", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + }, + new Set(), + ); + expect(rows.map((row) => row.type)).toEqual(["turn-fold", "message"]); + + const nextUser = { + id: MessageId.make("next-prompt"), + role: "user" as const, + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:04.000Z", + updatedAt: "2026-04-01T00:00:04.000Z", + }; + const whileNextTurnStarts = deriveThreadFeedPresentation( + buildThreadFeed({ + messages: [{ ...messages[0]!, streaming: true }, messages[1]!, nextUser], + activities: [], + }), + { + turnId: TurnId.make("next-turn"), + state: "running", + startedAt: nextUser.createdAt, + completedAt: null, + }, + new Set(), + new Set(), + nextUser.createdAt, + ); + expect(whileNextTurnStarts.some((row) => row.type === "turn-fold")).toBe(true); + }); + + it("retains a settled empty assistant answer after reasoning", () => { + const turnId = TurnId.make("empty-answer"); + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ + messages: [ + { + id: MessageId.make("reasoning:summary:empty-answer"), + role: "reasoning", + text: "Checked the branch.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:01.000Z", + }, + { + id: MessageId.make("assistant:empty:empty-answer"), + role: "assistant", + text: "", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + ], + activities: [], + }), + { + turnId, + state: "completed", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + }, + new Set(), + ); + expect(rows.map((row) => row.type)).toEqual(["turn-fold", "message"]); + expect(rows.at(-1)).toMatchObject({ + type: "message", + message: { role: "assistant", text: "" }, + }); + const providerEmptyRows = buildThreadFeed({ + messages: [ + { + id: MessageId.make("provider-empty-answer"), + role: "assistant", + text: " ", + turnId, + streaming: true, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + ], + activities: [], + }); + expect(providerEmptyRows).toMatchObject([ + { type: "message", message: { id: "provider-empty-answer", text: " " } }, + ]); + }); + + it("keeps one work group across intermediate empty assistant messages", () => { + const turnId = TurnId.make("turn-empty-progress"); + const at = (second: number) => `2026-04-01T00:00:0${second}.000Z`; + const rows = buildThreadFeed({ + messages: [ + { + id: MessageId.make("intermediate-empty"), + role: "assistant", + text: "", + turnId, + streaming: false, + createdAt: at(2), + updatedAt: at(2), + }, + { + id: MessageId.make("terminal-empty"), + role: "assistant", + text: "", + turnId, + streaming: false, + createdAt: at(4), + updatedAt: at(4), + }, + ], + activities: [1, 3].map((second) => + makeActivity({ + id: EventId.make(`tool-${second}`), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + turnId, + createdAt: at(second), + payload: { itemType: "file_read", status: "completed" }, + }), + ), + }); + + expect(rows).toMatchObject([ + { type: "activity-group", activities: [{ id: "tool-1" }, { id: "tool-3" }] }, + { type: "message", message: { id: "terminal-empty", text: "" } }, + ]); + }); + + it("leaves failed work visible when the turn has no answer", () => { + const turnId = TurnId.make("failed-without-answer"); + const latestTurn = { + turnId, + state: "error" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + assistantMessageId: null, + }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ + messages: [ + { + id: MessageId.make("reasoning:raw:failed"), + role: "reasoning", + text: "Checking the provider.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:01.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("provider-error"), + kind: "runtime.error", + tone: "error", + summary: "Provider error", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { message: "Connection lost" }, + }), + ], + }), + latestTurn, + new Set(), + ); + expect(rows.some((row) => row.type === "turn-fold")).toBe(false); + expect(rows.some((row) => row.type === "activity-group")).toBe(true); + }); + + it("keeps a failed tool visible when its display tone is still tool", () => { + const turnId = TurnId.make("failed-tool-without-answer"); + const feed = buildThreadFeed({ + messages: [ + { + id: MessageId.make("failed-tool-reasoning"), + role: "reasoning", + text: "Checking the tests.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:01.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("failed-tool"), + kind: "tool.completed", + tone: "tool", + summary: "Run tests", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { itemType: "command_execution", status: "failed", detail: "Exit code 1" }, + }), + ], + }); + const rows = deriveThreadFeedPresentation( + feed, + { + turnId, + state: "completed", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + }, + new Set(), + ); + expect(rows.some((row) => row.type === "turn-fold")).toBe(false); + expect(rows.some((row) => row.type === "work-toggle" && row.hasFailure)).toBe(true); + }); + + it("keeps a new live tool visible while the latest turn projection still names the prior turn", () => { + const priorTurnId = TurnId.make("prior-turn"); + const activeTurnId = TurnId.make("new-running-turn"); + const latestTurn = { + turnId: priorTurnId, + state: "completed" as const, + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: "2026-04-01T00:00:01.000Z", + }; + const feed = buildThreadFeed({ + messages: [ + { + id: MessageId.make("new-turn-prompt"), + role: "user", + text: "Run the tests", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("new-running-tool"), + kind: "tool.updated", + tone: "tool", + summary: "Running tests", + createdAt: "2026-04-01T00:00:03.000Z", + turnId: activeTurnId, + payload: { itemType: "command_execution", status: "inProgress" }, + }), + ], + }); + const activeWorkStartedAt = "2026-04-01T00:00:02.000Z"; + expect(deriveActiveFeedTurnId(feed, latestTurn, activeWorkStartedAt)).toBe(activeTurnId); + expect( + deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), activeWorkStartedAt), + ).toMatchObject([ + { type: "message", message: { role: "user" } }, + { type: "work-toggle", live: true, shimmer: true }, + ]); + expect(deriveActiveFeedTurnId(feed, latestTurn, null)).toBeNull(); + }); + + it("folds earlier turnless reports from one completed response", () => { + const messages: OrchestrationThread["messages"] = [ + { + id: MessageId.make("prompt"), + role: "user", + text: "Audit this branch", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ...[1, 2, 3].map((index) => ({ + id: MessageId.make(`report-${index}`), + role: "assistant" as const, + text: `Audit report ${index}`, + turnId: null, + streaming: false, + createdAt: `2026-04-01T00:00:0${index}.000Z`, + updatedAt: `2026-04-01T00:00:0${index}.000Z`, + })), + ]; + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set(), + ); + expect(rows.map((row) => row.type)).toEqual(["message", "turn-fold", "message"]); + expect(rows.at(-1)?.id).toBe("report-3"); + const liveRows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set(), + new Set(), + "2026-04-01T00:00:00.000Z", + ); + expect(liveRows.filter((row) => row.type === "turn-fold")).toHaveLength(0); + const nextUser = { + id: MessageId.make("next-prompt"), + role: "user" as const, + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:10.000Z", + updatedAt: "2026-04-01T00:00:10.000Z", + }; + const nextTurnRows = deriveThreadFeedPresentation( + buildThreadFeed({ messages: [...messages, nextUser], activities: [] }), + null, + new Set(), + new Set(), + nextUser.createdAt, + ); + expect(nextTurnRows.filter((row) => row.type === "turn-fold")).toHaveLength(1); + }); + + it("folds turnless reasoning with its completed response", () => { + const messages: OrchestrationThread["messages"] = [ + { + id: MessageId.make("prompt"), + role: "user", + text: "Audit this branch", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("reasoning:raw:thought"), + role: "reasoning", + text: "Checking the files.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:01.000Z", + }, + { + id: MessageId.make("answer"), + role: "assistant", + text: "Done", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + ]; + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set(), + ); + expect(rows.map((row) => row.type)).toEqual(["message", "turn-fold", "message"]); + }); + + it("places a late-only reasoning fold before its answer", () => { + const turnId = TurnId.make("late-thought"); + const messages: OrchestrationThread["messages"] = [ + { + id: MessageId.make("answer"), + role: "assistant", + text: "Done", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + { + id: MessageId.make("reasoning:raw:late"), + role: "reasoning", + text: "One last check.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:03.000Z", + updatedAt: "2026-04-01T00:00:03.000Z", + }, + ]; + const rows = deriveThreadFeedPresentation( + buildThreadFeed({ messages, activities: [] }), + null, + new Set(), + ); + expect(rows.map((row) => row.type)).toEqual(["turn-fold", "message"]); + }); + it("shows one Thinking row while a turn works without live tool activity", () => { const turnId = TurnId.make("turn-thinking"); const latestTurn = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc..3db9243d52fb 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,17 +1,27 @@ import * as Option from "effect/Option"; +import { + isReasoningSummaryMessage, + isUnkeyedResponseTurnId, + reasoningDisplayKind, + unkeyedResponseTurnId as makeUnkeyedResponseTurnId, + unsettledTurnId, +} from "@t3tools/client-runtime/thread-message-presentation"; import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Schema from "effect/Schema"; import { requestKindFromRequestType, type PendingApproval, } from "@t3tools/client-runtime/pending-requests"; -import { UserInputAttachmentAnswerPayload, isToolLifecycleItemType } from "@t3tools/contracts"; +import { + TurnId, + UserInputAttachmentAnswerPayload, + isToolLifecycleItemType, +} from "@t3tools/contracts"; import type { OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, ToolLifecycleItemType, - TurnId, UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; @@ -28,6 +38,7 @@ import { toolGroupAction, toolGroupSummaryKind, workEntryIndicatesToolFailure, + workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, type ToolGroupSummaryKind, @@ -151,6 +162,7 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | (Extract & { readonly reasoningMessages?: OrchestrationThread["messages"]; + readonly reasoningKind?: "summary" | "raw"; }) | { readonly type: "activity-group"; @@ -1544,6 +1556,12 @@ function isEmptyMessage(entry: RawThreadFeedEntry): boolean { function groupAdjacentActivities(entries: ReadonlyArray): ThreadFeedEntry[] { const grouped: ThreadFeedEntry[] = []; + const lastAssistantIndexByTurnId = new Map(); + entries.forEach((entry, index) => { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + lastAssistantIndexByTurnId.set(entry.message.turnId, index); + } + }); let firstActivityEntry: Extract | null = null; let openGroupActivities: ThreadFeedActivity[] = []; const flushGroup = () => { @@ -1570,9 +1588,18 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th openGroupActivities = []; }; - for (const entry of entries) { - // Skip empty messages so they don't break activity grouping. - if (isEmptyMessage(entry)) { + for (const [index, entry] of entries.entries()) { + // Keep the terminal empty assistant answer visible without splitting earlier work. + if (isEmptyMessage(entry) && (entry.type !== "message" || entry.message.role !== "assistant")) { + continue; + } + if ( + entry.type === "message" && + entry.message.role === "assistant" && + isEmptyMessage(entry) && + entry.message.turnId && + lastAssistantIndexByTurnId.get(entry.message.turnId) !== index + ) { continue; } @@ -1618,11 +1645,32 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null { } export function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { - if (!latestTurn) { - return null; + return unsettledTurnId(latestTurn); +} + +export function deriveActiveFeedTurnId( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, + activeWorkStartedAt: string | null, +): TurnId | null { + const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + if (unsettledTurnId !== null || activeWorkStartedAt === null) return unsettledTurnId; + const startedAt = Date.parse(activeWorkStartedAt); + const completedAt = latestTurn?.completedAt ? Date.parse(latestTurn.completedAt) : NaN; + for (let index = feed.length - 1; index >= 0; index -= 1) { + const entry = feed[index]!; + if (entry.type === "message" && entry.message.role === "user") break; + if (Number.isFinite(startedAt) && Date.parse(entry.createdAt) < startedAt) continue; + if (Number.isFinite(completedAt) && Date.parse(entry.createdAt) <= completedAt) continue; + const turnId = + entry.type === "activity-group" + ? entry.turnId + : entry.type === "message" + ? entry.message.turnId + : null; + if (turnId !== null) return turnId; } - const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; - return settled ? null : latestTurn.turnId; + return null; } interface ThreadFeedTurnFold { @@ -1635,17 +1683,10 @@ interface ThreadFeedTurnFold { function deriveThreadFeedTurnFolds( feed: ReadonlyArray, latestTurn: ThreadFeedLatestTurn | null, + isWorking: boolean, ): ReadonlyMap { const firstAssistantMessageIdByTurn = new Map(); const terminalAssistantMessageIdByTurn = new Map(); - for (const entry of feed) { - if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { - if (!firstAssistantMessageIdByTurn.has(entry.message.turnId)) { - firstAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); - } - terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); - } - } interface TurnGroup { readonly entries: ThreadFeedEntry[]; @@ -1653,9 +1694,15 @@ function deriveThreadFeedTurnFolds( } const groupsByTurnId = new Map(); let pendingUserBoundary: string | null = null; - for (const entry of feed) { + let unkeyedResponseTurnId: TurnId | null = null; + const lastUserMessageIndex = feed.findLastIndex( + (entry) => entry.type === "message" && entry.message.role === "user", + ); + const activeVisualTurnIds = new Set(); + for (const [index, entry] of feed.entries()) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; + unkeyedResponseTurnId = makeUnkeyedResponseTurnId(entry.message.id); continue; } // Thinking is work, so it folds with the rest of it. A provider that @@ -1665,13 +1712,22 @@ function deriveThreadFeedTurnFolds( const turnId = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "reasoning") - ? entry.message.turnId + ? (entry.message.turnId ?? unkeyedResponseTurnId) : entry.type === "activity-group" ? entry.turnId : null; if (!turnId) { continue; } + if (entry.type === "message" && entry.message.role === "assistant") { + if (!firstAssistantMessageIdByTurn.has(turnId)) { + firstAssistantMessageIdByTurn.set(turnId, entry.id); + } + terminalAssistantMessageIdByTurn.set(turnId, entry.id); + } + if (isWorking && lastUserMessageIndex >= 0 && index > lastUserMessageIndex) { + activeVisualTurnIds.add(turnId); + } let group = groupsByTurnId.get(turnId); if (!group) { group = { @@ -1686,31 +1742,46 @@ function deriveThreadFeedTurnFolds( const unsettledTurnId = deriveUnsettledTurnId(latestTurn); const foldsByAnchorId = new Map(); + const activeUnkeyedResponseTurnId = isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { const { entries } = group; - if (turnId === unsettledTurnId) { + if (activeVisualTurnIds.has(turnId)) { continue; } - // A live turn is already excluded above, so only an answer still being - // written may hold a fold open. A thinking block stranded by a crashed - // provider keeps its streaming flag forever and must not. - if ( - entries.some( - (entry) => - entry.type === "message" && entry.message.streaming && entry.message.role !== "reasoning", - ) - ) { + if (isUnkeyedResponseTurnId(turnId) && turnId === activeUnkeyedResponseTurnId) { continue; } + if (turnId === unsettledTurnId) { + continue; + } + // The turn lifecycle decides whether work is live; an errored provider + // can leave even its visible answer marked as streaming. const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId); const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); + if ( + terminalAssistantMessageId === undefined && + ((latestTurn?.turnId === turnId && latestTurn.state === "error") || + entries.some( + (entry) => + entry.type === "activity-group" && + entry.activities.some( + (activity) => + activity.status === "failure" || + workEntryDisplayIndicatesToolFailure(activity.workEntry), + ), + )) + ) { + continue; + } + const unkeyedResponse = isUnkeyedResponseTurnId(turnId); const hiddenEntryIds = new Set( entries .filter( (entry) => - entry.id !== firstAssistantMessageId && + (unkeyedResponse || entry.id !== firstAssistantMessageId) && entry.id !== terminalAssistantMessageId && + !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) && !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), ) .map((entry) => entry.id), @@ -1718,17 +1789,23 @@ function deriveThreadFeedTurnFolds( if (hiddenEntryIds.size === 0) { continue; } - // A lone compaction row stays visible on its own; it only folds away as - // part of a turn that already folds other work. Thinking is the same: a - // question answered by thought alone keeps its "Thought" row - // rather than collapsing behind a "Worked for ..." that hides nothing else. + // A lone compaction row stays visible on its own. Reasoning folds when + // the turn has a final answer, even if its only other activity was a question. const hidesFoldableWork = entries.some( (entry) => hiddenEntryIds.has(entry.id) && !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) && !(entry.type === "message" && entry.message.role === "reasoning"), ); - if (!hidesFoldableWork) { + const hidesReasoningBeforeAnswer = + terminalAssistantMessageId !== undefined && + entries.some( + (entry) => + hiddenEntryIds.has(entry.id) && + entry.type === "message" && + entry.message.role === "reasoning", + ); + if (!hidesFoldableWork && !hidesReasoningBeforeAnswer) { continue; } @@ -1764,9 +1841,13 @@ function deriveThreadFeedTurnFolds( ? `Worked for ${duration}` : "Worked"; - foldsByAnchorId.set(firstHiddenEntry.id, { + const anchorEntry = + terminalEntry && entries.indexOf(firstHiddenEntry) > entries.indexOf(terminalEntry) + ? terminalEntry + : firstHiddenEntry; + foldsByAnchorId.set(anchorEntry.id, { turnId, - createdAt: firstHiddenEntry.createdAt, + createdAt: anchorEntry.createdAt, hiddenEntryIds, label, }); @@ -1780,6 +1861,7 @@ export function deriveThreadFeedPresentation( expandedTurnIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), activeWorkStartedAt: string | null = null, + activeFeedTurnId?: TurnId | null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( (entry) => @@ -1791,9 +1873,22 @@ export function deriveThreadFeedPresentation( const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), ); - const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); - const unsettledTurnId = deriveUnsettledTurnId(latestTurn); const isWorking = activeWorkStartedAt !== null; + const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn, isWorking); + const unsettledTurnId = + activeFeedTurnId === undefined + ? deriveActiveFeedTurnId(sourceFeed, latestTurn, activeWorkStartedAt) + : activeFeedTurnId; + const turnsWithSummary = new Set( + sourceFeed.flatMap((entry) => + entry.type === "message" && + entry.message.role === "reasoning" && + entry.message.turnId && + isReasoningSummaryMessage(entry.message) + ? [entry.message.turnId] + : [], + ), + ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedTurnIds.has(fold.turnId)) { @@ -1857,11 +1952,16 @@ export function deriveThreadFeedPresentation( unsettledTurnId, isWorking, run.at(-1) === activeTailGroup, + turnsWithSummary.has(runTurnId), ); index = end - 1; continue; } } + if (entry.type === "message" && entry.message.role === "reasoning" && runTurnId === null) { + result.push(...groupConsecutiveReasoningMessages([entry], false)); + continue; + } appendPresentedFeedEntry( result, entry, @@ -1919,6 +2019,7 @@ function appendMixedActivityRun( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + hasSummary: boolean, ) { const first = run[0]!; const last = run.at(-1)!; @@ -1930,7 +2031,7 @@ function appendMixedActivityRun( : undefined; const groupId = firstTool ? toolActivityGroupId(firstTool) : `activity-run:${first.id}`; const expanded = expandedWorkGroupIds.has(groupId); - const state = `${isWorking}:${unsettledTurnId}:${activeTail}:${expanded}`; + const state = `${isWorking}:${unsettledTurnId}:${activeTail}:${expanded}:${hasSummary}`; const cached = activityRunsCache.get(first); if ( cached?.state === state && @@ -1941,7 +2042,7 @@ function appendMixedActivityRun( return; } const outputStart = result.length; - const history = groupConsecutiveReasoningMessages(run).map((entry) => + const history = groupConsecutiveReasoningMessages(run, hasSummary).map((entry) => entry.type === "activity-group" ? { ...entry, @@ -1972,8 +2073,23 @@ function appendMixedActivityRun( } const toolSummary = toolRows.find((entry) => entry.type === "work-toggle"); const thinking = live && (last.type === "message" || !toolSummary?.shimmer); - const thoughtCount = run.filter((entry) => entry.type === "message").length; - result.push({ + const isInlineSummary = (entry: ThreadFeedEntry) => + entry.type === "message" && + entry.message.role === "reasoning" && + entry.reasoningKind === "summary"; + const thoughtCount = history.reduce( + (count, entry) => + entry.type === "message" && !isInlineSummary(entry) + ? count + (entry.reasoningMessages?.length ?? 1) + : count, + 0, + ); + if (activities.length + thoughtCount === 0) { + result.push(...history); + activityRunsCache.set(first, { source: run, state, rows: result.slice(outputStart) }); + return; + } + const toggle: ThreadFeedEntry = { type: "work-toggle", id: live ? LIVE_ACTIVITY_ROW_ID : `work-toggle:${groupId}`, createdAt: first.createdAt, @@ -1995,28 +2111,42 @@ function appendMixedActivityRun( hasFailure: toolSummary?.hasFailure ?? false, live, shimmer: live, - }); - if (expanded) result.push(...history); + }; + let showedToggle = false; + for (const entry of history) { + if (!isInlineSummary(entry) && !showedToggle) { + result.push(toggle); + showedToggle = true; + } + if (expanded || isInlineSummary(entry)) { + result.push(entry); + } + } activityRunsCache.set(first, { source: run, state, rows: result.slice(outputStart) }); } function groupConsecutiveReasoningMessages( feed: ReadonlyArray, + hasSummary: boolean, ): ThreadFeedEntry[] { const result: ThreadFeedEntry[] = []; + const kindOf = (message: OrchestrationThread["messages"][number]) => + reasoningDisplayKind(message, hasSummary); for (let index = 0; index < feed.length; index += 1) { const entry = feed[index]!; - if (entry.type !== "message" || entry.message.role !== "reasoning" || !entry.message.turnId) { + if (entry.type !== "message" || entry.message.role !== "reasoning") { result.push(entry); continue; } + const reasoningKind = kindOf(entry.message); const messages = [entry.message]; while (index + 1 < feed.length) { const next = feed[index + 1]!; if ( next.type !== "message" || next.message.role !== "reasoning" || - next.message.turnId !== entry.message.turnId + next.message.turnId !== entry.message.turnId || + kindOf(next.message) !== reasoningKind ) { break; } @@ -2024,18 +2154,19 @@ function groupConsecutiveReasoningMessages( index += 1; } if (messages.length === 1) { - result.push(entry); + result.push(entry.reasoningKind === reasoningKind ? entry : { ...entry, reasoningKind }); continue; } let group = reasoningGroupsCache.get(entry); if ( !group || group.reasoningMessages?.length !== messages.length || + group.reasoningKind !== reasoningKind || !messages.every( (message, messageIndex) => group?.reasoningMessages?.[messageIndex] === message, ) ) { - group = { ...entry, reasoningMessages: messages }; + group = { ...entry, reasoningMessages: messages, reasoningKind }; reasoningGroupsCache.set(entry, group); } result.push(group); diff --git a/apps/server/integration/TestProviderAdapter.integration.test.ts b/apps/server/integration/TestProviderAdapter.integration.test.ts new file mode 100644 index 000000000000..7c4acb407cf7 --- /dev/null +++ b/apps/server/integration/TestProviderAdapter.integration.test.ts @@ -0,0 +1,44 @@ +import { EventId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { makeTestProviderAdapterHarness } from "./TestProviderAdapter.integration.ts"; + +it.effect("keeps progress deltas out of the synthetic assistant answer", () => + Effect.gen(function* () { + const harness = yield* makeTestProviderAdapterHarness(); + const threadId = ThreadId.make("thread-progress-fixture"); + yield* harness.adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + cwd: "/repo", + }); + const eventBase = { + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: String(threadId), + type: "content.delta", + } as const; + yield* harness.queueTurnResponse(threadId, { + events: [ + { + ...eventBase, + eventId: EventId.make("progress-delta"), + payload: { streamKind: "assistant_progress_text", delta: "Thinking aloud. " }, + }, + { + ...eventBase, + eventId: EventId.make("answer-delta"), + payload: { streamKind: "assistant_text", delta: "Final answer." }, + }, + ], + }); + yield* harness.adapter.sendTurn({ threadId, input: "Prompt", attachments: [] }); + const snapshot = yield* harness.adapter.readThread(threadId); + assert.deepStrictEqual(snapshot.turns[0]?.items, [ + { type: "userMessage", content: [{ type: "text", text: "Prompt" }] }, + { type: "agentMessage", text: "Final answer." }, + ]); + }), +); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 095cca4e5e74..816c99e9ef6b 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -325,8 +325,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter const runtimeEvent = normalizeFixtureEvent(rawEvent); const runtimeType = (runtimeEvent as { type: string }).type; if (runtimeType === "content.delta") { - const payload = runtimeEvent.payload as { delta?: unknown } | undefined; - if (typeof payload?.delta === "string") { + const payload = runtimeEvent.payload as + | { delta?: unknown; streamKind?: unknown } + | undefined; + if (payload?.streamKind === "assistant_text" && typeof payload.delta === "string") { assistantDeltas.push(payload.delta); } } else if (runtimeType === "message.delta") { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 179d04843c7e..5fdc61406ba2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -66,6 +66,91 @@ const encodeThreadLinkedPullRequest = Schema.encodeSync( Schema.fromJsonString(ThreadLinkedPullRequest), ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-reclassified-anchor-")))( + "reasoning message projection", + (it) => { + it.effect("clears the assistant anchor when progress becomes reasoning", () => + Effect.gen(function* () { + const pipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-reclassified-anchor"); + const turnId = TurnId.make("turn-reclassified-anchor"); + const messageId = MessageId.make("assistant:reclassified-anchor"); + const at = "2026-03-01T08:00:00.000Z"; + const append = (event: Parameters[0]) => + eventStore.append(event).pipe(Effect.flatMap(pipeline.projectEvent)); + const fields = (id: string) => ({ + eventId: EventId.make(`evt-${id}`), + occurredAt: at, + commandId: CommandId.make(`cmd-${id}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-${id}`), + metadata: {}, + }); + + yield* append({ + ...fields("anchor-project"), + type: "project.created", + aggregateKind: "project", + aggregateId: ProjectId.make("project-reclassified-anchor"), + payload: { + projectId: ProjectId.make("project-reclassified-anchor"), + title: "Anchor", + workspaceRoot: "/tmp/reclassified-anchor", + defaultModelSelection: null, + scripts: [], + createdAt: at, + updatedAt: at, + }, + }); + yield* append({ + ...fields("anchor-thread"), + type: "thread.created", + aggregateKind: "thread", + aggregateId: threadId, + payload: { + threadId, + projectId: ProjectId.make("project-reclassified-anchor"), + title: "Anchor", + modelSelection: { instanceId: ProviderInstanceId.make("opencode"), model: "mimo" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: at, + updatedAt: at, + }, + }); + const sendMessage = (role: "assistant" | "reasoning", id: string) => + append({ + ...fields(id), + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: threadId, + payload: { + threadId, + messageId, + role, + text: role === "assistant" ? "Checking the audit." : "", + turnId, + streaming: role === "assistant", + createdAt: at, + updatedAt: at, + }, + }); + yield* sendMessage("assistant", "anchor-assistant"); + const anchor = sql<{ readonly assistantMessageId: string | null }>` + SELECT assistant_message_id AS "assistantMessageId" + FROM projection_turns WHERE turn_id = ${turnId} + `; + assert.deepEqual(yield* anchor, [{ assistantMessageId: messageId }]); + yield* sendMessage("reasoning", "anchor-reasoning"); + assert.deepEqual(yield* anchor, [{ assistantMessageId: null }]); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))( "OrchestrationProjectionPipeline cursor batches", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b5e6cb0cdd54..e83ca444928f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1566,7 +1566,32 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.message-sent": { - if (event.payload.turnId === null || event.payload.role !== "assistant") { + if (event.payload.turnId === null) { + return; + } + if (event.payload.role === "reasoning") { + const existingTurn = yield* projectionTurnRepository.getByTurnId({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + }); + if ( + Option.isSome(existingTurn) && + existingTurn.value.assistantMessageId === event.payload.messageId + ) { + const replacement = + yield* projectionThreadMessageRepository.getLatestAssistantMessageIdForTurn({ + threadId: event.payload.threadId, + turnId: event.payload.turnId, + excludeMessageId: event.payload.messageId, + }); + yield* projectionTurnRepository.upsertByTurnId({ + ...existingTurn.value, + assistantMessageId: Option.getOrNull(replacement), + }); + } + return; + } + if (event.payload.role !== "assistant") { return; } // A completed assistant message only settles the turn once the diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d61739f72c21..2f984c444e6d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1439,6 +1439,209 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("keeps a completed OpenCode turn with reasoning but no answer visible", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-empty-answer"); + const now = "2026-01-01T00:00:00.000Z"; + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-empty-reasoning"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId: asItemId("reasoning-part"), + payload: { streamKind: "reasoning_summary_text", delta: "Checked the branch." }, + }); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-empty-turn-complete"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + status: "completed", + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.role === "reasoning" && + message.text === "Checked the branch." && + !message.streaming, + ), + ); + expect( + thread.messages.find((message: ProviderRuntimeTestMessage) => message.role === "reasoning") + ?.text, + ).toBe("Checked the branch."); + expect( + thread.messages.some((message: ProviderRuntimeTestMessage) => message.role === "assistant"), + ).toBe(false); + }); + + it("reclassifies completed OpenCode progress text without changing the final answer", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-progress"); + const now = "2026-01-01T00:00:00.000Z"; + const emitText = (item: string, text: string, event: string) => + harness.emit({ + type: "content.delta", + eventId: asEventId(event), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId: asItemId(item), + payload: { streamKind: "assistant_text", delta: text }, + }); + const complete = (item: string, event: string, presentation?: "progress", detail?: string) => + harness.emit({ + type: "item.completed", + eventId: asEventId(event), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId: asItemId(item), + payload: { + itemType: "assistant_message", + status: "completed", + ...(presentation ? { presentation } : {}), + ...(detail ? { detail } : {}), + }, + }); + + emitText("preface-part", "Starting the review.", "evt-preface-text"); + emitText("progress-part", "Checking the reviews.", "evt-progress-text"); + complete("progress-part", "evt-progress-complete", undefined, "Checking the reviews."); + emitText("answer-part", "The review ", "evt-answer-text-first"); + complete("progress-part", "evt-progress-classified", "progress"); + emitText("answer-part", "is complete.", "evt-answer-text-second"); + complete("answer-part", "evt-answer-complete"); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:progress-part" && message.role === "reasoning", + ) && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:answer-part" && message.role === "assistant", + ), + ); + expect( + thread.messages + .filter((message: ProviderRuntimeTestMessage) => + ["assistant:preface-part", "assistant:progress-part", "assistant:answer-part"].includes( + message.id, + ), + ) + .map((message: ProviderRuntimeTestMessage) => [message.id, message.role, message.text]) + .sort(), + ).toEqual([ + ["assistant:answer-part", "assistant", "The review is complete."], + ["assistant:preface-part", "assistant", "Starting the review."], + ["assistant:progress-part", "reasoning", "Checking the reviews."], + ]); + }); + + it("keeps a classified OpenCode progress part out of the following answer segment", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-progress-stream"); + const now = "2026-01-01T00:00:00.000Z"; + const emitText = ( + item: string, + text: string, + event: string, + streamKind: "assistant_text" | "assistant_progress_text", + ) => + harness.emit({ + type: "content.delta", + eventId: asEventId(event), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId: asItemId(item), + payload: { streamKind, delta: text }, + }); + const complete = (item: string, event: string, presentation?: "progress") => + harness.emit({ + type: "item.completed", + eventId: asEventId(event), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + itemId: asItemId(item), + payload: { + itemType: "assistant_message", + status: "completed", + ...(presentation ? { presentation } : {}), + }, + }); + + emitText("progress-part", "Checking ", "evt-progress-first", "assistant_text"); + complete("progress-part", "evt-progress-classified", "progress"); + emitText("progress-part", "the ", "evt-progress-second", "assistant_progress_text"); + emitText("progress-part", "reviews.", "evt-progress-third", "assistant_progress_text"); + emitText("answer-part", "Review complete.", "evt-answer", "assistant_text"); + complete("answer-part", "evt-answer-complete"); + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-complete"), + provider: ProviderDriverKind.make("opencode"), + createdAt: now, + threadId, + turnId, + status: "completed", + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:answer-part" && !message.streaming, + ) && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:progress-part" && + message.text === "Checking the reviews." && + !message.streaming, + ), + ); + expect( + thread.messages + .map((message: ProviderRuntimeTestMessage) => [message.id, message.role, message.text]) + .sort(), + ).toEqual([ + ["assistant:answer-part", "assistant", "Review complete."], + ["assistant:progress-part", "reasoning", "Checking the reviews."], + ]); + + emitText("progress-part", " Later note.", "evt-progress-late", "assistant_progress_text"); + const withLateProgress = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:progress-part" && + message.text === "Checking the reviews. Later note." && + !message.streaming, + ), + ); + expect( + withLateProgress.messages.find( + (message: ProviderRuntimeTestMessage) => message.id === "assistant:progress-part", + )?.role, + ).toBe("reasoning"); + }); + it("streams reasoning deltas into a finalized reasoning message", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0db70e491235..88937cabdc36 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1051,6 +1051,11 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(0), }); + const progressMessageIds = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(false), + }); // When a thinking block opened, so "Thought for ..." measures the model's // time and not the moment buffered text happened to be flushed. @@ -1243,22 +1248,38 @@ const make = Effect.gen(function* () { turnId?: TurnId; }) => Effect.gen(function* () { + const baseKey = assistantSegmentBaseKeyFromEvent(input.event); if (!input.turnId) { - return assistantSegmentMessageId(assistantSegmentBaseKeyFromEvent(input.event), 0); + return assistantSegmentMessageId(baseKey, 0); } - const activeMessageId = yield* getActiveAssistantMessageIdForTurn( - input.threadId, - input.turnId, - ); - if (Option.isSome(activeMessageId)) { - return activeMessageId.value; + const state = yield* getAssistantSegmentStateForTurn(input.threadId, input.turnId); + const activeMessageId = Option.getOrUndefined(state)?.activeMessageId; + if (activeMessageId) { + // OpenCode can classify an already streamed text part as tool-call + // progress when its parent message finishes. Keep each part separate + // so that reclassification cannot move text from an earlier answer. + if ( + input.event.provider !== "opencode" || + Option.getOrUndefined(state)?.baseKey === baseKey + ) { + return activeMessageId; + } + yield* finalizeActiveSegmentForTurn({ + event: input.event, + threadId: input.threadId, + turnId: input.turnId, + createdAt: input.event.createdAt, + commandTag: "assistant-complete-on-new-part", + finalDeltaCommandTag: "assistant-delta-finalize-on-new-part", + hasProjectedMessage: false, + }); } return yield* startAssistantSegmentForTurn({ threadId: input.threadId, turnId: input.turnId, - baseKey: assistantSegmentBaseKeyFromEvent(input.event), + baseKey, }); }); @@ -1399,6 +1420,7 @@ const make = Effect.gen(function* () { const clearAssistantMessageState = (messageId: MessageId) => clearBufferedAssistantText(messageId).pipe( + Effect.andThen(Cache.invalidate(progressMessageIds, messageId)), Effect.andThen(Cache.invalidate(reasoningPartIndexByMessageId, messageId)), Effect.andThen(Cache.invalidate(reasoningStartedAtByMessageId, messageId)), ); @@ -1422,7 +1444,9 @@ const make = Effect.gen(function* () { return false; } - const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning"; + const isReasoning = + messageStreamRoleOf(input.messageId) === "reasoning" || + Option.isSome(yield* Cache.getOption(progressMessageIds, input.messageId)); yield* orchestrationEngine.dispatch({ type: isReasoning ? "thread.message.reasoning.delta" : "thread.message.assistant.delta", commandId: yield* providerCommandId(input.event, input.commandTag), @@ -1480,6 +1504,7 @@ const make = Effect.gen(function* () { finalDeltaCommandTag: string; fallbackText?: string; hasProjectedMessage?: boolean; + presentation?: "progress"; }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); @@ -1491,7 +1516,10 @@ const make = Effect.gen(function* () { : ""; const hasRenderableText = hasRenderableAssistantText(text); - const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning"; + const isReasoning = + input.presentation === "progress" || + messageStreamRoleOf(input.messageId) === "reasoning" || + Option.isSome(yield* Cache.getOption(progressMessageIds, input.messageId)); if (hasRenderableText) { yield* orchestrationEngine.dispatch({ @@ -1760,6 +1788,7 @@ const make = Effect.gen(function* () { if ( event.type === "content.delta" && event.payload.streamKind !== "assistant_text" && + event.payload.streamKind !== "assistant_progress_text" && event.payload.streamKind !== "reasoning_text" && event.payload.streamKind !== "reasoning_summary_text" ) { @@ -1938,6 +1967,10 @@ const make = Effect.gen(function* () { event.type === "content.delta" && event.payload.streamKind === "assistant_text" ? event.payload.delta : undefined; + const assistantProgressDelta = + event.type === "content.delta" && event.payload.streamKind === "assistant_progress_text" + ? event.payload.delta + : undefined; const reasoningDelta = event.type === "content.delta" && (event.payload.streamKind === "reasoning_text" || @@ -2073,6 +2106,61 @@ const make = Effect.gen(function* () { } } + if (assistantProgressDelta && assistantProgressDelta.length > 0) { + const turnId = toTurnId(event.turnId); + const messageId = MessageId.make( + `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, + ); + if (turnId) { + yield* rememberAssistantMessageId(thread.id, turnId, messageId); + } + yield* Cache.set(progressMessageIds, messageId, true); + const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); + const progressMode = streamingMode === "token" ? "paragraph" : streamingMode; + const spillChunk = yield* appendBufferedAssistantText( + messageId, + assistantProgressDelta, + progressMode, + yield* Clock.currentTimeMillis, + ); + if (spillChunk.length > 0) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.reasoning.delta", + commandId: yield* providerCommandId(event, "assistant-progress-delta-buffer-spill"), + threadId: thread.id, + messageId, + delta: spillChunk, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + } + // OpenCode can append text after classifying a completed part as + // progress. Finish a live part at item or turn completion instead of + // persisting a completion for every token. Late deltas from a settled + // turn still need immediate completion. + const progressTurn = + turnId && (activeTurnId === null || !sameId(activeTurnId, turnId)) + ? yield* projectionTurnRepository.getByTurnId({ threadId: thread.id, turnId }) + : Option.none(); + const isSettledProgressTurn = + Option.isSome(progressTurn) && + progressTurn.value.state !== "pending" && + progressTurn.value.state !== "running"; + if (!turnId || isSettledProgressTurn) { + yield* finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId, + ...(turnId ? { turnId } : {}), + createdAt: now, + commandTag: "assistant-progress-complete", + finalDeltaCommandTag: "assistant-progress-delta-finalize", + hasProjectedMessage: (yield* getThreadMessageById(thread.id, messageId)) !== undefined, + presentation: "progress", + }); + } + } + const pauseForUserTurnId = event.type === "request.opened" || (event.type === "user-input.requested" && event.payload.responseMode !== "message") @@ -2232,6 +2320,7 @@ const make = Effect.gen(function* () { `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ), fallbackText: event.payload.detail, + presentation: event.payload.presentation, } : undefined; const proposedPlanCompletion = @@ -2260,10 +2349,15 @@ const make = Effect.gen(function* () { const activeAssistantMessageId = turnId ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) : Option.none(); - const assistantMessageId = Option.getOrElse( - activeAssistantMessageId, - () => assistantCompletion.messageId, - ); + const activeMessageMatchesItem = + Option.isSome(activeAssistantMessageId) && + (activeAssistantMessageId.value === assistantCompletion.messageId || + activeAssistantMessageId.value.startsWith(`${assistantCompletion.messageId}:segment:`)); + const assistantMessageId = + assistantCompletion.presentation === "progress" || + (event.provider === "opencode" && !activeMessageMatchesItem) + ? assistantCompletion.messageId + : Option.getOrElse(activeAssistantMessageId, () => assistantCompletion.messageId); const [existingAssistantMessage, hasAssistantMessagesForTurn] = yield* Effect.all([ getThreadMessageById(thread.id, assistantMessageId), turnId === undefined @@ -2278,6 +2372,7 @@ const make = Effect.gen(function* () { !existingAssistantMessage || existingAssistantMessage.text.length === 0; const shouldSkipRedundantCompletion = + assistantCompletion.presentation !== "progress" && Option.isNone(activeAssistantMessageId) && turnId !== undefined && hasAssistantMessagesForTurn && @@ -2293,10 +2388,16 @@ const make = Effect.gen(function* () { threadId: thread.id, messageId: assistantMessageId, ...(turnId ? { turnId } : {}), - createdAt: now, + createdAt: + assistantCompletion.presentation === "progress" + ? (existingAssistantMessage?.createdAt ?? now) + : now, commandTag: "assistant-complete", finalDeltaCommandTag: "assistant-delta-finalize", hasProjectedMessage: existingAssistantMessage !== undefined, + ...(assistantCompletion.presentation !== undefined + ? { presentation: assistantCompletion.presentation } + : {}), ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText ? { fallbackText: assistantCompletion.fallbackText } : {}), @@ -2307,7 +2408,12 @@ const make = Effect.gen(function* () { } } - if (turnId) { + if ( + turnId && + (assistantCompletion.presentation !== "progress" || + (Option.isSome(activeAssistantMessageId) && + activeAssistantMessageId.value === assistantMessageId)) + ) { yield* clearAssistantSegmentStateForTurn(thread.id, turnId); } } @@ -2382,6 +2488,9 @@ const make = Effect.gen(function* () { commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", hasProjectedMessage: existingMessage !== undefined, + ...(existingMessage?.role === "reasoning" + ? { presentation: "progress" as const } + : {}), }), ), ), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..ce0122e20a94 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,9 +1,11 @@ import { CommandId, EventId, + MessageId, ProjectId, ProviderDriverKind, ThreadId, + TurnId, type OrchestrationEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -671,9 +673,23 @@ describe("orchestration projector", () => { ), ); + const withAssistantAnchor = { + ...afterDelta, + threads: afterDelta.threads.map((thread) => ({ + ...thread, + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "running" as const, + requestedAt: deltaAt, + startedAt: deltaAt, + completedAt: null, + assistantMessageId: MessageId.make("assistant:msg-1"), + }, + })), + }; const afterComplete = await Effect.runPromise( projectEvent( - afterDelta, + withAssistantAnchor, makeEvent({ sequence: 3, type: "thread.message-sent", @@ -684,7 +700,7 @@ describe("orchestration projector", () => { payload: { threadId: "thread-1", messageId: "assistant:msg-1", - role: "assistant", + role: "reasoning", text: "", turnId: "turn-1", streaming: false, @@ -697,9 +713,11 @@ describe("orchestration projector", () => { const message = afterComplete.threads[0]?.messages[0]; expect(message?.id).toBe("assistant:msg-1"); + expect(message?.role).toBe("reasoning"); expect(message?.text).toBe("hello"); expect(message?.streaming).toBe(false); expect(message?.updatedAt).toBe(completeAt); + expect(afterComplete.threads[0]?.latestTurn?.assistantMessageId).toBeNull(); }); it("prunes reverted turn messages from in-memory thread snapshot", async () => { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..4ad470dc66c0 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -782,6 +782,7 @@ export function projectEvent( entry.id === message.id ? { ...entry, + role: message.role, text: message.streaming ? `${entry.text}${message.text}` : message.text.length > 0 @@ -799,11 +800,32 @@ export function projectEvent( ) : [...thread.messages, message]; const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + const reclassifiedAssistant = + existingMessage?.role === "assistant" && message.role === "reasoning"; + const replacementAssistantMessageId = + reclassifiedAssistant && message.turnId !== null + ? (cappedMessages.findLast( + (entry) => entry.role === "assistant" && entry.turnId === message.turnId, + )?.id ?? null) + : null; + const latestTurn = + reclassifiedAssistant && thread.latestTurn?.assistantMessageId === message.id + ? { ...thread.latestTurn, assistantMessageId: replacementAssistantMessageId } + : thread.latestTurn; + const checkpoints = reclassifiedAssistant + ? thread.checkpoints.map((entry) => + entry.assistantMessageId === message.id + ? { ...entry, assistantMessageId: replacementAssistantMessageId } + : entry, + ) + : thread.checkpoints; return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, + latestTurn, + checkpoints, updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 87a15b95e413..c6a8ccafc84f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -2,6 +2,7 @@ import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; @@ -12,6 +13,44 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("finds the latest other assistant message in one turn", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-reclassified-message"); + const turnId = TurnId.make("turn-reclassified-message"); + for (const [index, role, messageTurnId] of [ + [0, "assistant", turnId], + [1, "assistant", TurnId.make("other-turn")], + [2, "reasoning", turnId], + [3, "assistant", turnId], + ] as const) { + yield* repository.upsert({ + messageId: MessageId.make(`reclassified-message-${index}`), + threadId, + turnId: messageTurnId, + role, + text: "Message body", + isStreaming: false, + createdAt: `2026-02-28T19:05:0${index}.000Z`, + updatedAt: `2026-02-28T19:05:0${index}.000Z`, + }); + } + + const replacement = yield* repository.getLatestAssistantMessageIdForTurn({ + threadId, + turnId, + excludeMessageId: MessageId.make("reclassified-message-3"), + }); + assert.strictEqual(Option.getOrNull(replacement), MessageId.make("reclassified-message-0")); + const none = yield* repository.getLatestAssistantMessageIdForTurn({ + threadId, + turnId: TurnId.make("other-turn"), + excludeMessageId: MessageId.make("reclassified-message-1"), + }); + assert.isTrue(Option.isNone(none)); + }), + ); + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 28aeb6d794e9..044b5ba37f31 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -11,6 +11,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; import { AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, + GetLatestAssistantMessageIdForTurnInput, HasProjectionThreadAssistantMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, @@ -204,6 +205,21 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const getLatestAssistantMessageIdForTurnRow = SqlSchema.findOneOption({ + Request: GetLatestAssistantMessageIdForTurnInput, + Result: Schema.Struct({ messageId: ProjectionThreadMessage.fields.messageId }), + execute: ({ threadId, turnId, excludeMessageId }) => sql` + SELECT message_id AS "messageId" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + AND role = 'assistant' + AND message_id != ${excludeMessageId} + ORDER BY created_at DESC, message_id DESC + LIMIT 1 + `, + }); + const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, @@ -279,6 +295,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((row) => row.exists === 1), ); + const getLatestAssistantMessageIdForTurn: ProjectionThreadMessageRepositoryShape["getLatestAssistantMessageIdForTurn"] = + (input) => + getLatestAssistantMessageIdForTurnRow(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.getLatestAssistantMessageIdForTurn:query", + ), + ), + Effect.map(Option.map((row) => row.messageId)), + ); + const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => listProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -309,6 +336,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { appendStreaming, getByMessageId, hasAssistantMessageForTurn, + getLatestAssistantMessageIdForTurn, listByThreadId, getLatestUserMessageAt, deleteByThreadId, diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index e3e5b6e5151d..f792bf9a0aa7 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -61,6 +61,14 @@ export const HasProjectionThreadAssistantMessageInput = Schema.Struct({ export type HasProjectionThreadAssistantMessageInput = typeof HasProjectionThreadAssistantMessageInput.Type; +export const GetLatestAssistantMessageIdForTurnInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + excludeMessageId: MessageId, +}); +export type GetLatestAssistantMessageIdForTurnInput = + typeof GetLatestAssistantMessageIdForTurnInput.Type; + export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -98,6 +106,11 @@ export interface ProjectionThreadMessageRepositoryShape { input: HasProjectionThreadAssistantMessageInput, ) => Effect.Effect; + /** Find the last assistant message in a turn without loading message bodies. */ + readonly getLatestAssistantMessageIdForTurn: ( + input: GetLatestAssistantMessageIdForTurnInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * List projected thread messages for a thread. * diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 601917d35864..3bfa1f1e737f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -61,6 +61,8 @@ type MessageEntry = { info: { id: string; role: "user" | "assistant"; + parentID?: string; + finish?: string; }; parts: Array; }; @@ -101,6 +103,7 @@ const runtimeMock = { promptEchoEvents: [] as Array, closeError: null as Error | null, messages: [] as MessageEntry[], + sessionMessagesCalls: 0, forkMessagesBySession: new Map(), forkPreservesBoundary: true, subscribedEvents: [] as Array>, @@ -165,6 +168,7 @@ const runtimeMock = { this.state.promptEchoEvents.length = 0; this.state.closeError = null; this.state.messages = []; + this.state.sessionMessagesCalls = 0; this.state.forkMessagesBySession.clear(); this.state.forkPreservesBoundary = true; this.state.subscribedEvents = []; @@ -405,10 +409,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.summarizeCalls.push(input); return { data: true }; }, - messages: async ({ sessionID }: { sessionID: string }) => ({ - data: - runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages, - }), + messages: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionMessagesCalls += 1; + return { + data: + runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages, + }; + }, message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { runtimeMock.state.messageCalls.push({ sessionID, messageID }); if (runtimeMock.state.messageFailures > 0) { @@ -2143,10 +2150,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const threadId = asThreadId("thread-steer-reconnect-before-acceptance"); const firstUserMessageEvent = promiseWithResolvers(); const reconnectEvent = promiseWithResolvers(); + const assistantEvent = promiseWithResolvers(); const steerStarted = promiseWithResolvers(); const steerRelease = promiseWithResolvers(); runtimeMock.state.autoPromptEcho = false; - runtimeMock.state.subscribedEvents = [firstUserMessageEvent.promise, reconnectEvent.promise]; + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + reconnectEvent.promise, + assistantEvent.promise, + ]; runtimeMock.state.promptAsyncImplementation = async () => { if (runtimeMock.state.promptCalls.length === 2) { steerStarted.resolve(undefined); @@ -2211,6 +2223,19 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { steerRelease.resolve(undefined); yield* Fiber.join(steerFiber); yield* advanceTestClock(250); + assistantEvent.resolve({ + id: "evt-assistant-after-reconnect-steer", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-assistant-after-reconnect-steer", + role: "assistant", + parentID: steerMessageId, + finish: "stop", + }, + }, + }); const completed = Option.getOrUndefined( yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), @@ -2976,13 +3001,18 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const threadId = asThreadId("thread-stale-admission-status-after-stop"); const idleEvent = promiseWithResolvers(); const userMessageEvent = promiseWithResolvers(); + const assistantEvent = promiseWithResolvers(); const staleStatusStarted = promiseWithResolvers(); const staleStatusRelease = promiseWithResolvers(); const staleStatusReturned = promiseWithResolvers(); const activePromptStarted = promiseWithResolvers(); const activePromptRelease = promiseWithResolvers(); runtimeMock.state.autoPromptEcho = false; - runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.subscribedEvents = [ + idleEvent.promise, + userMessageEvent.promise, + assistantEvent.promise, + ]; runtimeMock.state.sessionStatusImplementation = async () => { if (runtimeMock.state.sessionStatusCalls === 1) { staleStatusStarted.resolve(undefined); @@ -3073,6 +3103,19 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { activePromptRelease.resolve(undefined); const activeTurn = yield* Fiber.join(activeTurnFiber); yield* advanceTestClock(250); + assistantEvent.resolve({ + id: "evt-assistant-after-stale-status", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-assistant-after-stale-status", + role: "assistant", + parentID: activeMessageId, + finish: "stop", + }, + }, + }); const completed = Option.getOrUndefined( yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), @@ -3269,14 +3312,19 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("reconciles a sole idle when the matching prompt echo arrives later", () => + it.effect("reconciles a length-limited reply when the matching prompt echo arrives later", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-idle-before-delayed-echo"); const idleEvent = promiseWithResolvers(); const userMessageEvent = promiseWithResolvers(); + const assistantEvent = promiseWithResolvers(); runtimeMock.state.autoPromptEcho = false; - runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.subscribedEvents = [ + idleEvent.promise, + userMessageEvent.promise, + assistantEvent.promise, + ]; runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); const completedFiber = yield* adapter.streamEvents.pipe( @@ -3316,6 +3364,25 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); yield* advanceTestClock(250); + const waiting = (yield* adapter.listSessions()).find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(waiting?.status, "running"); + assistantEvent.resolve({ + id: "evt-delayed-assistant-response", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-delayed-assistant", + role: "assistant", + parentID: messageId, + finish: "length", + }, + }, + }); + yield* advanceTestClock(2_000); + const completed = Option.getOrUndefined( yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), ); @@ -3389,15 +3456,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); const sessions = yield* adapter.listSessions(); const session = sessions.find((candidate) => candidate.threadId === threadId); - NodeAssert.equal(session?.status, "ready"); - NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, activeTurn.turnId); NodeAssert.notEqual(activeTurn.turnId, stoppedTurn.turnId); yield* adapter.stopSession(threadId); }), ); - it.effect("reconciles a sole idle after a stop when the exact prompt echo arrives", () => + it.effect("keeps the turn running when an idle precedes the exact prompt echo", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-idle-before-exact-echo-after-stop"); @@ -3406,12 +3473,6 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { runtimeMock.state.autoPromptEcho = false; runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); - - const completedFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), - Stream.runHead, - Effect.forkChild, - ); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId, @@ -3463,14 +3524,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); yield* advanceTestClock(250); - const completed = Option.getOrUndefined( - yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + const waiting = (yield* adapter.listSessions()).find( + (candidate) => candidate.threadId === threadId, ); - NodeAssert.equal(completed?.turnId, activeTurn.turnId); - const sessions = yield* adapter.listSessions(); - const session = sessions.find((candidate) => candidate.threadId === threadId); - NodeAssert.equal(session?.status, "ready"); - NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.equal(waiting?.status, "running"); + NodeAssert.equal(waiting?.activeTurnId, activeTurn.turnId); yield* adapter.stopSession(threadId); }), @@ -3482,10 +3540,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const threadId = asThreadId("thread-idle-and-echo-before-acceptance-after-stop"); const idleEvent = promiseWithResolvers(); const userMessageEvent = promiseWithResolvers(); + const assistantEvent = promiseWithResolvers(); const activePromptStarted = promiseWithResolvers(); const activePromptRelease = promiseWithResolvers(); runtimeMock.state.autoPromptEcho = false; - runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.subscribedEvents = [ + idleEvent.promise, + userMessageEvent.promise, + assistantEvent.promise, + ]; runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); runtimeMock.state.promptAsyncImplementation = async () => { if (runtimeMock.state.promptCalls.length === 2) { @@ -3551,6 +3614,25 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const activeTurn = yield* Fiber.join(activeTurnFiber); yield* advanceTestClock(250); + const waiting = (yield* adapter.listSessions()).find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(waiting?.activeTurnId, activeTurn.turnId); + assistantEvent.resolve({ + id: "evt-assistant-after-held-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-after-held-acceptance", + role: "assistant", + parentID: activeMessageId, + finish: "stop", + }, + }, + }); + yield* advanceTestClock(2_000); + const completed = Option.getOrUndefined( yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), ); @@ -6973,6 +7055,108 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "marks tool-call continuation text as progress while keeping length and stop output visible", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-progress-text"); + const sessionID = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = (["tool-calls", "length", "stop"] as const).flatMap( + (finish, index) => { + const messageID = `msg-progress-${index}`; + return [ + { + type: "message.updated", + properties: { sessionID, info: { id: messageID, role: "assistant" } }, + }, + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: `part-progress-${index}`, + messageID, + sessionID, + type: "text", + text: `Text ${index}`, + time: { start: index + 1, end: index + 2 }, + }, + }, + }, + { + type: "message.updated", + properties: { sessionID, info: { id: messageID, role: "assistant", finish } }, + }, + ...(index === 0 + ? [ + { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: `part-progress-${index}`, + messageID, + sessionID, + type: "text", + text: "Text 0 updated", + time: { start: 1, end: 2 }, + }, + }, + }, + ] + : []), + ]; + }, + ); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "content.delta" || event.type === "item.completed"), + ), + Stream.take(8), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const completions = events.filter((event) => event.type === "item.completed"); + NodeAssert.deepEqual( + completions.map((event) => + event.type === "item.completed" ? [event.itemId, event.payload.presentation] : [], + ), + [ + ["part-progress-0", undefined], + ["part-progress-0", "progress"], + ["part-progress-1", undefined], + ["part-progress-2", undefined], + ], + ); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => + event.type === "content.delta" + ? [event.itemId, event.payload.streamKind, event.payload.delta] + : [], + ), + [ + ["part-progress-0", "assistant_text", "Text 0"], + ["part-progress-0", "assistant_progress_text", " updated"], + ["part-progress-1", "assistant_text", "Text 1"], + ["part-progress-2", "assistant_text", "Text 2"], + ], + ); + }), + ); + it.effect("emits tool lifecycle events before late assistant metadata", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -7100,6 +7284,104 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("keeps late final reports with the prompt's completed turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-late-final-report"); + const sessionID = "http://127.0.0.1:9999/session"; + const enqueue = makeOpenCodeEventQueue(); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Audit this branch", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const promptMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + enqueue({ type: "session.status", properties: { sessionID, status: { type: "busy" } } }); + enqueue({ + type: "message.updated", + properties: { + sessionID, + info: { + id: "msg-first-report", + role: "assistant", + parentID: promptMessageId, + finish: "stop", + }, + }, + }); + enqueue({ type: "session.status", properties: { sessionID, status: { type: "idle" } } }); + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, turn.turnId); + + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "Continue the audit", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const lateDeltaFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + event.type === "content.delta" && + event.itemId === "part-late-report", + ), + Stream.runHead, + Effect.forkChild, + ); + enqueue({ + type: "message.updated", + properties: { + sessionID, + info: { + id: "msg-late-report", + role: "assistant", + parentID: promptMessageId, + finish: "stop", + }, + }, + }); + enqueue({ + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "part-late-report", + messageID: "msg-late-report", + sessionID, + type: "text", + text: "Revised audit", + time: { start: 1, end: 2 }, + }, + }, + }); + const lateDelta = Option.getOrUndefined( + yield* Fiber.join(lateDeltaFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(lateDelta?.turnId, turn.turnId); + NodeAssert.notEqual(lateDelta?.turnId, nextTurn.turnId); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("processes late assistant metadata without visiting completed turns", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -7126,9 +7408,23 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { "opencode/kimi-k3", ), }); + const promptMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + enqueue({ + type: "session.status", + properties: { sessionID, status: { type: "busy" } }, + }); enqueue({ type: "message.updated", - properties: { sessionID, info: { id: `history-message-${index}`, role: "assistant" } }, + properties: { + sessionID, + info: { + id: `history-message-${index}`, + role: "assistant", + parentID: promptMessageId, + finish: "stop", + }, + }, }); enqueue({ type: "message.part.updated", @@ -7231,6 +7527,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { "opencode/kimi-k3", ), }); + enqueue({ type: "session.status", properties: { sessionID, status: { type: "busy" } } }); for (const parentID of ["", promptMessageId]) { enqueue({ type: "message.updated", @@ -7561,6 +7858,78 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("recovers a finished assistant message whose terminal event was lost", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-reconnect-lost-terminal-message"); + const reconnect = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [reconnect.promise]; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Finish during reconnect", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const promptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string }).messageID; + runtimeMock.state.messages.push( + { + info: { + id: "assistant-from-previous-turn", + role: "assistant", + parentID: "other-prompt", + finish: "stop", + }, + parts: [], + }, + { + info: { + id: "assistant-finished-during-reconnect", + role: "assistant", + parentID: promptMessageId, + finish: "length", + }, + parts: [], + }, + ); + const warningFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild, + ); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + runtimeMock.state.eventStreamError?.(new Error("socket closed")); + yield* Fiber.join(warningFiber); + reconnect.resolve({ + id: "evt-reconnected-after-finished-message", + type: "server.connected", + properties: {}, + } satisfies OpenCodeEvent); + yield* advanceTestClock(6_000); + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.sessionMessagesCalls, 1); + NodeAssert.equal( + (yield* adapter.listSessions()).find((candidate) => candidate.threadId === threadId) + ?.status, + "ready", + ); + yield* adapter.stopSession(threadId); + }), + ); + it.effect( "ends a running session on clean stream closure without discarding unresolved permissions", () => diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..ed8dda572210 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -213,6 +213,7 @@ interface OpenCodeIdleReconciliation { } interface OpenCodePromptAdmission { + steering: boolean; readonly generation: number; readonly turnId: TurnId; readonly messageId: string; @@ -222,6 +223,8 @@ interface OpenCodePromptAdmission { idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined; idleObservedAfterMessage: boolean; messageObserved: boolean; + assistantResponseFinished: boolean; + responseReceipt: Deferred.Deferred; busyObserved: boolean; idleStatusConfirmations: number; accepted: boolean; @@ -332,6 +335,7 @@ type OpenCodeTextPartState = Pick, "id" | "tokens">; @@ -350,6 +354,7 @@ interface OpenCodeSessionContext { readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; + readonly messageTurnIdById: Map; // 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>; @@ -597,8 +602,14 @@ function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray): "assistant_text" | "reasoning_text" { - return part.type === "reasoning" ? "reasoning_text" : "assistant_text"; +function resolveTextStreamKind( + part: Pick, +): "assistant_text" | "assistant_progress_text" | "reasoning_text" { + return part.type === "reasoning" + ? "reasoning_text" + : part.progressClassified + ? "assistant_progress_text" + : "assistant_text"; } function retainOpenCodeTextPart( @@ -616,6 +627,7 @@ function retainOpenCodeTextPart( ...(part.time !== undefined ? { time: part.time } : {}), emittedText: previous?.emittedText, completed: previous?.completed ?? false, + progressClassified: previous?.progressClassified ?? false, }; parts.set(part.id, state); context.textPartsByMessageId.set(part.messageID, parts); @@ -687,6 +699,17 @@ function messageRoleForPart( return part.type === "tool" ? "assistant" : undefined; } +const MAX_RECENT_MESSAGE_TURN_IDS = 4_096; + +function rememberMessageTurnId(context: OpenCodeSessionContext, messageId: string, turnId: TurnId) { + const associations = context.messageTurnIdById; + associations.delete(messageId); + associations.set(messageId, turnId); + if (associations.size > MAX_RECENT_MESSAGE_TURN_IDS) { + associations.delete(associations.keys().next().value!); + } +} + function detailFromToolPart(part: Extract): string | undefined { switch (part.state.status) { case "completed": @@ -873,6 +896,9 @@ const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(fun return; } admission.cancelled = true; + if (admission.recoveryFiber) { + yield* Fiber.interrupt(admission.recoveryFiber); + } if (admission.promptFiber) { yield* Fiber.interrupt(admission.promptFiber); } @@ -1357,6 +1383,7 @@ export function makeOpenCodeAdapter( if (!promptAdmission.requiresMessageReceipt) { yield* Deferred.await(promptAdmission.acceptance); } + let lastStatusIsIdle = false; for ( let retryCount = 0; retryCount < 5 || (promptAdmission.requiresMessageReceipt && !promptAdmission.accepted); @@ -1433,6 +1460,7 @@ export function makeOpenCodeAdapter( const status = statusData?.[context.openCodeSessionId]; const isIdle = statusData !== undefined && (status === undefined || status.type === "idle"); + lastStatusIsIdle = isIdle; const isBusy = status?.type === "busy" || status?.type === "retry"; if (isBusy) { promptAdmission.busyObserved = true; @@ -1446,14 +1474,14 @@ export function makeOpenCodeAdapter( if ( isIdle && idle !== undefined && - (promptAdmission.messageObserved || promptAdmission.busyObserved) + (promptAdmission.assistantResponseFinished || promptAdmission.busyObserved) ) { context.promptAdmission = undefined; context.awaitingBusyAfterInterruption = false; yield* scheduleIdleReconciliation(context, promptAdmission.turnId, idle.raw); return; } - if (isIdle && promptAdmission.messageObserved) { + if (isIdle && promptAdmission.assistantResponseFinished) { promptAdmission.idleStatusConfirmations += 1; if (promptAdmission.idleStatusConfirmations >= 2) { context.promptAdmission = undefined; @@ -1474,7 +1502,7 @@ export function makeOpenCodeAdapter( } if ( isIdle && - promptAdmission.messageObserved && + promptAdmission.assistantResponseFinished && promptAdmission.recoveryRaw !== undefined ) { context.promptAdmission = undefined; @@ -1490,6 +1518,61 @@ export function makeOpenCodeAdapter( const delayMs = Math.min(250 * 2 ** retryCount, 2_000); yield* Effect.sleep(`${delayMs} millis`); } + // An idle status can precede OpenCode's first assistant response. Keep + // the turn owned until that response arrives; its terminal update + // restarts reconciliation without polling an idle session indefinitely. + if (promptAdmission.messageObserved && !promptAdmission.assistantResponseFinished) { + // The terminal assistant event may have been lost during a reconnect. + // Confirm it from OpenCode's durable messages before waiting for the stream. + if (lastStatusIsIdle) { + const messages = yield* runOpenCodeSdk("session.messages", (signal) => + context.client.session.messages({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe(Effect.timeout("1 second"), Effect.option); + if ( + (yield* Ref.get(context.stopped)) || + sessions.get(context.session.threadId) !== context || + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled + ) { + return; + } + const finished = + Option.isSome(messages) && + messages.value.data?.some( + (message) => + message.info.role === "assistant" && + message.info.parentID === promptAdmission.messageId && + (message.info.finish === "stop" || message.info.finish === "length"), + ); + if (finished) { + promptAdmission.assistantResponseFinished = true; + yield* Deferred.succeed(promptAdmission.responseReceipt, undefined); + } + } + const response = yield* Deferred.await(promptAdmission.responseReceipt).pipe( + Effect.timeoutOption("2 minutes"), + ); + if (Option.isNone(response)) { + yield* failPromptAdmissionRecovery(context, promptAdmission); + return; + } + if ( + context.promptAdmission === promptAdmission && + context.activeTurnId === promptAdmission.turnId && + context.promptGeneration === promptAdmission.generation && + !promptAdmission.cancelled + ) { + context.promptAdmission = undefined; + yield* scheduleIdleReconciliation( + context, + promptAdmission.turnId, + promptAdmission.recoveryRaw, + ); + } + return; + } yield* failPromptAdmissionRecovery(context, promptAdmission); }).pipe( Effect.catchCause(() => Effect.void), @@ -1613,6 +1696,34 @@ export function makeOpenCodeAdapter( yield* Scope.close(context.sessionScope, Exit.void); }); + const emitAssistantProgressCompletion = Effect.fn("emitAssistantProgressCompletion")(function* ( + context: OpenCodeSessionContext, + part: OpenCodeTextPartState, + turnId: TurnId | undefined, + raw: unknown, + ) { + if (part.text === undefined || part.text.trim().length === 0) return; + part.progressClassified = true; + part.completed = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: part.time?.end !== undefined ? isoFromEpochMs(part.time.end) : undefined, + raw, + })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + presentation: "progress", + title: "Assistant message", + detail: part.text, + }, + }); + }); + /** Emit content.delta and item.completed events for an assistant text part. */ const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( context: OpenCodeSessionContext, @@ -2264,7 +2375,24 @@ export function makeOpenCodeAdapter( return; } - const turnId = context.activeTurnId; + if (event.type === "message.updated" && event.properties.info.role === "assistant") { + const parentId = event.properties.info.parentID; + const parentTurnId = parentId ? context.messageTurnIdById.get(parentId) : undefined; + if (parentTurnId) { + rememberMessageTurnId(context, event.properties.info.id, parentTurnId); + } + } + const assistantMessageId = + event.type === "message.updated" && event.properties.info.role === "assistant" + ? event.properties.info.id + : event.type === "message.part.updated" + ? event.properties.part.messageID + : event.type === "message.part.delta" + ? event.properties.messageID + : undefined; + const turnId = + (assistantMessageId ? context.messageTurnIdById.get(assistantMessageId) : undefined) ?? + context.activeTurnId; yield* writeNativeEventBestEffort(context.session.threadId, { observedAt: yield* nowIso, event: { @@ -2336,15 +2464,7 @@ export function makeOpenCodeAdapter( promptAdmission.messageObserved = true; yield* Deferred.succeed(promptAdmission.messageReceipt, undefined); if (promptAdmission.accepted) { - const idle = promptAdmission.idleDuringAdmission; - context.awaitingBusyAfterInterruption = false; - context.promptAdmission = undefined; - if (promptAdmission.recoveryFiber) { - yield* Fiber.interrupt(promptAdmission.recoveryFiber); - } - if (idle) { - yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw); - } + yield* schedulePromptAdmissionRecovery(context, event); } } context.messageRoleById.set(event.properties.info.id, event.properties.info.role); @@ -2352,6 +2472,22 @@ export function makeOpenCodeAdapter( context.textPartsByMessageId.delete(event.properties.info.id); } if (event.properties.info.role === "assistant") { + if ( + promptAdmission !== undefined && + promptAdmission.messageId === event.properties.info.parentID && + (event.properties.info.finish === "stop" || event.properties.info.finish === "length") + ) { + promptAdmission.assistantResponseFinished = true; + yield* Deferred.succeed(promptAdmission.responseReceipt, undefined); + if (promptAdmission.accepted) { + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* scheduleIdleReconciliation(context, promptAdmission.turnId, event); + } + } const usage = context.turnTokenUsage; const parentMessageId = typeof event.properties.info.parentID === "string" && @@ -2387,6 +2523,13 @@ export function makeOpenCodeAdapter( .get(event.properties.info.id) ?.values() ?? []) { yield* emitAssistantTextDelta(context, part, turnId, event); + if ( + part.type === "text" && + !part.progressClassified && + event.properties.info.finish === "tool-calls" + ) { + yield* emitAssistantProgressCompletion(context, part, turnId, event); + } } } break; @@ -2594,8 +2737,17 @@ export function makeOpenCodeAdapter( yield* cancelIdleReconciliation(context); context.awaitingBusyAfterInterruption = false; if (context.promptAdmission?.turnId === turnId) { - context.promptAdmission.busyObserved = true; - yield* schedulePromptAdmissionRecovery(context, event); + const admission = context.promptAdmission; + admission.busyObserved = true; + if (admission.accepted && admission.messageObserved) { + admission.cancelled = true; + context.promptAdmission = undefined; + if (admission.recoveryFiber) { + yield* Fiber.interrupt(admission.recoveryFiber); + } + } else { + yield* schedulePromptAdmissionRecovery(context, event); + } } yield* updateProviderSession(context, { status: "running", @@ -2625,9 +2777,23 @@ export function makeOpenCodeAdapter( break; } if (context.promptAdmission?.turnId === turnId) { - context.promptAdmission.idleDuringAdmission = { turnId, raw: event }; - context.promptAdmission.idleObservedAfterMessage = - context.promptAdmission.messageObserved; + const admission = context.promptAdmission; + const priorIdle = admission.idleDuringAdmission ?? admission.priorIdle; + admission.idleDuringAdmission = { turnId, raw: event }; + admission.idleObservedAfterMessage = admission.messageObserved; + if ( + admission.accepted && + admission.messageObserved && + (priorIdle || !context.awaitingBusyAfterInterruption) + ) { + admission.cancelled = true; + context.promptAdmission = undefined; + if (admission.recoveryFiber) { + yield* Fiber.interrupt(admission.recoveryFiber); + } + yield* scheduleIdleReconciliation(context, turnId, event); + break; + } yield* schedulePromptAdmissionRecovery(context, event); break; } @@ -2662,6 +2828,14 @@ export function makeOpenCodeAdapter( break; } } + const promptAdmission = context.promptAdmission; + if (promptAdmission) { + promptAdmission.cancelled = true; + context.promptAdmission = undefined; + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } + } yield* cancelIdleReconciliation(context); const terminalCancellation = activeTurnId !== undefined && cancellation?.turnId === activeTurnId @@ -3017,6 +3191,7 @@ export function makeOpenCodeAdapter( pendingQuestions: new Map(), textPartsByMessageId: new Map(), messageRoleById: new Map(), + messageTurnIdById: new Map(), turnTokenUsage: undefined, activeTurnId: undefined, activeAgent: undefined, @@ -3177,7 +3352,12 @@ export function makeOpenCodeAdapter( : undefined; context.pendingIdleReconciliation = undefined; const promptGeneration = context.promptGeneration + 1; + const previousAdmission = context.promptAdmission; + if (previousAdmission?.recoveryFiber) { + yield* Fiber.interrupt(previousAdmission.recoveryFiber); + } const promptAdmission: OpenCodePromptAdmission = { + steering: steeringTurnId !== undefined, generation: promptGeneration, turnId, messageId, @@ -3187,6 +3367,8 @@ export function makeOpenCodeAdapter( idleDuringAdmission: undefined, idleObservedAfterMessage: false, messageObserved: false, + assistantResponseFinished: false, + responseReceipt: Deferred.makeUnsafe(), busyObserved: false, idleStatusConfirmations: 0, accepted: false, @@ -3200,6 +3382,7 @@ export function makeOpenCodeAdapter( context.promptAdmission = promptAdmission; context.activeTurnId = turnId; + rememberMessageTurnId(context, messageId, turnId); if (steeringTurnId === undefined) { context.turnTokenUsage = makeOpenCodeTurnTokenUsageAccumulator(); } @@ -3490,13 +3673,29 @@ export function makeOpenCodeAdapter( ) { context.awaitingBusyAfterInterruption = false; const idle = promptAdmission.idleDuringAdmission; - if (idle && !promptAdmission.idleObservedAfterMessage) { - yield* schedulePromptAdmissionRecovery(context, idle.raw); - } else { + if (promptAdmission.busyObserved && promptAdmission.messageObserved) { + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } + context.promptAdmission = undefined; + if (idle) { + yield* scheduleIdleReconciliation(context, turnId, idle.raw); + } + } else if (promptAdmission.assistantResponseFinished) { + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } + context.promptAdmission = undefined; + yield* scheduleIdleReconciliation(context, turnId, promptAdmission.recoveryRaw); + } else if ( + idle && + promptAdmission.idleObservedAfterMessage && + promptAdmission.steering + ) { context.promptAdmission = undefined; - } - if (idle && promptAdmission.idleObservedAfterMessage) { yield* scheduleIdleReconciliation(context, turnId, idle.raw); + } else { + yield* schedulePromptAdmissionRecovery(context, idle?.raw); } } else { yield* schedulePromptAdmissionRecovery(context, promptAdmission.recoveryRaw); @@ -3625,6 +3824,9 @@ export function makeOpenCodeAdapter( const promptAdmission = context.promptAdmission; if (promptAdmission !== undefined && promptAdmission.turnId === interruptedTurnId) { promptAdmission.cancelled = true; + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } if (promptAdmission.promptFiber) { yield* Fiber.interrupt(promptAdmission.promptFiber); } @@ -3975,6 +4177,7 @@ export function makeOpenCodeAdapter( context.relatedSessionIds.clear(); context.relatedSessionIds.add(forkedSessionId); context.messageRoleById.clear(); + context.messageTurnIdById.clear(); context.textPartsByMessageId.clear(); context.turnTokenUsage = undefined; context.activeTurnId = undefined; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..0e1575ca9eec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -27,6 +27,7 @@ import { liveWorkEntryLabel, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + reasoningDisplayKind, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, @@ -44,6 +45,28 @@ import { } from "../../session-logic"; import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; +describe("expanded tool group scrolling", () => { + const entry = (id: string) => ({ + id, + createdAt: "2026-09-20T12:00:00.000Z", + label: id, + tone: "tool" as const, + }); + + it("restores the saved entry in a group", () => { + expect( + resolveWorkGroupScrollIndex([entry("one"), entry("two")], { entryId: "two", offset: 4 }), + ).toEqual({ index: 1, viewOffset: -4 }); + }); + + it("follows appended entries only when the group is at its end", () => { + expect(shouldFollowWorkGroupAppend([entry("one")], [entry("one"), entry("two")], 0)).toBe(true); + expect(shouldFollowWorkGroupAppend([entry("one")], [entry("one"), entry("two")], 5)).toBe( + false, + ); + }); +}); + describe("streaming row projection", () => { function fixture(text = "") { const turnId = TurnId.make("live-turn"); @@ -634,55 +657,6 @@ describe("streaming row projection", () => { }); }); -describe("expanded tool group scrolling", () => { - const entries = [{ id: "first" }, { id: "second" }]; - - it("follows appended calls only at the hard end", () => { - const appended = [...entries, { id: "third" }]; - expect(shouldFollowWorkGroupAppend(entries, appended, 0)).toBe(true); - expect(shouldFollowWorkGroupAppend(entries, appended, 0.5)).toBe(true); - expect(shouldFollowWorkGroupAppend(entries, appended, 1)).toBe(true); - expect(shouldFollowWorkGroupAppend(entries, appended, 1.01)).toBe(false); - expect(shouldFollowWorkGroupAppend(entries, appended, 10)).toBe(false); - expect(shouldFollowWorkGroupAppend(entries, appended, Infinity)).toBe(false); - }); - - it("does not follow output updates, prepends, or replacements", () => { - expect( - shouldFollowWorkGroupAppend( - entries, - entries.map((entry) => ({ ...entry })), - 0, - ), - ).toBe(false); - expect(shouldFollowWorkGroupAppend(entries, [{ id: "older" }, ...entries], 0)).toBe(false); - expect( - shouldFollowWorkGroupAppend( - entries, - [{ id: "replacement" }, entries[1]!, { id: "third" }], - 0, - ), - ).toBe(false); - expect(shouldFollowWorkGroupAppend([], entries, 0)).toBe(false); - }); - - it("restores the visible tool and its offset inside expanded output", () => { - const anchor = { entryId: "second", offset: 120 }; - expect(resolveWorkGroupScrollIndex(entries, anchor)).toEqual({ index: 1, viewOffset: -120 }); - expect(resolveWorkGroupScrollIndex([{ id: "older" }, ...entries], anchor)).toEqual({ - index: 2, - viewOffset: -120, - }); - }); - - it("starts normally when the saved tool no longer exists", () => { - expect(resolveWorkGroupScrollIndex(entries, undefined)).toBeUndefined(); - expect( - resolveWorkGroupScrollIndex(entries, { entryId: "removed", offset: 120 }), - ).toBeUndefined(); - }); -}); - describe("work entry labels", () => { const entry = { id: "tool-1", @@ -803,7 +777,6 @@ describe("work entry labels", () => { const directRow = rows.find((row) => row.kind === "work"); expect(directRow).toMatchObject({ groupedEntries: [expect.objectContaining({ id: "tool-1" })], - isExpandedToolGroup: false, displayLabel: label, }); }, @@ -1950,303 +1923,398 @@ describe("deriveMessagesTimelineRows", () => { }, }); - it("keeps all thoughts in one activity row as current and earlier traces stream", () => { - const entries = [1, 2, 3, 4].map((second) => { - const entry = reasoningEntry(`reasoning-${second}`, `2026-01-01T00:00:0${second}Z`, "turn-1"); - return { - ...entry, - message: { - ...entry.message, - text: `Step ${second}`, - streaming: second === 2 || second === 4, - }, - }; - }); - const input = { - timelineEntries: entries, + it("uses the provider stream kind even when the summary is long or raw text is short", () => { + const base = reasoningEntry("reasoning", "2026-01-01T00:00:01Z", "turn-1").message; + expect( + reasoningDisplayKind( + { ...base, id: MessageId.make("reasoning:summary:item"), text: "x".repeat(5_000) }, + { hasSummary: true }, + ), + ).toBe("summary"); + expect( + reasoningDisplayKind( + { ...base, id: MessageId.make("reasoning:raw:item"), text: "short" }, + { hasSummary: true }, + ), + ).toBe("raw"); + expect( + reasoningDisplayKind( + { ...base, id: MessageId.make("reasoning:raw:item"), text: "short" }, + { hasSummary: false }, + ), + ).toBe("summary"); + expect( + reasoningDisplayKind( + { ...base, id: MessageId.make("assistant:progress-part"), text: "Checking tools." }, + { hasSummary: true }, + ), + ).toBe("summary"); + }); + + it("keeps short raw-only thoughts inline when another thought in the turn is long", () => { + const first = reasoningEntry("reasoning:raw:first", "2026-01-01T00:00:01Z", "turn-1"); + first.message.text = "Checking the test result."; + const progress = answerEntry("progress", "2026-01-01T00:00:02Z", "turn-1"); + progress.message.text = "The test passed. Running typecheck."; + const second = reasoningEntry("reasoning:raw:second", "2026-01-01T00:00:03Z", "turn-1"); + second.message.text = "Checking the typecheck result."; + const long = reasoningEntry("reasoning:raw:long", "2026-01-01T00:00:04Z", "turn-1"); + long.message.text = "The full trace.\n".repeat(300); + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [first, progress, second, long], runningTurnId: TurnId.make("turn-1"), isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", turnDiffSummaries: [], supportsConversationRollback: false, - } satisfies Parameters[0]; - const initial = deriveMessagesTimelineRowsWithState(input); - expect(initial.rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); - expect(initial.rows.at(-1)).toMatchObject({ - id: "live-activity-row", - entries, - expanded: false, - active: true, }); - expect(deriveMessagesTimelineRowsWithState(input, initial).rows).toBe(initial.rows); - const stable = computeStableMessagesTimelineRows(initial.rows, { byId: new Map(), result: [] }); - expect(computeStableMessagesTimelineRows(deriveMessagesTimelineRows(input), stable)).toBe( - stable, - ); - for (const index of [3, 1]) { - const entry = entries[index]!; - const updatedEntry = { ...entry, message: { ...entry.message, text: "Updated trace" } }; - const updatedEntries = entries.map((entry, position) => - position === index ? updatedEntry : entry, - ); - const updatedInput = { ...input, timelineEntries: updatedEntries }; - const updated = deriveMessagesTimelineRowsWithState(updatedInput, initial); - expect(updated.rows).toEqual(deriveMessagesTimelineRows(updatedInput)); - const updatedStable = computeStableMessagesTimelineRows(updated.rows, stable); - expect(updatedStable.byId.get("live-activity-row")).not.toBe( - stable.byId.get("live-activity-row"), - ); - expect(updatedStable.byId.get("live-activity-row")).toMatchObject({ - entries: updatedEntries, - }); - expect(updatedStable.byId.get("working-indicator-row")).toBe( - stable.byId.get("working-indicator-row"), - ); - expect(initial.rows.at(-1)).toMatchObject({ entries }); - } + expect( + rows.filter((row) => row.kind === "reasoning-run").map((row) => [row.id, row.reasoningKind]), + ).toEqual([ + [first.id, "summary"], + [second.id, "summary"], + [long.id, "raw"], + ]); + expect( + rows.some((row) => row.kind === "message" && row.message.id === progress.message.id), + ).toBe(true); }); - it("updates the same collapsed row as tools and thoughts alternate", () => { - const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); - const current = reasoningEntry("thought-current", "2026-01-01T00:00:02Z", "turn-1"); - current.message.streaming = true; - const tool = toolEntry("tool-current", "2026-01-01T00:00:03Z", "turn-1"); - const runningTool = { - ...tool, - entry: { ...tool.entry, command: "pwd", toolLifecycleStatus: "inProgress" as const }, - }; - const next = reasoningEntry("thought-next", "2026-01-01T00:00:04Z", "turn-1"); - next.message.streaming = true; - const completed = { ...current, message: { ...current.message, streaming: false } }; - for (const entries of [ - [first, current], - [first, current, runningTool], - [first, current, runningTool, next], - [first, completed], - ]) { - const input = { - timelineEntries: entries, - runningTurnId: TurnId.make("turn-1"), - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaries: [], - supportsConversationRollback: false, - } satisfies Parameters[0]; - const rows = deriveMessagesTimelineRows(input); - expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); - expect(rows.at(-1)).toMatchObject({ - id: "live-activity-row", - entries, - active: true, - expanded: false, - }); - const expanded = deriveMessagesTimelineRows({ - ...input, - expandedWorkGroupIds: new Set(["activity-group:thought-first"]), - }); - expect(expanded.at(-1)).toMatchObject({ id: "live-activity-row", entries, expanded: true }); - } - }); + it("shows reclassified OpenCode progress inline beside raw reasoning and a final answer", () => { + const raw = reasoningEntry("reasoning:raw:trace", "2026-01-01T00:00:01Z", "turn-1"); + raw.message.text = "Detailed trace."; + const progress = reasoningEntry("progress", "2026-01-01T00:00:02Z", "turn-1"); + progress.message.id = MessageId.make("assistant:progress-part") as never; + progress.message.text = "Checking the reviews."; + const answer = answerEntry("answer", "2026-01-01T00:00:03Z", "turn-1"); - it.each(["assistant", "user", "error", "turn"] as const)( - "keeps activity separate across a %s boundary", - (boundary) => { - const first = reasoningEntry("reasoning-first", "2026-01-01T00:00:01Z", "turn-1"); - const last = reasoningEntry( - "reasoning-last", - "2026-01-01T00:00:03Z", - boundary === "turn" ? "turn-2" : "turn-1", - ); - const answer = answerEntry("answer-between", "2026-01-01T00:00:02Z", "turn-1"); - const error = toolEntry("error-between", "2026-01-01T00:00:02Z", "turn-1"); - const middle = - boundary === "turn" - ? [] - : boundary === "error" - ? [{ ...error, entry: { ...error.entry, tone: "error" as const } }] - : [ - { - ...answer, - message: { - ...answer.message, - role: boundary === "user" ? ("user" as const) : ("assistant" as const), - }, - }, - ]; - const rows = deriveMessagesTimelineRows({ - timelineEntries: [first, ...middle, last], - expandedTurnIds: new Set([TurnId.make("turn-1"), TurnId.make("turn-2")]), - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaries: [], - supportsConversationRollback: false, - }); - expect(rows.filter((row) => row.kind === "activity-group")).toMatchObject([ - { entries: [first], active: false }, - { entries: [last], active: false }, - ]); - if (boundary === "error") { - expect(rows).toContainEqual(expect.objectContaining({ kind: "work", id: error.id })); - } - }, - ); - - it("does not combine thoughts without a known turn", () => { - const thoughts = [1, 2].map((second) => - reasoningEntry(`unknown-${second}`, `2026-01-01T00:00:0${second}Z`, null), - ); const rows = deriveMessagesTimelineRows({ - timelineEntries: thoughts, - isWorking: false, - activeTurnStartedAt: null, + timelineEntries: [raw, progress, answer], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", turnDiffSummaries: [], supportsConversationRollback: false, }); - expect(rows.map((row) => row.id)).toEqual(thoughts.map((entry) => entry.id)); + + expect( + rows.filter((row) => row.kind === "reasoning-run").map((row) => [row.id, row.reasoningKind]), + ).toEqual([ + [raw.id, "raw"], + [progress.id, "summary"], + ]); + expect(rows.some((row) => row.kind === "message" && row.id === answer.id)).toBe(true); }); - it("keeps a thought-only turn out of the work fold", () => { - const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); + it("folds earlier turnless reports left by a completed OpenCode prompt", () => { + const user = answerEntry("prompt", "2026-01-01T00:00:00Z", "turn-1"); + user.message.role = "user" as never; + user.message.turnId = null as never; + const reports = [1, 2, 3].map((index) => { + const entry = answerEntry(`report-${index}`, `2026-01-01T00:00:0${index}Z`, "turn-1"); + entry.message.turnId = null as never; + entry.message.text = `Audit report ${index}`; + return entry; + }); const rows = deriveMessagesTimelineRows({ - timelineEntries: [thought, answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1")], + timelineEntries: [user, ...reports], isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, }); - expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); - expect(rows.find((row) => row.kind === "activity-group")).toMatchObject({ - entries: [thought], - expanded: false, + expect(rows.map((row) => row.kind)).toEqual(["message", "turn-fold", "message"]); + expect(rows.at(-1)?.id).toBe("report-3"); + const liveRows = deriveMessagesTimelineRows({ + timelineEntries: [user, ...reports], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, }); + expect(liveRows.filter((row) => row.kind === "turn-fold")).toHaveLength(0); + const nextUser = answerEntry("next-prompt", "2026-01-01T00:00:10Z", "turn-2"); + nextUser.message.role = "user" as never; + nextUser.message.turnId = null as never; + const nextTurnRows = deriveMessagesTimelineRows({ + timelineEntries: [user, ...reports, nextUser], + isWorking: true, + activeTurnStartedAt: nextUser.createdAt, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(nextTurnRows.filter((row) => row.kind === "turn-fold")).toHaveLength(1); }); - it("keeps the assistant footer before a trailing thought-only group", () => { - const answer = answerEntry("assistant-entry", "2026-01-01T00:00:01Z", "turn-1"); - const thought = reasoningEntry("reasoning-after", "2026-01-01T00:00:02Z", "turn-1"); + it("folds turnless reasoning with its completed response", () => { + const user = answerEntry("prompt", "2026-01-01T00:00:00Z", "turn-1"); + user.message.role = "user" as never; + user.message.turnId = null as never; + const thought = reasoningEntry("thought", "2026-01-01T00:00:01Z", "turn-1"); + thought.message.turnId = null as never; + const answer = answerEntry("answer", "2026-01-01T00:00:02Z", "turn-1"); + answer.message.turnId = null as never; const rows = deriveMessagesTimelineRows({ - timelineEntries: [answer, thought], + timelineEntries: [user, thought, answer], isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, }); - expect(rows.map((row) => row.kind)).toEqual(["message", "activity-group"]); - expect(rows[0]).toMatchObject({ message: answer.message, showAssistantMeta: true }); - expect(rows[1]).toMatchObject({ entries: [thought] }); + expect(rows.map((row) => row.kind)).toEqual(["message", "turn-fold", "message"]); }); - it("keeps thoughts and tools in one activity row across a failed tool", () => { - const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); - const tools = ["a", "b", "c"].map((id, index) => { - const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1"); - return { - ...entry, - entry: { - ...entry.entry, - command: `echo ${id}`, - toolCallId: id, - toolLifecycleStatus: id === "b" ? ("failed" as const) : ("completed" as const), - sourceActivityKind: "tool.completed" as const, - }, - }; - }); - const input = { - timelineEntries: [thought, ...tools], + it("keeps provider summaries and raw traces in separate reasoning rows", () => { + const summary = reasoningEntry("summary", "2026-01-01T00:00:01Z", "turn-1"); + summary.message.id = MessageId.make("reasoning:summary:item") as never; + const raw = reasoningEntry("raw", "2026-01-01T00:00:02Z", "turn-1"); + raw.message.id = MessageId.make("reasoning:raw:item") as never; + const rows = deriveMessagesTimelineRows({ + timelineEntries: [summary, raw], runningTurnId: TurnId.make("turn-1"), isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", turnDiffSummaries: [], supportsConversationRollback: false, + }); + expect( + rows.filter((row) => row.kind === "reasoning-run").map((row) => row.reasoningKind), + ).toEqual(["summary", "raw"]); + }); + + it("keeps turnless raw reasoning collapsed beside its summary", () => { + const user = answerEntry("prompt", "2026-01-01T00:00:00Z", "turn-1"); + user.message.role = "user" as never; + user.message.turnId = null as never; + const summary = reasoningEntry("reasoning:summary:brief", "2026-01-01T00:00:01Z", null); + const raw = reasoningEntry("reasoning:raw:brief", "2026-01-01T00:00:02Z", null); + raw.message.text = "Short raw trace."; + raw.message.streaming = true; + const input = { + timelineEntries: [user, summary, raw], + isWorking: true, + activeTurnStartedAt: user.createdAt, + turnDiffSummaries: [], + supportsConversationRollback: false, } satisfies Parameters[0]; - const rows = deriveMessagesTimelineRows(input); - expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]); - expect(rows.at(-1)).toMatchObject({ - id: "live-activity-row", - entries: [thought, ...tools], - active: true, + const first = deriveMessagesTimelineRowsWithState(input); + expect( + first.rows.filter((row) => row.kind === "reasoning-run").map((row) => row.reasoningKind), + ).toEqual(["summary", "raw"]); + + const updatedRaw = { + ...raw, + message: { ...raw.message, text: "Short raw trace, still growing." }, + }; + const updated = deriveMessagesTimelineRowsWithState( + { ...input, timelineEntries: [user, summary, updatedRaw] }, + first, + ); + expect(updated.rows.find((row) => row.id === raw.id)).toMatchObject({ + kind: "reasoning-run", + reasoningKind: "raw", + messages: [updatedRaw.message], }); - const settled = deriveMessagesTimelineRows({ + + const nextUser = answerEntry("next-prompt", "2026-01-01T00:00:03Z", "turn-2"); + nextUser.message.role = "user" as never; + nextUser.message.turnId = null as never; + const nextRaw = reasoningEntry("reasoning:raw:next", "2026-01-01T00:00:04Z", null); + const nextRows = deriveMessagesTimelineRows({ ...input, - timelineEntries: [ - thought, - ...tools, - reasoningEntry("reasoning-next", "2026-01-01T00:00:05Z", "turn-1"), - { ...tools[1]!, id: "d", entry: { ...tools[1]!.entry, id: "d", toolCallId: "d" } }, - ], + timelineEntries: [user, summary, raw, nextUser, nextRaw], + activeTurnStartedAt: nextUser.createdAt, + }); + expect(nextRows.find((row) => row.id === nextRaw.id)).toMatchObject({ + kind: "reasoning-run", + reasoningKind: "summary", + }); + }); + + it("shows Thinking after settled reasoning while a turn keeps running", () => { + const thought = reasoningEntry("reasoning:summary:settled", "2026-01-01T00:00:01Z", "turn-1"); + const commentary = answerEntry("commentary", "2026-01-01T00:00:02Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [thought, commentary], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" }); + }); + + it("renders reasoning between adjacent tool groups in turn order", () => { + const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); + const tools = [ + toolEntry("tool-a", "2026-01-01T00:00:02Z", "turn-1"), + toolEntry("tool-b", "2026-01-01T00:00:03Z", "turn-1"), + ]; + const second = reasoningEntry("thought-second", "2026-01-01T00:00:04Z", "turn-1"); + const answer = answerEntry("answer", "2026-01-01T00:00:06Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [first, ...tools, second, answer], + expandedTurnIds: new Set([TurnId.make("turn-1")]), isWorking: false, activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, }); - expect(settled.map((row) => row.kind)).toEqual(["activity-group"]); + expect(rows.map((row) => row.kind)).toEqual([ + "turn-fold", + "reasoning-run", + "work-toggle", + "reasoning-run", + "message", + ]); + expect( + rows + .filter((row) => row.kind === "reasoning-run" || row.kind === "message") + .map((row) => row.id), + ).toEqual([first.id, second.id, answer.id]); + expect(rows[2]).toMatchObject({ hiddenCount: 2 }); }); - it.each(["failed", "declined"] as const)( - "settles the activity row while the latest tool is %s", - (status) => { - const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); - const tool = toolEntry("last-tool", "2026-01-01T00:00:02Z", "turn-1"); - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - thought, - { - ...tool, - entry: { - ...tool.entry, - command: "echo nope", - toolCallId: "last-tool", - toolLifecycleStatus: status, - sourceActivityKind: "tool.completed" as const, - }, - }, - ], - runningTurnId: TurnId.make("turn-1"), - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaries: [], - supportsConversationRollback: false, - }); - expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]); - expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false }); - expect(rows[2]).toMatchObject({ id: "live-activity-row" }); - }, - ); + it("keeps inline reasoning live and updates its row as text streams", () => { + const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); + first.message.streaming = true; + const input = { + timelineEntries: [first], + runningTurnId: TurnId.make("turn-1"), + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const initial = deriveMessagesTimelineRowsWithState(input); + expect(initial.rows.map((row) => row.kind)).toEqual(["working", "reasoning-run"]); + const updated = { + ...first, + message: { ...first.message, text: "More reasoning", updatedAt: "2026-01-01T00:00:02Z" }, + }; + const next = deriveMessagesTimelineRowsWithState( + { ...input, timelineEntries: [updated] }, + initial, + ); + expect(next.rows[1]).toMatchObject({ kind: "reasoning-run", messages: [updated.message] }); + expect(next.rows[0]).toBe(initial.rows[0]); + }); - it("folds mixed activity under worked-for and restores ordered details when expanded", () => { - const entries = [ - reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"), - toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), - reasoningEntry("reasoning-next", "2026-01-01T00:00:03Z", "turn-1"), - ]; + it("folds completed reasoning and tools together while preserving inline order", () => { + const first = reasoningEntry("thought-first", "2026-01-01T00:00:01Z", "turn-1"); + const tool = toolEntry("tool", "2026-01-01T00:00:02Z", "turn-1"); + const second = reasoningEntry("thought-second", "2026-01-01T00:00:03Z", "turn-1"); + const answer = answerEntry("answer", "2026-01-01T00:00:04Z", "turn-1"); const input = { - timelineEntries: [ - ...entries, - answerEntry("assistant-entry", "2026-01-01T00:00:04Z", "turn-1"), - ], + timelineEntries: [first, tool, second, answer], isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, } satisfies Parameters[0]; - const rows = deriveMessagesTimelineRows(input); - expect(rows.map((row) => row.kind)).toEqual(["turn-fold", "message"]); + expect(deriveMessagesTimelineRows(input).map((row) => row.kind)).toEqual([ + "turn-fold", + "message", + ]); const expanded = deriveMessagesTimelineRows({ ...input, expandedTurnIds: new Set([TurnId.make("turn-1")]), }); - expect(expanded.filter((row) => row.kind === "activity-group")).toMatchObject([ - { entries, expanded: false, active: false }, + expect(expanded.map((row) => row.kind)).toEqual([ + "turn-fold", + "reasoning-run", + "work", + "reasoning-run", + "message", ]); - const details = deriveMessagesTimelineRows({ - ...input, - expandedTurnIds: new Set([TurnId.make("turn-1")]), - expandedWorkGroupIds: new Set(["activity-group:reasoning-entry"]), + expect( + expanded + .filter((row) => row.kind === "reasoning-run" || row.kind === "message") + .map((row) => row.id), + ).toEqual([first.id, second.id, answer.id]); + }); + + it("folds reasoning once a final answer settles", () => { + const thought = reasoningEntry("thought", "2026-01-01T00:00:01Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [thought, answerEntry("answer", "2026-01-01T00:00:02Z", "turn-1")], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, }); - expect(details.find((row) => row.kind === "activity-group")).toMatchObject({ - entries, - expanded: true, + expect(rows.map((row) => row.kind)).toEqual(["turn-fold", "message"]); + }); + + it("keeps compaction visible beside folded tool work", () => { + const compaction = toolEntry("compaction", "2026-01-01T00:00:01Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + ...compaction, + entry: { + ...compaction.entry, + tone: "info" as const, + sourceActivityKind: "context-compaction" as const, + }, + }, + toolEntry("tool", "2026-01-01T00:00:02Z", "turn-1"), + answerEntry("answer", "2026-01-01T00:00:03Z", "turn-1"), + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, }); - expect(deriveMessagesTimelineRows(input)).toEqual(rows); + expect(rows.map((row) => row.kind)).toEqual(["context-compaction", "turn-fold", "message"]); + }); + + it("folds Mimo reasoning around a visible user question", () => { + const turnId = TurnId.make("turn-1"); + const questionBase = toolEntry("question", "2026-01-01T00:00:03Z", "turn-1"); + const question = { + ...questionBase, + entry: { + ...questionBase.entry, + questionAnswer: { + requestId: ApprovalRequestId.make("request-1"), + answers: { scope: "Audit the branch" }, + questionTextById: { scope: "What should I audit?" }, + attachmentsByQuestionId: {}, + }, + }, + }; + const input = { + timelineEntries: [ + reasoningEntry("raw", "2026-01-01T00:00:01Z", "turn-1"), + reasoningEntry("progress", "2026-01-01T00:00:02Z", "turn-1"), + question, + answerEntry("report", "2026-01-01T00:00:04Z", "turn-1"), + ], + latestTurn: { + turnId, + state: "completed" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:05Z", + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + expect(deriveMessagesTimelineRows(input).map((row) => row.kind)).toEqual([ + "turn-fold", + "work", + "message", + ]); + expect( + deriveMessagesTimelineRows({ ...input, expandedTurnIds: new Set([turnId]) }).map( + (row) => row.kind, + ), + ).toEqual(["turn-fold", "reasoning-run", "work", "message"]); }); it("still folds a lone trailing tool call when a thought follows the answer", () => { @@ -2268,21 +2336,110 @@ describe("deriveMessagesTimelineRows", () => { expect(ids).not.toContain("reasoning-after"); }); + it("places a late-only reasoning fold before its answer", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + answerEntry("answer", "2026-01-01T00:00:02Z", "turn-1"), + reasoningEntry("late-thought", "2026-01-01T00:00:03Z", "turn-1"), + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["turn-fold", "message"]); + }); + it("does not let a stranded streaming thought hold a settled turn open", () => { const stranded = reasoningEntry("reasoning-stranded", "2026-01-01T00:00:01Z", "turn-1"); + const nextUser = answerEntry("next-user", "2026-01-01T00:00:04Z", "turn-2"); const rows = deriveMessagesTimelineRows({ timelineEntries: [ { ...stranded, message: { ...stranded.message, streaming: true } }, toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"), + { + ...nextUser, + message: { ...nextUser.message, role: "user", turnId: null, streaming: false }, + }, ], + isWorking: true, + runningTurnId: TurnId.make("turn-2"), + activeTurnStartedAt: "2026-01-01T00:00:04Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); + }); + + it("keeps a streaming turn visible when its lifecycle is unknown", () => { + const thought = reasoningEntry("thought", "2026-01-01T00:00:01Z", "turn-1"); + const answer = answerEntry("answer", "2026-01-01T00:00:02Z", "turn-1"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [thought, { ...answer, message: { ...answer.message, streaming: true } }], + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); + expect(rows.some((row) => row.kind === "reasoning-run")).toBe(true); + }); + + it("leaves failed work visible when the turn has no answer", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + reasoningEntry("thought", "2026-01-01T00:00:01Z", "turn-1"), + toolEntry("failed-tool", "2026-01-01T00:00:02Z", "turn-1"), + ], + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "error", + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:03Z", + }, isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false, }); + expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); + expect(rows.some((row) => row.kind === "work")).toBe(true); + }); - expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); + it("folds failed turn work when its visible answer was left streaming", () => { + const answer = answerEntry("assistant-entry", "2026-01-01T00:00:03Z", "turn-1"); + const input = { + timelineEntries: [ + reasoningEntry("thought", "2026-01-01T00:00:01Z", "turn-1"), + toolEntry("tool-entry", "2026-01-01T00:00:02Z", "turn-1"), + { ...answer, message: { ...answer.message, streaming: true } }, + ], + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "error" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:04Z", + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + } satisfies Parameters[0]; + const collapsed = deriveMessagesTimelineRows(input); + expect(collapsed.map((row) => row.kind)).toEqual(["turn-fold", "message"]); + expect(collapsed[1]?.id).toBe(answer.id); + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set([TurnId.make("turn-1")]), + }); + expect(expanded.map((row) => row.kind)).toEqual([ + "turn-fold", + "reasoning-run", + "work", + "message", + ]); }); it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => { @@ -2552,6 +2709,61 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("keeps streaming work visible behind turn-less progress", () => { + const turnId = TurnId.make("turn-streaming"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "work", + createdAt: "2026-01-01T00:00:05Z", + turnId, + label: "Ran command", + tone: "tool", + }, + }, + { + id: "streaming-entry", + kind: "message", + createdAt: "2026-01-01T00:00:06Z", + message: { + id: MessageId.make("streaming-response"), + role: "assistant", + text: "Checking results", + turnId, + createdAt: "2026-01-01T00:00:06Z", + updatedAt: "2026-01-01T00:00:06Z", + streaming: true, + }, + }, + { + id: "turnless-progress-entry", + kind: "work", + createdAt: "2026-01-01T00:00:07Z", + entry: { + id: "turnless-progress", + createdAt: "2026-01-01T00:00:07Z", + turnId: null, + label: "Still working", + tone: "thinking", + sourceActivityKind: "task.progress", + }, + }, + ], + latestTurn: null, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); + expect(rows.some((row) => row.id === "work-entry")).toBe(true); + }); + it("keeps a promptless restart in one active visual response", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -2794,7 +3006,6 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]); expect(rows.find((row) => row.kind === "work")).toMatchObject({ groupedEntries: [{ id: "completed-command", command: "rg toolCall" }], - isExpandedToolGroup: false, displayLabel: "rg toolCall", }); }); @@ -2845,7 +3056,6 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.find((row) => row.kind === "work")).toMatchObject({ groupedEntries: [{ id: "command-completed", toolCallId: "call-1" }], - isExpandedToolGroup: false, displayLabel: "rg toolCall", }); expect(rows.some((row) => row.kind === "work-toggle")).toBe(false); @@ -2961,6 +3171,56 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "work-live")).toBe(false); }); + it("keeps fresh work visible while the turn projection still names the previous turn", () => { + const activeTurnId = TurnId.make("fresh-work-turn"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "fresh-user-entry", + kind: "message", + createdAt: "2026-01-01T00:01:00Z", + message: { + id: MessageId.make("fresh-user"), + role: "user", + text: "Continue", + turnId: null, + createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", + streaming: false, + }, + }, + { + id: "fresh-running-entry", + kind: "work", + createdAt: "2026-01-01T00:01:02Z", + entry: { + id: "fresh-running", + createdAt: "2026-01-01T00:01:02Z", + turnId: activeTurnId, + label: "Running tests", + command: "vp test", + tone: "tool", + toolLifecycleStatus: "inProgress", + }, + }, + ], + latestTurn: { + turnId: TurnId.make("previous-turn"), + state: "completed", + startedAt: "2026-01-01T00:00:00Z", + completedAt: "2026-01-01T00:00:30Z", + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:01:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.some((row) => row.kind === "turn-fold" && row.turnId === activeTurnId)).toBe(false); + expect(rows.some((row) => row.kind === "work-live" && row.entry.id === "fresh-running")).toBe( + true, + ); + }); + it("does not revive separated historical task progress", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -3352,16 +3612,10 @@ describe("deriveMessagesTimelineRows", () => { toolSurface: "browser", toolIcon: { _tag: "website", pageUrl: "https://example.com/checkout" }, }); - expect(expandedRows.map((row) => row.id)).toEqual([ - "work-toggle:work-entry-1", - "work-group:work-entry-1:details", - ]); - expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({ - isExpandedToolGroup: true, - groupedEntries: timelineEntries.map(({ entry }) => entry), - }); + expect(expandedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, + groupedEntries: timelineEntries.map(({ entry }) => entry), }); }); @@ -3418,9 +3672,7 @@ describe("deriveMessagesTimelineRows", () => { ), }); expect( - expandedGroups.flatMap((row) => - row.kind === "work" && row.isExpandedToolGroup ? [row.groupedEntries] : [], - ), + expandedGroups.flatMap((row) => (row.kind === "work-toggle" ? [row.groupedEntries] : [])), ).toEqual([tools.slice(0, 2), tools.slice(2)]); const active = deriveMessagesTimelineRows({ ...input, @@ -3434,9 +3686,7 @@ describe("deriveMessagesTimelineRows", () => { (row) => (row.kind === "work" || row.kind === "work-live") && row.groupedEntries.includes(answer), ); - expect(answerRows).toMatchObject([ - { kind: "work", groupedEntries: [answer], isExpandedToolGroup: false }, - ]); + expect(answerRows).toMatchObject([{ kind: "work", groupedEntries: [answer] }]); } expect(active.find((row) => row.kind === "work-live")).toMatchObject({ groupedEntries: tools.slice(2), @@ -3522,7 +3772,7 @@ describe("deriveMessagesTimelineRows", () => { }); it.each([true, false])( - "keeps a large expanded tool run inside one timeline item, live=%s", + "keeps a large expanded tool run in outer timeline rows, live=%s", (isWorking) => { const turnId = TurnId.make("turn-many-tools"); const createdAt = "2026-09-01T12:00:00Z"; @@ -3555,12 +3805,14 @@ describe("deriveMessagesTimelineRows", () => { ...input, expandedWorkGroupIds: new Set([groupId]), }); - const groupRows = expandedRows.filter((row) => row.kind === "work"); + const groupRows = expandedRows.filter( + (row): row is Extract => + (row.kind === "work-toggle" || row.kind === "work-live") && row.groupId === groupId, + ); expect(groupRows).toHaveLength(1); - expect(groupRows[0]?.groupedEntries.map(({ id }) => id)).toEqual( + expect(groupRows[0]?.groupedEntries.map((entry) => entry.id)).toEqual( timelineEntries.map(({ entry }) => entry.id), ); - expect(groupRows[0]?.id).toBe(`${groupId}:details`); expect(deriveMessagesTimelineRows(input).some((row) => row.kind === "work")).toBe(false); }, ); @@ -3664,6 +3916,7 @@ describe("computeStableMessagesTimelineRows", () => { createdAt: "2026-01-01T00:00:00Z", groupId: "work-group:1", hiddenCount: 1, + groupedEntries: [], expanded: false, summary: "Used Browser", summaryKind: "other", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 57ed45a89d1d..49083783a929 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,11 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { + isReasoningSummaryMessage, + isUnkeyedResponseTurnId, + reasoningDisplayKind as sharedReasoningDisplayKind, + unkeyedResponseTurnId as makeUnkeyedResponseTurnId, + unsettledTurnId, +} from "@t3tools/client-runtime/thread-message-presentation"; export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; @@ -34,7 +41,7 @@ import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { type MessageId, type OrchestrationLatestTurn, - type TurnId, + TurnId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; @@ -110,35 +117,6 @@ export function workEntryIsVisibleInGroup( ); } -export interface WorkGroupScrollAnchor { - readonly entryId: string; - readonly offset: number; -} - -/** Restore a visible tool, including a position partway through its expanded output. */ -export function resolveWorkGroupScrollIndex( - entries: ReadonlyArray<{ readonly id: string }>, - anchor: WorkGroupScrollAnchor | undefined, -): { index: number; viewOffset: number } | undefined { - if (!anchor) return undefined; - const index = entries.findIndex((entry) => entry.id === anchor.entryId); - return index < 0 ? undefined : { index, viewOffset: -anchor.offset }; -} - -/** Only newly appended calls may follow the end, never status or output updates. */ -export function shouldFollowWorkGroupAppend( - previous: ReadonlyArray<{ readonly id: string }>, - entries: ReadonlyArray<{ readonly id: string }>, - distanceFromEnd: number, -): boolean { - return ( - previous.length > 0 && - entries.length > previous.length && - distanceFromEnd <= 1 && - previous.every((entry, index) => entry.id === entries[index]?.id) - ); -} - export interface TimelineEndState { readonly isAtEnd?: boolean; readonly contentLength?: number; @@ -314,35 +292,52 @@ export type TimelineLatestTurn = Pick< const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; -type ActivityEntry = Extract; +interface ReasoningResponseStats { + hasSummary: boolean; +} + +function reasoningStatsByMessage( + entries: ReadonlyArray, +): Map { + const statsByResponse = new Map(); + const statsByMessageId = new Map(); + let userBoundary = 0; + for (const entry of entries) { + if (entry.kind === "message" && entry.message.role === "user") userBoundary += 1; + if (entry.kind !== "message" || entry.message.role !== "reasoning") continue; + const message = entry.message; + // Imported responses can lack a turn ID; the preceding user message bounds them. + const key = message.turnId ? `turn:${message.turnId}` : `unkeyed:${userBoundary}`; + const response = statsByResponse.get(key) ?? { hasSummary: false }; + if (isReasoningSummaryMessage(message)) { + response.hasSummary = true; + } + statsByResponse.set(key, response); + statsByMessageId.set(message.id, response); + } + return statsByMessageId; +} -function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry { - return entry.kind === "message" - ? entry.message.role === "reasoning" - : entry.kind === "work" && - entry.entry.agentSpawn === undefined && - entry.entry.questionAnswer === undefined && - entry.entry.sourceActivityKind !== "context-compaction" && - entry.entry.tone !== "error"; +export function reasoningDisplayKind( + message: ChatMessage, + response: ReasoningResponseStats, +): "summary" | "raw" { + return sharedReasoningDisplayKind(message, response.hasSummary); } export type MessagesTimelineRow = | { - kind: "activity-group"; + kind: "reasoning-run"; id: string; createdAt: string; - turnId: TurnId; - groupId: string; - entries: ActivityEntry[]; - expanded: boolean; - active: boolean; + reasoningKind: "summary" | "raw"; + messages: ChatMessage[]; } | { kind: "work"; id: string; createdAt: string; groupedEntries: WorkLogEntry[]; - isExpandedToolGroup: boolean; displayLabel?: string; } | { @@ -362,6 +357,7 @@ export type MessagesTimelineRow = turnId?: TurnId | null; groupId: string; hiddenCount: number; + groupedEntries: WorkLogEntry[]; expanded: boolean; summary: string; summaryKind: ToolGroupSummaryKind; @@ -475,18 +471,31 @@ function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; } -function expandedWorkGroupRow( - groupId: string, - createdAt: string, - groupedEntries: WorkLogEntry[], -): Extract { - return { - kind: "work", - id: `${groupId}:details`, - createdAt, - groupedEntries, - isExpandedToolGroup: true, - }; +export interface WorkGroupScrollAnchor { + readonly entryId: string; + readonly offset: number; +} + +export function resolveWorkGroupScrollIndex( + entries: ReadonlyArray<{ readonly id: string }>, + anchor: WorkGroupScrollAnchor | undefined, +): { index: number; viewOffset: number } | undefined { + if (!anchor) return undefined; + const index = entries.findIndex((entry) => entry.id === anchor.entryId); + return index < 0 ? undefined : { index, viewOffset: -anchor.offset }; +} + +export function shouldFollowWorkGroupAppend( + previous: ReadonlyArray<{ readonly id: string }>, + entries: ReadonlyArray<{ readonly id: string }>, + distanceFromEnd: number, +): boolean { + return ( + previous.length > 0 && + entries.length > previous.length && + distanceFromEnd <= 1 && + previous.every((entry, index) => entry.id === entries[index]?.id) + ); } export function resolveAssistantMessageCopyState({ @@ -552,14 +561,7 @@ export function deriveUnsettledTurnId( latestTurn: TimelineLatestTurn | null, runningTurnId: TurnId | null, ): TurnId | null { - if (runningTurnId !== null) { - return runningTurnId; - } - if (!latestTurn) { - return null; - } - const isSettled = latestTurn.completedAt !== null && latestTurn.state !== "running"; - return isSettled ? null : latestTurn.turnId; + return unsettledTurnId(latestTurn, runningTurnId); } function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { @@ -593,6 +595,27 @@ function deriveActiveVisualResponseTurnIds(input: { }): ReadonlySet { const turnIds = new Set(); if (input.unsettledTurnId === null) { + if (input.isWorking) { + const latestUserIndex = lastUserMessageIndex(input.timelineEntries); + for (let index = input.timelineEntries.length - 1; index > latestUserIndex; index -= 1) { + const entry = input.timelineEntries[index]!; + if ( + !( + (entry.kind === "message" && entry.message.streaming) || + (entry.kind === "work" && + (entry.entry.toolLifecycleStatus === "inProgress" || + entry.entry.sourceActivityKind === "task.progress")) + ) + ) { + continue; + } + const turnId = timelineEntryTurnId(entry); + if (turnId !== null) { + turnIds.add(turnId); + break; + } + } + } return turnIds; } @@ -611,7 +634,7 @@ function deriveActiveVisualResponseTurnIds(input: { return turnIds; } -export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { +function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { return ( entry.toolLifecycleStatus === "inProgress" || (entry.toolLifecycleStatus === undefined && @@ -629,11 +652,11 @@ function deriveTurnFolds(input: { terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unfoldedTurnIds: ReadonlySet; + isWorking: boolean; }): ReadonlyMap { interface TurnGroup { entries: Array; terminalEntry: Extract | null; - hasStreamingMessage: boolean; /** * The user message that kicked the turn off. Entry timestamps alone * undercount the duration (the first entry appears only once the @@ -645,19 +668,19 @@ function deriveTurnFolds(input: { const groupsByTurnId = new Map(); let pendingUserBoundary: string | null = null; + let unkeyedResponseTurnId: TurnId | null = null; for (const entry of input.timelineEntries) { if (entry.kind === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; + unkeyedResponseTurnId = makeUnkeyedResponseTurnId(entry.message.id); continue; } - // Thinking is work, so it folds with the rest of it. A provider that - // interleaves a block with every tool call would otherwise leave dozens of - // "Thought" rows standing beside the "Worked for ..." summary. - // Nothing folds while the turn is live, which is when traces are watched. + // Reasoning reads inline between tools while the turn is live. Once it + // settles, fold that text with the rest of the turn's work. const turnId = entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "reasoning") - ? (entry.message.turnId ?? null) + ? (entry.message.turnId ?? unkeyedResponseTurnId) : entry.kind === "work" ? (entry.entry.turnId ?? null) : null; @@ -669,7 +692,6 @@ function deriveTurnFolds(input: { group = { entries: [], terminalEntry: null, - hasStreamingMessage: false, // Each user boundary starts at most one turn; a second turn after the // same user message (e.g. a steer-superseded continuation) falls back // to its own first entry. @@ -683,23 +705,31 @@ function deriveTurnFolds(input: { if (input.terminalAssistantMessageIds.has(entry.message.id)) { group.terminalEntry = entry; } - // A live turn is already excluded above, so only an answer still being - // written may hold a fold open. A thinking block stranded by a crashed - // provider keeps its streaming flag forever and must not. - if (entry.message.streaming && entry.message.role !== "reasoning") { - group.hasStreamingMessage = true; - } } } const foldsByAnchorEntryId = new Map(); + const activeUnkeyedResponseTurnId = input.isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { + if (isUnkeyedResponseTurnId(turnId) && turnId === activeUnkeyedResponseTurnId) { + continue; + } if (input.unfoldedTurnIds.has(turnId)) { continue; } - if (group.hasStreamingMessage) { + if ( + group.terminalEntry === null && + ((input.latestTurn?.turnId === turnId && input.latestTurn.state === "error") || + group.entries.some( + (entry) => + entry.kind === "work" && + (entry.entry.tone === "error" || workEntryDisplayIndicatesToolFailure(entry.entry)), + )) + ) { continue; } + // The turn lifecycle above decides whether work is still live. A provider + // error can leave its final assistant message marked as streaming forever. const hiddenEntryIds = new Set(); const terminalEntryIndex = group.terminalEntry ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id) @@ -719,6 +749,9 @@ function deriveTurnFolds(input: { } const isCompaction = entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"; + if (isCompaction) { + continue; + } const isSingleTrailingActivity = trailingEntryCount === 1 && entry.kind === "work" && @@ -726,12 +759,7 @@ function deriveTurnFolds(input: { // A thinking block after the answer folds with its turn rather than // trailing under it, which is what mobile already does. const isReasoning = entry.kind === "message" && entry.message.role === "reasoning"; - if ( - !isCompaction && - !isReasoning && - index > terminalEntryIndex && - !isSingleTrailingActivity - ) { + if (!isReasoning && index > terminalEntryIndex && !isSingleTrailingActivity) { continue; } // User input and subagent batches stay visible after their turn settles. @@ -746,17 +774,23 @@ function deriveTurnFolds(input: { if (hiddenEntryIds.size === 0) { continue; } - // A lone compaction row stays visible on its own; it only folds away as - // part of a turn that already folds other work. Thinking is the same: a - // question answered by thought alone keeps its "Thought" row - // rather than collapsing behind a "Worked for ..." that hides nothing else. + // A lone compaction row stays visible on its own. Reasoning folds when the + // turn has a final answer, even if its only other activity was a question. const hidesFoldableWork = group.entries.some( (entry) => hiddenEntryIds.has(entry.id) && !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction") && !(entry.kind === "message" && entry.message.role === "reasoning"), ); - if (!hidesFoldableWork) { + const hidesReasoningBeforeAnswer = + group.terminalEntry !== null && + group.entries.some( + (entry) => + hiddenEntryIds.has(entry.id) && + entry.kind === "message" && + entry.message.role === "reasoning", + ); + if (!hidesFoldableWork && !hidesReasoningBeforeAnswer) { continue; } @@ -792,10 +826,15 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - foldsByAnchorEntryId.set(firstHiddenEntry.id, { + const anchorEntry = + group.terminalEntry && + group.entries.indexOf(firstHiddenEntry) > group.entries.indexOf(group.terminalEntry) + ? group.terminalEntry + : firstHiddenEntry; + foldsByAnchorEntryId.set(anchorEntry.id, { turnId, - anchorEntryId: firstHiddenEntry.id, - createdAt: firstHiddenEntry.createdAt, + anchorEntryId: anchorEntry.id, + createdAt: anchorEntry.createdAt, hiddenEntryIds, label, }); @@ -837,18 +876,13 @@ function attachTrailingToolGroupsToAssistant( } // A thinking block can follow the answer (the next one starts before its // tool call); it is not another message in the conversation. - if (candidate.kind === "message" && candidate.message.role === "reasoning") { + if (candidate.kind === "reasoning-run") { continue; } if (candidate.kind === "message") { break; } - if ( - (candidate.kind === "work-toggle" || - (candidate.kind === "activity-group" && - candidate.entries.some((entry) => entry.kind === "work"))) && - candidate.turnId === turnId - ) { + if (candidate.kind === "work-toggle" && candidate.turnId === turnId) { hasTrailingToolGroup = true; lastTrailingWorkIndex = index; continue; @@ -857,15 +891,10 @@ function attachTrailingToolGroupsToAssistant( candidate.kind === "work" && candidate.groupedEntries.some((entry) => entry.turnId === turnId) ) { - if ( - !candidate.isExpandedToolGroup && - candidate.groupedEntries.some(workLogEntryIsToolLike) - ) { + if (candidate.groupedEntries.some(workLogEntryIsToolLike)) { hasTrailingToolGroup = true; } - if (hasTrailingToolGroup) { - lastTrailingWorkIndex = index; - } + if (hasTrailingToolGroup) lastTrailingWorkIndex = index; } } @@ -988,6 +1017,7 @@ export function deriveMessagesTimelineRows(input: { terminalAssistantMessageIds, latestTurn: input.latestTurn ?? null, unfoldedTurnIds: activeVisualResponseTurnIds, + isWorking: input.isWorking, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1094,17 +1124,12 @@ export function deriveMessagesTimelineRows(input: { if (activeWorkRow === null) return; nextRows.push(activeWorkRow); hasActivityRow ||= activeWorkRow.active; - if (!activeWorkRow.expanded || activeWorkRow.entry.agentSpawn) return; - nextRows.push( - expandedWorkGroupRow( - activeWorkRow.groupId, - activeWorkRow.createdAt, - activeWorkRow.groupedEntries, - ), - ); }; - let scannedActivityThrough = -1; + const reasoningStats = reasoningStatsByMessage(input.timelineEntries); + const displayKind = (message: ChatMessage) => + reasoningDisplayKind(message, reasoningStats.get(message.id) ?? { hasSummary: false }); + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { @@ -1135,50 +1160,6 @@ export function deriveMessagesTimelineRows(input: { continue; } - const activityTurnId = timelineEntryTurnId(timelineEntry); - if (index > scannedActivityThrough && activityTurnId && isActivityEntry(timelineEntry)) { - const entries = [timelineEntry]; - let cursor = index + 1; - while (cursor < input.timelineEntries.length) { - const next = input.timelineEntries[cursor]!; - if ( - !isActivityEntry(next) || - timelineEntryTurnId(next) !== activityTurnId || - collapsedEntryIds.has(next.id) || - foldsByAnchorEntryId.has(next.id) - ) - break; - entries.push(next); - cursor += 1; - } - scannedActivityThrough = cursor - 1; - if (entries.some((entry) => entry.kind === "message")) { - const active = - input.isWorking && - activityTurnId === unsettledTurnId && - cursor === input.timelineEntries.length && - !latestToolFailed && - (latestVisibleToolEntry === undefined || latestToolKeepsActivityLive); - const groupId = - timelineEntry.kind === "work" - ? workGroupId(timelineEntry.id, timelineEntry.entry) - : `activity-group:${timelineEntry.id}`; - nextRows.push({ - kind: "activity-group", - id: active ? LIVE_ACTIVITY_ROW_ID : groupId, - createdAt: timelineEntry.createdAt, - turnId: activityTurnId, - groupId, - entries, - expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - active, - }); - hasActivityRow ||= active; - index = cursor - 1; - continue; - } - } - if (activeWorkEntryIds.has(timelineEntry.id)) { continue; } @@ -1213,7 +1194,6 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: [timelineEntry.entry], - isExpandedToolGroup: false, }); continue; } @@ -1260,11 +1240,6 @@ export function deriveMessagesTimelineRows(input: { active: true, }); hasActivityRow = true; - if (expanded) { - nextRows.push( - expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), - ); - } } else if ( visibleGroupedEntries.length === 1 && workLogEntryIsToolLike(visibleGroupedEntries[0]!) @@ -1275,7 +1250,6 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, - isExpandedToolGroup: false, displayLabel: toolGroupAction(singleEntry) === "edit" ? summarizeToolGroup(visibleGroupedEntries) @@ -1318,6 +1292,7 @@ export function deriveMessagesTimelineRows(input: { turnId: timelineEntry.entry.turnId ?? null, groupId, hiddenCount: visibleGroupedEntries.length, + groupedEntries: visibleGroupedEntries, expanded, summary: usesSingleToolCallLabel ? singleToolCallLabel(singleEntry) @@ -1332,11 +1307,6 @@ export function deriveMessagesTimelineRows(input: { latestToolEntry !== undefined && workEntryDisplayIndicatesToolFailure(latestToolEntry), }); - if (expanded) { - nextRows.push( - expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), - ); - } } } index = cursor - 1; @@ -1353,6 +1323,39 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.message.role === "reasoning") { + const reasoningKind = displayKind(timelineEntry.message); + const messages = [timelineEntry.message]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const next = input.timelineEntries[cursor]; + if ( + next?.kind !== "message" || + next.message.role !== "reasoning" || + next.message.turnId !== timelineEntry.message.turnId || + displayKind(next.message) !== reasoningKind || + collapsedEntryIds.has(next.id) || + foldsByAnchorEntryId.has(next.id) + ) { + break; + } + messages.push(next.message); + cursor += 1; + } + nextRows.push({ + kind: "reasoning-run", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + reasoningKind, + messages, + }); + hasActivityRow ||= + entryBelongsToActiveTurn(timelineEntry, index) && + messages.some((message) => message.streaming); + index = cursor - 1; + continue; + } + const assistantResponseStillInProgress = timelineEntry.message.role === "assistant" && timelineEntry.message.turnId !== null && @@ -1505,6 +1508,8 @@ function replaceStreamingMessageRows( return null; } const replacements = new Map(); + let previousReasoningStats: Map | null = null; + let nextReasoningStats: Map | null = null; for (const [index, entry] of timelineEntries.entries()) { const previousEntry = previousEntries[index]!; if (entry === previousEntry) continue; @@ -1518,20 +1523,29 @@ function replaceStreamingMessageRows( } if (entry.message === previousEntry.message) continue; if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null; + if (entry.message.role === "reasoning") { + previousReasoningStats ??= reasoningStatsByMessage(previousEntries); + nextReasoningStats ??= reasoningStatsByMessage(timelineEntries); + const previousStats = previousReasoningStats.get(previousEntry.message.id); + const nextStats = nextReasoningStats.get(entry.message.id); + if ( + previousStats && + nextStats && + reasoningDisplayKind(previousEntry.message, previousStats) !== + reasoningDisplayKind(entry.message, nextStats) + ) { + return null; + } + } replacements.set(previousEntry.message, entry.message); } if (replacements.size === 0) return previous.rows; return previous.rows.map((row) => { - if (row.kind === "activity-group") { - if (!row.entries.some((entry) => entry.kind === "message" && replacements.has(entry.message))) - return row; + if (row.kind === "reasoning-run") { + if (!row.messages.some((message) => replacements.has(message))) return row; return { ...row, - entries: row.entries.map((entry) => { - if (entry.kind !== "message") return entry; - const message = replacements.get(entry.message); - return message ? { ...entry, message } : entry; - }), + messages: row.messages.map((message) => replacements.get(message) ?? message), }; } if (row.kind !== "message" && row.kind !== "assistant-meta") return row; @@ -1578,14 +1592,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean if (a.kind !== b.kind || a.id !== b.id) return false; switch (a.kind) { - case "activity-group": { - const group = b as typeof a; + case "reasoning-run": { + const run = b as typeof a; return ( - a.active === group.active && - a.expanded === group.expanded && - a.groupId === group.groupId && - a.entries.length === group.entries.length && - a.entries.every((entry, index) => entry === group.entries[index]) + a.reasoningKind === run.reasoningKind && + a.messages.length === run.messages.length && + a.messages.every((message, index) => message === run.messages[index]) ); } case "working": @@ -1625,9 +1637,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "work": { const bw = b as typeof a; return ( - a.isExpandedToolGroup === bw.isExpandedToolGroup && - a.displayLabel === bw.displayLabel && - Equal.equals(a.groupedEntries, bw.groupedEntries) + a.displayLabel === bw.displayLabel && Equal.equals(a.groupedEntries, bw.groupedEntries) ); } @@ -1650,6 +1660,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.turnId === bw.turnId && a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && + Equal.equals(a.groupedEntries, bw.groupedEntries) && a.expanded === bw.expanded && a.summary === bw.summary && a.summaryKind === bw.summaryKind && diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 016e621be1dd..c0ffa5345831 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -304,6 +304,192 @@ describe("MessagesTimeline", () => { expect(markup).toContain('aria-label="Next turn"'); }); + it("renders expanded tool groups in a bounded tool-call list", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const labels = ["Alpha sync", "Beta sync", "Gamma sync"]; + const entries = labels.map((label, index) => ({ + id: `entry-work-${index}`, + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: `work-${index}`, + createdAt: MESSAGE_CREATED_AT, + toolCallId: `call-${index}`, + label, + tone: "tool" as const, + toolLifecycleStatus: "completed" as const, + }, + })); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + const toggle = renderer!.root + .findAllByType("button") + .find((button) => button.props["aria-expanded"] === false); + expect(toggle).toBeDefined(); + await act(() => toggle!.props.onClick()); + const scrollLists = renderer!.root.findAll( + (node) => + typeof node.type === "string" && + typeof node.props["data-class-name"] === "string" && + node.props["data-class-name"].includes("max-h-[min(18rem,50dvh)]"), + ); + expect(scrollLists).toHaveLength(1); + const markup = JSON.stringify(renderer!.toJSON()); + for (const label of labels) expect(markup).toContain(label); + } finally { + await act(() => renderer?.unmount()); + } + }); + + it("opens a long legacy reasoning trace on demand", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const turnId = TurnId.make("reasoning-turn"); + const thought = { + id: "thought-entry", + kind: "message" as const, + createdAt: MESSAGE_CREATED_AT, + message: { + id: MessageId.make("thought"), + role: "reasoning" as const, + text: "Investigating a long reasoning trace.\n\n".repeat(100), + turnId, + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + streaming: false, + }, + }; + const answer = buildAssistantTimelineEntry("Final answer"); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + const row = renderer!.root.findByProps({ + "data-timeline-row-kind": "reasoning-run", + "data-message-role": "reasoning", + }); + const toggle = row.findByType("button"); + expect(toggle.props["aria-expanded"]).toBe(false); + expect(JSON.stringify(renderer!.toJSON())).not.toContain( + "Investigating a long reasoning trace.", + ); + await act(() => toggle.props.onClick()); + expect(row.findByType("button").props["aria-expanded"]).toBe(true); + expect(row.findByProps({ "data-reasoning-scroll": true }).props.className).toContain( + "max-h-96", + ); + expect(JSON.stringify(renderer!.toJSON())).toContain("Investigating a long reasoning trace."); + } finally { + await act(() => renderer?.unmount()); + } + }); + + it("renders expanded agent-spawn entries within the tool-call list", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const turnId = TurnId.make("turn-spawn"); + const entries = [ + { + id: "entry-user", + kind: "message" as const, + createdAt: MESSAGE_CREATED_AT, + message: { + id: MessageId.make("message-user"), + role: "user" as const, + text: "Fan out.", + turnId, + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + streaming: false, + }, + }, + { + id: "entry-spawn", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "spawn-1", + createdAt: MESSAGE_CREATED_AT, + turnId, + label: "Ran 2 subagents", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + agentSpawn: { workflowId: null, agentTaskIds: ["agent-a", "agent-b"] }, + }, + }, + { + id: "entry-tool", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "tool-1", + createdAt: MESSAGE_CREATED_AT, + turnId, + toolCallId: "call-tool-1", + label: "Run command", + tone: "tool" as const, + itemType: "command_execution" as const, + command: "echo hi", + toolLifecycleStatus: "inProgress" as const, + }, + }, + ]; + const props = { + ...buildProps(), + isWorking: true, + activeTurnStartedAt: MESSAGE_CREATED_AT, + runningTurnId: turnId, + latestTurn: { + turnId, + state: "running" as const, + startedAt: MESSAGE_CREATED_AT, + completedAt: null, + }, + }; + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + const expandToggle = renderer!.root + .findAllByType("button") + .find((button) => button.props["aria-expanded"] === false); + expect(expandToggle).toBeDefined(); + await act(() => expandToggle!.props.onClick()); + + const expandedRows = renderer!.root.findAllByProps({ + isExpandedToolGroupEntry: true, + }); + expect(expandedRows.length).toBeGreaterThan(0); + expect( + renderer!.root.findAll( + (node) => + typeof node.type === "string" && + typeof node.props["data-class-name"] === "string" && + node.props["data-class-name"].includes("max-h-[min(18rem,50dvh)]"), + ), + ).toHaveLength(1); + } finally { + await act(() => renderer?.unmount()); + } + }); + // Expanding history uses this suite's existing test renderer, deprecated in // React 19. Migrate these interaction tests together when a DOM test setup is added. it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( @@ -1656,97 +1842,54 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it.each( - ( - [ - [ - "**Viewing image first** with *care*, ~~old~~ `code` and [context](https://example.com)", - "Viewing image first with care, old code and context", - 1, - ], - ["first paragraph\n\nsecond paragraph", "first paragraph second paragraph", 0], - ["- first\n- second", "first second", 0], - ["first \nsecond", "first second", 0], - ["![image description](image.png)", "image description", 0], - ["![](image.png)", "Thought", 0], - ["---", "Thought", 0], - ] as const - ).flatMap(([markdown, expected, strongCount]) => - [false, true].map((streaming) => ({ - markdown, - expected, - strongCount, - streaming, - })), - ), - )( - "shows a plain thought preview for $markdown, streaming=$streaming", - async ({ markdown, expected, strongCount, streaming }) => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("requestAnimationFrame", () => 0); - vi.stubGlobal("cancelAnimationFrame", () => {}); - const turnId = TurnId.make("turn-thought"); - const thought = buildAssistantTimelineEntry(markdown); - let renderer: ReactTestRenderer | undefined; - try { - await act(() => { - renderer = create( - , - ); - }); - await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick()); - const text = renderer!.root.findByProps({ - className: "relative min-w-0 flex-1 truncate text-secondary-label", - }); - const preview = text.parent!; - expect( - text - .findAll(() => true) - .flatMap((node) => node.children) - .filter((child) => typeof child === "string") - .join(""), - ).toBe( - (streaming && expected === "Thought" ? "Thinking" : expected).repeat(streaming ? 2 : 1), + it("renders raw reasoning blocks with their original spacing when opened", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const turnId = TurnId.make("turn-raw-blocks"); + const entries = [ + { id: "summary-entry", messageId: "reasoning:summary:item", text: "Checking the API." }, + { id: "raw-first", messageId: "reasoning:raw:first", text: "**first block**" }, + { id: "raw-second", messageId: "reasoning:raw:second", text: "## second block" }, + ].map(({ id, messageId, text }) => { + const entry = buildAssistantTimelineEntry(text); + return { + ...entry, + id, + message: { + ...entry.message, + id: MessageId.make(messageId), + role: "reasoning" as const, + turnId, + }, + }; + }); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , ); - expect( - preview.findAll((node) => - ["strong", "em", "del", "code", "a"].includes(String(node.type)), - ), - ).toHaveLength(0); - await act(() => preview.props.onClick()); - expect(renderer!.root.findAllByType("strong")).toHaveLength(strongCount); - await act(() => preview.props.onClick()); - expect(renderer!.root.findAllByType("strong")).toHaveLength(0); - } finally { - await act(() => renderer?.unmount()); - } - }, - ); + }); + const toggle = renderer!.root + .findAllByType("button") + .find((button) => button.props["aria-expanded"] === false); + expect(toggle).toBeDefined(); + await act(() => toggle!.props.onClick()); + const details = renderer!.root.findByProps({ "data-reasoning-scroll": true }); + expect(details.props.className).toContain("gap-3"); + expect(details.findAllByType("strong")).toHaveLength(1); + expect(details.findAllByType("h2")).toHaveLength(1); + } finally { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); it("renders initial thinking as the shared live activity row", () => { const turnId = TurnId.make("turn-live"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 505f64af3640..310f105ed1e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,10 +29,9 @@ import { resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, - summarizeToolGroup, - omitSupersededLifecycleMarkers, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import type { AgentPanelModel, RuntimeSubagent, @@ -63,6 +62,7 @@ import { use, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -102,9 +102,6 @@ import { } from "../../lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import type { Root, RootContent } from "mdast"; import { T3Wordmark } from "../T3Wordmark"; import { BotIcon, @@ -140,7 +137,6 @@ import { Button } from "../ui/button"; import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; -import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { buildAttachmentVideoAsset, buildAttachmentVideoPreview, @@ -180,7 +176,6 @@ import { deriveUnsettledTurnId, type MessagesTimelineRowsProjection, liveWorkEntryLabel, - workEntryIsActiveTurnActivity, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -201,7 +196,6 @@ import { type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, type TimelineLatestTurn, - type WorkGroupScrollAnchor, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -315,12 +309,18 @@ interface TimelineRowActivityState { backgroundWorktreeSetup: WorktreeSetupSnapshot | null; } +function messageIsLiveStreaming(message: ChatMessage, activity: TimelineRowActivityState): boolean { + if (!message.streaming) return false; + if (message.turnId === null || activity.unsettledTurnId === null) return activity.isWorking; + return message.turnId === activity.unsettledTurnId; +} + const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); interface WorkGroupViewState { - scrollPositions: Map; expandedEntries: Set; + scrollPositions: Map; } const WorkGroupViewCtx = createContext<{ @@ -583,13 +583,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ current.has(turnId) ? current : new Set([...current, turnId]), ); }, []); - // Nested tool state shares the bounded thread-position cache. + // Expanded tool-entry disclosures share the thread-position cache. const workGroupViewState = useMemo( - () => - rememberedPosition?.disclosures?.workGroupState ?? { - scrollPositions: new Map(), - expandedEntries: new Set(), - }, + () => ({ + expandedEntries: + rememberedPosition?.disclosures?.workGroupState?.expandedEntries ?? new Set(), + scrollPositions: + rememberedPosition?.disclosures?.workGroupState?.scrollPositions ?? new Map(), + }), [listIdentityKey, rememberedPosition], ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); @@ -1654,7 +1655,6 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { - const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; const isExpandedToolGroupHeader = (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded); @@ -1663,24 +1663,21 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time className={cn( // Commentary (non-terminal assistant) rows carry no metadata row, so // they sit closer to the work that follows them. - isExpandedToolGroup - ? "pb-1" - : isExpandedToolGroupHeader - ? "pb-0" - : row.kind === "turn-fold" || row.kind === "working" - ? "pb-1.5" - : (row.kind === "message" && - row.message.role === "assistant" && - !row.showAssistantMeta) || - (row.kind === "message" && row.message.role === "reasoning") || - row.kind === "work" || - row.kind === "work-live" || - row.kind === "work-toggle" || - row.kind === "activity-group" || - row.kind === "thinking" || - row.kind === "worktree-setup" - ? "pb-2" - : "pb-4", + isExpandedToolGroupHeader + ? "pb-0" + : row.kind === "turn-fold" || row.kind === "working" + ? "pb-1.5" + : (row.kind === "message" && + row.message.role === "assistant" && + !row.showAssistantMeta) || + row.kind === "reasoning-run" || + row.kind === "work" || + row.kind === "work-live" || + row.kind === "work-toggle" || + row.kind === "thinking" || + row.kind === "worktree-setup" + ? "pb-2" + : "pb-4", (row.kind === "message" && row.message.role === "assistant") || row.kind === "assistant-meta" ? "group/assistant" @@ -1689,20 +1686,28 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time data-timeline-row-id={row.id} data-timeline-row-kind={row.kind} data-message-id={ - row.kind === "message" || row.kind === "assistant-meta" ? row.message.id : undefined + row.kind === "message" || row.kind === "assistant-meta" + ? row.message.id + : row.kind === "reasoning-run" + ? row.messages[0]?.id + : undefined + } + data-message-role={ + row.kind === "message" + ? row.message.role + : row.kind === "reasoning-run" + ? "reasoning" + : undefined } - data-message-role={row.kind === "message" ? row.message.role : undefined} > {row.kind === "work" ? ( ) : null} {row.kind === "work-live" ? : null} - {row.kind === "activity-group" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "context-compaction" ? : null} @@ -1710,9 +1715,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} - {row.kind === "message" && row.message.role === "reasoning" ? ( - - ) : null} + {row.kind === "reasoning-run" ? : null} {row.kind === "assistant-meta" ? : null} {row.kind === "proposed-plan" ? : null} {row.kind === "working" ? : null} @@ -2350,7 +2353,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const activity = use(TimelineRowActivityCtx); + const isStreaming = messageIsLiveStreaming(row.message, activity); + const messageText = + row.message.text.trim().length > 0 ? row.message.text : isStreaming ? "" : "(empty response)"; return ( <> @@ -2367,7 +2373,7 @@ function AssistantTimelineRow({ row }: { row: Extract; -}) { - const ctx = use(TimelineRowCtx); - const work = omitSupersededLifecycleMarkers( - row.entries.flatMap((entry) => - entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) - ? [entry.entry] - : [], - ), - (entry) => entry, - ); - const thoughtCount = row.entries.filter((entry) => entry.kind === "message").length; - const lastThoughtIndex = row.entries.findLastIndex((entry) => entry.kind === "message"); - const trailingWork = omitSupersededLifecycleMarkers( - row.entries - .slice(lastThoughtIndex + 1) - .flatMap((entry) => - entry.kind === "work" && workEntryIsVisibleInGroup(entry.entry, row.active) - ? [entry.entry] - : [], - ), - (entry) => entry, - ); - const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1); - const thinking = row.active && liveWork === undefined; - const iconWork = row.active ? liveWork : work.at(-1); - const failed = iconWork !== undefined && workEntryDisplayIndicatesToolFailure(iconWork); - const label = row.active - ? liveWork - ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true) - : "Thinking" - : work.length > 0 - ? summarizeToolGroup(work) - : `Thought${thoughtCount > 1 ? ` (×${thoughtCount})` : ""}`; - const details: ReactNode[] = []; - if (row.expanded) { - for (let index = 0; index < row.entries.length; index += 1) { - const entry = row.entries[index]!; - if (entry.kind === "work") { - const entries = [entry.entry]; - while (row.entries[index + 1]?.kind === "work") { - const next = row.entries[++index]!; - if (next.kind === "work") entries.push(next.entry); - } - details.push( - entry)} - isExpandedToolGroup - />, - ); - } else { - const messages = [entry.message]; - while (row.entries[index + 1]?.kind === "message") { - const next = row.entries[++index]!; - if (next.kind === "message") messages.push(next.message); - } - details.push( - 0} - />, - ); - } - } - } - return ( -
- - {row.expanded ?
{details}
: null} -
- ); -} - function ThinkingTimelineRow() { const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx); // Reserve the activity row during setup so the handoff keeps the same height. @@ -2684,156 +2593,103 @@ function ThinkingTimelineRow() { ); } -function remarkThoughtPreview(fallback: string) { - return (tree: Root) => { - const plainText = (node: Root | RootContent): string => { - if (node.type === "html" || node.type === "definition") return ""; - if ("alt" in node) return node.alt ?? ""; - if ("value" in node) return node.value; - if ("children" in node) { - const separator = ["root", "blockquote", "list", "listItem", "table", "tableRow"].includes( - node.type, - ) - ? " " - : ""; - return node.children.map(plainText).join(separator); - } - return node.type === "break" ? " " : ""; - }; - tree.children = [ - { type: "text", value: plainText(tree).replace(/\s+/g, " ").trim() || fallback }, - ]; - }; -} +/** Summaries read like narration; raw traces remain available on demand. */ +const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ + row, +}: { + row: Extract; +}) { + if (row.reasoningKind === "raw") { + return ; + } + return ; +}); -/** - * Thinking inside a tool group has its own disclosure, preserved across recycling. - * A group whose row already reads "Thought" (no visible tool) skips the header. - */ -function ReasoningTraceBlock({ - anchorKey, - messages, - live, - showHeader, +function SummaryReasoningTimelineRow({ + row, }: { - anchorKey: string; - messages: ReadonlyArray; - live: boolean; - showHeader: boolean; + row: Extract; }) { const ctx = use(TimelineRowCtx); - const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx); - const first = messages[0]!; - const expanded = !showHeader || ctx.expandedReasoningMessageIds.has(first.id); - const streaming = - live && - messages.some((reasoningMessage) => reasoningMessage.streaming) && - isWorking && - first.turnId !== null && - first.turnId === unsettledTurnId; - if ( - messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) && - !streaming - ) { - return null; - } - const label = streaming ? "Thinking" : "Thought"; - const collapsedPreview = messages.find((message) => message.text.trim().length > 0)?.text.trim(); - const headerText = expanded ? ( - label - ) : ( - - {collapsedPreview ?? label} - - ); + const activity = use(TimelineRowActivityCtx); + const detailsId = useId(); + const messageId = row.messages[0]!.id; + const expanded = ctx.expandedReasoningMessageIds.has(messageId); + const chunks = row.messages.map((message) => message.text.trim()).filter(Boolean); + const text = chunks.join(" "); + const streaming = row.messages.some((message) => messageIsLiveStreaming(message, activity)); + if (text.length === 0) return streaming ? : null; + const longSummary = + text.length > 2_000 || + chunks.reduce((count, chunk) => count + chunk.split("\n").length, 0) > 25; + const preview = chunks.slice(0, 25).join(" ").slice(0, 2_000).split("\n").slice(0, 25).join("\n"); return ( -
- {showHeader ? ( +
+
+ +
+ {longSummary ? ( ) : null} - {expanded ? ( -
- {messages.map((reasoningMessage) => ( - - ))} -
- ) : null}
); } -/** - * A provider's thinking trace. Collapsed by default: reasoning is context for - * the answer, not the answer. The open/closed flag lives on the list so it - * survives row recycling in the virtualizer. - */ -const ReasoningTimelineRow = memo(function ReasoningTimelineRow({ +function RawReasoningTimelineRow({ row, }: { - row: Extract; + row: Extract; }) { const ctx = use(TimelineRowCtx); - const { message } = row; - const expanded = ctx.expandedReasoningMessageIds.has(message.id); - const { onToggleReasoning } = ctx; - const toggle = useCallback(() => { - onToggleReasoning(message.id, !expanded, row.id); - }, [expanded, message.id, row.id, onToggleReasoning]); - - if (message.text.trim().length === 0) { - return null; - } - + const activity = use(TimelineRowActivityCtx); + const detailsId = useId(); + const messageId = row.messages[0]!.id; + const expanded = ctx.expandedReasoningMessageIds.has(messageId); + const streaming = row.messages.some((message) => messageIsLiveStreaming(message, activity)); + const label = `Thought${row.messages.length > 1 ? ` (×${row.messages.length})` : ""}`; return ( -
+
{expanded ? ( -
- +
+ {row.messages.map((message) => ( + + ))}
) : null}
); -}); +} function CompactingLabel() { return ( @@ -2906,43 +2773,29 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- -/** Renders standalone activity or one bounded, virtualized expanded tool group. */ +/** Renders standalone activity; expanded entries are separate outer-list rows. */ const WorkGroupSection = memo(function WorkGroupSection({ anchorKey, disclosureAnchorKey = anchorKey, groupedEntries, - isExpandedToolGroup, displayLabel, }: { anchorKey: string; disclosureAnchorKey?: string; groupedEntries: Extract["groupedEntries"]; - isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { - const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); + const { workspaceRoot, onToggleWorkEntry } = use(TimelineRowCtx); const onToggleStandaloneEntry = useCallback( (collapsed: boolean) => onToggleWorkEntry(disclosureAnchorKey, collapsed), [disclosureAnchorKey, onToggleWorkEntry], ); const nonEmptyEntries = useMemo( - () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), - [groupedEntries, isExpandedToolGroup], + () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry)), + [groupedEntries], ); if (nonEmptyEntries.length === 0) return null; - if (isExpandedToolGroup) { - return ( - - ); - } - return (
@@ -3236,39 +3089,50 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} - > - - {label} - - {getQuestionAnswerPreview(row.entry.questionAnswer)} +
+ + ) : ( + label + ) + } + iconName={workEntryIconName(row.entry)} + toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} + failed={failed} + active={row.active} + /> + + {row.expanded ? ( + + ) : null} +
); } @@ -3316,26 +3180,37 @@ function WorkGroupToggleTimelineRow({ }) { const ctx = use(TimelineRowCtx); return ( - + {row.expanded ? ( + -
- {row.summary} - - + ) : null} +
); } diff --git a/docs/user/activity-groups.md b/docs/user/activity-groups.md new file mode 100644 index 000000000000..4e3a4ab4e0d0 --- /dev/null +++ b/docs/user/activity-groups.md @@ -0,0 +1,10 @@ +# Activity groups + +On web and desktop, expand a completed turn in chat to see the agent's reasoning +and tool calls in order. Reasoning summaries read like the agent's other +text. When a provider also exposes a full reasoning trace, open Thought +to read it. Expand a tool summary to see its calls in a bounded list. + +Open a tool call to inspect its details. Those details stay open when you scroll +away and back, or collapse and reopen the turn in the same thread view. +Select the tool call again to close its details. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index dd6113ad6426..40bf8cb08e8c 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./thread-message-presentation": { + "types": "./src/threadMessagePresentation.ts", + "default": "./src/threadMessagePresentation.ts" + }, "./device/stream": { "types": "./src/device/stream.ts", "default": "./src/device/stream.ts" diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index dbee48d7808c..7c96d6f8a5da 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -575,6 +575,71 @@ describe("applyThreadDetailEvent", () => { } }); + it("updates a streamed assistant message to a progress summary", () => { + const messageId = MessageId.make("assistant:progress-part"); + const thread = { + ...baseThread, + latestTurn: { + turnId: TurnId.make("turn-progress"), + state: "running" as const, + requestedAt: baseThread.createdAt, + startedAt: baseThread.createdAt, + completedAt: null, + assistantMessageId: messageId, + }, + checkpoints: [ + { + turnId: TurnId.make("turn-progress"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-progress"), + status: "ready" as const, + files: [], + assistantMessageId: messageId, + completedAt: baseThread.updatedAt, + }, + ], + messages: [ + { + id: messageId, + role: "assistant" as const, + text: "Checking the reviews.", + turnId: TurnId.make("turn-progress"), + streaming: false, + createdAt: baseThread.createdAt, + updatedAt: baseThread.updatedAt, + }, + ], + }; + const result = applyThreadDetailEvent(thread, { + ...baseEventFields, + sequence: 6, + occurredAt: baseThread.updatedAt, + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId, + role: "reasoning", + text: "", + turnId: TurnId.make("turn-progress"), + streaming: false, + createdAt: baseThread.createdAt, + updatedAt: baseThread.updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind !== "updated") return; + expect(result.thread.messages[0]).toMatchObject({ + id: messageId, + role: "reasoning", + text: "Checking the reviews.", + }); + expect(result.thread.latestTurn?.assistantMessageId).toBeNull(); + expect(result.thread.checkpoints[0]?.assistantMessageId).toBeNull(); + }); + it("appends a new message", () => { const result = applyThreadDetailEvent(baseThread, { ...baseEventFields, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 101bb34fba91..8a4515be2d00 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -396,6 +396,7 @@ export function applyThreadDetailEvent( found = true; return { ...entry, + role: message.role, text: message.streaming ? `${entry.text}${message.text}` : message.text.length > 0 @@ -409,6 +410,15 @@ export function applyThreadDetailEvent( }; }); if (!found) messages.push(message); + const reclassifiedAssistant = + message.role === "reasoning" && + thread.messages.some((entry) => entry.id === message.id && entry.role === "assistant"); + const replacementAssistantMessageId = + reclassifiedAssistant && message.turnId !== null + ? (messages.findLast( + (entry) => entry.role === "assistant" && entry.turnId === message.turnId, + )?.id ?? null) + : null; // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn @@ -420,7 +430,7 @@ export function applyThreadDetailEvent( thread.session?.status === "running" && thread.session.activeTurnId === event.payload.turnId; const settlesTurn = !event.payload.streaming && !turnStillRunning; - const latestTurn = reuseLatestTurn( + const projectedLatestTurn = reuseLatestTurn( thread.latestTurn, event.payload.role === "assistant" && event.payload.turnId !== null && @@ -454,7 +464,7 @@ export function applyThreadDetailEvent( // Rebind checkpoint assistant message IDs for assistant messages. The // helper hands back the same array when the entry is already bound. - const checkpoints = + const projectedCheckpoints = event.payload.role === "assistant" && event.payload.turnId !== null ? rebindCheckpointAssistantMessage( thread.checkpoints, @@ -462,6 +472,17 @@ export function applyThreadDetailEvent( event.payload.messageId, ) : thread.checkpoints; + const latestTurn = + reclassifiedAssistant && projectedLatestTurn?.assistantMessageId === message.id + ? { ...projectedLatestTurn, assistantMessageId: replacementAssistantMessageId } + : projectedLatestTurn; + const checkpoints = reclassifiedAssistant + ? Arr.map(projectedCheckpoints, (entry) => + entry.assistantMessageId === message.id + ? { ...entry, assistantMessageId: replacementAssistantMessageId } + : entry, + ) + : projectedCheckpoints; return { kind: "updated", diff --git a/packages/client-runtime/src/threadMessagePresentation.ts b/packages/client-runtime/src/threadMessagePresentation.ts new file mode 100644 index 000000000000..9859911975fe --- /dev/null +++ b/packages/client-runtime/src/threadMessagePresentation.ts @@ -0,0 +1,36 @@ +import { TurnId } from "@t3tools/contracts"; + +export function unkeyedResponseTurnId(messageId: string): TurnId { + return TurnId.make(`unkeyed-response:${messageId}`); +} + +export function isUnkeyedResponseTurnId(turnId: TurnId): boolean { + return turnId.startsWith("unkeyed-response:"); +} + +export function unsettledTurnId( + latestTurn: { turnId: TurnId; completedAt: string | null; state: string } | null, + runningTurnId: TurnId | null = null, +): TurnId | null { + if (runningTurnId !== null) return runningTurnId; + if (!latestTurn) return null; + return latestTurn.completedAt !== null && latestTurn.state !== "running" + ? null + : latestTurn.turnId; +} + +export function reasoningDisplayKind( + message: { id: string; text: string }, + hasSummary: boolean, +): "summary" | "raw" { + if (isReasoningSummaryMessage(message)) { + return "summary"; + } + return hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25 + ? "raw" + : "summary"; +} + +export function isReasoningSummaryMessage(message: { id: string }): boolean { + return message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:"); +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3e323e4964d5..b223eeaf9cbe 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -560,9 +560,8 @@ export const OrchestrationProject = Schema.Struct({ }); export type OrchestrationProject = typeof OrchestrationProject.Type; -/** `reasoning` carries a provider's thinking trace: a reasoning summary, or - * the raw chain of thought when the model exposes one. It is a sibling of the - * assistant text it precedes, not a replacement for it. */ +/** `reasoning` carries a provider's thinking summary, raw trace, or OpenCode + * continuation text. It sits alongside the assistant answer. */ export const OrchestrationMessageRole = Schema.Literals([ "user", "assistant", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 309b61935485..4e817cb2879d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -82,6 +82,7 @@ export type RuntimeItemStatus = typeof RuntimeItemStatus.Type; const RuntimeContentStreamKind = Schema.Literals([ "assistant_text", + "assistant_progress_text", "reasoning_text", "reasoning_summary_text", "plan_text", @@ -434,6 +435,7 @@ export const ItemLifecyclePayload = Schema.Struct({ status: Schema.optional(RuntimeItemStatus), title: Schema.optional(TrimmedNonEmptyStringSchema), detail: Schema.optional(TrimmedNonEmptyStringSchema), + presentation: Schema.optional(Schema.Literal("progress")), toolSurface: Schema.optional(ToolActivitySurface), toolIcon: Schema.optional(ToolActivityIcon), toolSource: Schema.optional(ToolActivitySource),