From 932624bc8adc56b177943377914a8bd9bc865dd0 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 22 Sep 2026 22:45:17 +0200 Subject: [PATCH 01/43] fix(web): show reasoning summaries inline --- .../chat/MessagesTimeline.logic.test.ts | 465 +++++---------- .../components/chat/MessagesTimeline.logic.ts | 302 +++++----- .../components/chat/MessagesTimeline.test.tsx | 410 +++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 532 ++++++------------ docs/user/activity-groups.md | 10 + 5 files changed, 778 insertions(+), 941 deletions(-) create mode 100644 docs/user/activity-groups.md diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..3f87e606c268 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,146 @@ 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, rawCharacters: 5_000, rawLines: 0 }, + ), + ).toBe("summary"); + expect( + reasoningDisplayKind( + { ...base, id: MessageId.make("reasoning:raw:item"), text: "short" }, + { hasSummary: true, rawCharacters: 5, rawLines: 0 }, + ), + ).toBe("raw"); + expect( + reasoningDisplayKind( + { ...base, id: MessageId.make("reasoning:raw:item"), text: "short" }, + { hasSummary: false, rawCharacters: 5, rawLines: 0 }, + ), + ).toBe("summary"); + }); + + 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, - } 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.reasoningKind), + ).toEqual(["summary", "raw"]); }); - it("updates the same collapsed row as tools and thoughts alternate", () => { + it("renders reasoning between adjacent tool groups in turn order", () => { 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.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, - turnDiffSummaries: [], - supportsConversationRollback: false, - }); - expect(rows.map((row) => row.id)).toEqual(thoughts.map((entry) => entry.id)); - }); - - it("keeps a thought-only turn out of the work fold", () => { - const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"); - const rows = deriveMessagesTimelineRows({ - timelineEntries: [thought, answerEntry("assistant-entry", "2026-01-01T00:00:02Z", "turn-1")], - 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, - }); - }); - - 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"); + 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: [answer, thought], + timelineEntries: [first, ...tools, second, answer], + expandedTurnIds: new Set([TurnId.make("turn-1")]), 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([ + "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("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, - }, - }; - }); + 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: [thought, ...tools], + timelineEntries: [first], 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: [thought, ...tools], - active: true, - }); - const settled = 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" } }, - ], - isWorking: false, - activeTurnStartedAt: null, - }); - expect(settled.map((row) => row.kind)).toEqual(["activity-group"]); + 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.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("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(details.find((row) => row.kind === "activity-group")).toMatchObject({ - entries, - expanded: true, + expect( + expanded + .filter((row) => row.kind === "reasoning-run" || row.kind === "message") + .map((row) => row.id), + ).toEqual([first.id, second.id, answer.id]); + }); + + it("leaves a thought-only turn visible", () => { + 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(deriveMessagesTimelineRows(input)).toEqual(rows); + expect(rows.map((row) => row.kind)).toEqual(["reasoning-run", "message"]); }); it("still folds a lone trailing tool call when a thought follows the answer", () => { @@ -2794,7 +2610,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 +2660,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); @@ -3352,16 +3166,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 +3226,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 +3240,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 +3326,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 +3359,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 +3470,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..34b4a375799b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -110,35 +110,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 +285,55 @@ export type TimelineLatestTurn = Pick< const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; -type ActivityEntry = Extract; +interface ReasoningTurnStats { + hasSummary: boolean; + rawCharacters: number; + rawLines: number; +} -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"; +function reasoningStatsByTurn( + entries: ReadonlyArray, +): Map { + const stats = new Map(); + for (const entry of entries) { + if (entry.kind !== "message" || entry.message.role !== "reasoning") continue; + const message = entry.message; + const key = message.turnId ?? message.id; + const turn = stats.get(key) ?? { hasSummary: false, rawCharacters: 0, rawLines: 0 }; + if (message.id.startsWith("reasoning:summary:")) { + turn.hasSummary = true; + } else { + turn.rawCharacters += message.text.length; + if (turn.rawCharacters <= 4_000 && turn.rawLines <= 24) { + turn.rawLines += message.text.split("\n", 26).length - 1; + } + } + stats.set(key, turn); + } + return stats; +} + +export function reasoningDisplayKind( + message: ChatMessage, + turn: ReasoningTurnStats, +): "summary" | "raw" { + if (message.id.startsWith("reasoning:summary:")) return "summary"; + return turn.hasSummary || turn.rawCharacters > 4_000 || turn.rawLines > 24 ? "raw" : "summary"; } 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 +353,7 @@ export type MessagesTimelineRow = turnId?: TurnId | null; groupId: string; hiddenCount: number; + groupedEntries: WorkLogEntry[]; expanded: boolean; summary: string; summaryKind: ToolGroupSummaryKind; @@ -475,18 +467,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({ @@ -650,10 +655,8 @@ function deriveTurnFolds(input: { pendingUserBoundary = entry.message.createdAt; 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") @@ -747,9 +750,8 @@ function deriveTurnFolds(input: { 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. + // part of a turn that already folds other work. Reasoning alone also stays + // visible rather than sitting behind a fold with no other work. const hidesFoldableWork = group.entries.some( (entry) => hiddenEntryIds.has(entry.id) && @@ -837,18 +839,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 +854,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; } } @@ -1094,17 +1086,19 @@ 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 reasoningTurns = reasoningStatsByTurn(input.timelineEntries); + const displayKind = (message: ChatMessage) => + reasoningDisplayKind( + message, + reasoningTurns.get(message.turnId ?? message.id) ?? { + hasSummary: false, + rawCharacters: message.text.length, + rawLines: message.text.split("\n", 26).length - 1, + }, + ); + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { @@ -1135,50 +1129,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 +1163,6 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: [timelineEntry.entry], - isExpandedToolGroup: false, }); continue; } @@ -1260,11 +1209,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 +1219,6 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, - isExpandedToolGroup: false, displayLabel: toolGroupAction(singleEntry) === "edit" ? summarizeToolGroup(visibleGroupedEntries) @@ -1318,6 +1261,7 @@ export function deriveMessagesTimelineRows(input: { turnId: timelineEntry.entry.turnId ?? null, groupId, hiddenCount: visibleGroupedEntries.length, + groupedEntries: visibleGroupedEntries, expanded, summary: usesSingleToolCallLabel ? singleToolCallLabel(singleEntry) @@ -1332,11 +1276,6 @@ export function deriveMessagesTimelineRows(input: { latestToolEntry !== undefined && workEntryDisplayIndicatesToolFailure(latestToolEntry), }); - if (expanded) { - nextRows.push( - expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), - ); - } } } index = cursor - 1; @@ -1353,6 +1292,37 @@ 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); + index = cursor - 1; + continue; + } + const assistantResponseStillInProgress = timelineEntry.message.role === "assistant" && timelineEntry.message.turnId !== null && @@ -1505,6 +1475,8 @@ function replaceStreamingMessageRows( return null; } const replacements = new Map(); + let previousReasoningTurns: Map | null = null; + let nextReasoningTurns: Map | null = null; for (const [index, entry] of timelineEntries.entries()) { const previousEntry = previousEntries[index]!; if (entry === previousEntry) continue; @@ -1518,20 +1490,31 @@ function replaceStreamingMessageRows( } if (entry.message === previousEntry.message) continue; if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null; + if (entry.message.role === "reasoning") { + previousReasoningTurns ??= reasoningStatsByTurn(previousEntries); + nextReasoningTurns ??= reasoningStatsByTurn(timelineEntries); + const previousTurn = previousReasoningTurns.get( + previousEntry.message.turnId ?? previousEntry.message.id, + ); + const nextTurn = nextReasoningTurns.get(entry.message.turnId ?? entry.message.id); + if ( + previousTurn && + nextTurn && + reasoningDisplayKind(previousEntry.message, previousTurn) !== + reasoningDisplayKind(entry.message, nextTurn) + ) { + 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 +1561,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 +1606,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 +1629,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..53a53723933e 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-[min(18rem,50dvh)]", + ); + 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,141 @@ 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 reasoning markdown with the same typography as assistant text", () => { + const turnId = TurnId.make("turn-thought"); + const thought = buildAssistantTimelineEntry("**Viewing image first** with `code`."); + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Viewing image first"); + expect(markup).toContain(""); + expect(markup).toContain(" { + const turnId = TurnId.make("turn-thought-chunks"); + const chunks = ["Launching subagents.", "Selecting direct calls.", "Sequencing the work."]; + const markup = renderToStaticMarkup( + ({ + id: `thought-entry-${index}`, + kind: "message" as const, + createdAt: MESSAGE_CREATED_AT, + message: { + id: MessageId.make(`reasoning:raw:thought-${index}`), + role: "reasoning" as const, + text, + turnId, + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + streaming: false, + }, + }))} + />, + ); + expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(1); + expect(markup).toContain("Launching subagents. Selecting direct calls. Sequencing the work."); + }); + + it("shows a provider summary beside a collapsed raw trace", () => { + const turnId = TurnId.make("turn-mixed-reasoning"); + const summary = buildAssistantTimelineEntry("I will check the API."); + const raw = buildAssistantTimelineEntry("Private raw trace."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(2); + expect(markup).toContain("I will check the API."); + expect(markup).toContain("Full reasoning"); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).not.toContain("Private raw trace."); + }); + + it("preserves raw reasoning blocks 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\n" }, + { id: "raw-second", messageId: "reasoning:raw:second", text: "\nsecond 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()); + expect(renderer!.root.findByProps({ "data-reasoning-scroll": true }).children).toEqual([ + " first block\n\n\nsecond block ", + ]); + } 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..88d0211d07b4 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, @@ -102,9 +101,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 +136,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 +175,6 @@ import { deriveUnsettledTurnId, type MessagesTimelineRowsProjection, liveWorkEntryLabel, - workEntryIsActiveTurnActivity, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -201,7 +195,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"; @@ -319,8 +312,8 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); interface WorkGroupViewState { - scrollPositions: Map; expandedEntries: Set; + scrollPositions: Map; } const WorkGroupViewCtx = createContext<{ @@ -583,13 +576,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 +1648,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 +1656,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 +1679,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 +1708,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} @@ -2575,103 +2571,6 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap ); } -function ActivityGroupTimelineRow({ - 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,185 +2583,88 @@ 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 - ) { + const text = row.messages + .map((message) => message.text.trim()) + .filter(Boolean) + .join(" "); + if (text.length === 0) { 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} - - ); return ( -
- {showHeader ? ( - - ) : null} - {expanded ? ( -
- {messages.map((reasoningMessage) => ( - - ))} -
- ) : null} +
+ message.streaming)} + lineBreaks={shouldPreserveAssistantLineBreaks(text)} + skills={ctx.skills} + headingLevelOffset={MESSAGE_HEADING_LEVEL} + onUseArtifactTemplate={ctx.onUseArtifactTemplate} + onImageExpand={ctx.onImageExpand} + />
); } -/** - * 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 detailsId = useId(); + const messageId = row.messages[0]!.id; + const expanded = ctx.expandedReasoningMessageIds.has(messageId); + const text = expanded ? row.messages.map((message) => message.text).join("\n") : null; return ( -
+
- {expanded ? ( -
- + {text !== null ? ( +
+ {text}
) : null}
); -}); +} function CompactingLabel() { return ( @@ -2906,43 +2708,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 +3024,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 +3115,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..1145b5b94f70 --- /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. Short reasoning summaries read like the agent's other +text. When a provider also exposes a full reasoning trace, open Full reasoning +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 activity group in the same thread view. +Select the tool call again to close its details. From 0309fd61ff2b8cf40e16e05ca4c6b3c7eeaf4ac5 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 22 Sep 2026 23:17:26 +0200 Subject: [PATCH 02/43] fix(web): restore thought styling for raw reasoning --- .../components/chat/MessagesTimeline.test.tsx | 17 ++++--- .../src/components/chat/MessagesTimeline.tsx | 49 ++++++++++++++----- docs/user/activity-groups.md | 2 +- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 53a53723933e..57a7063bb1b4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -391,7 +391,7 @@ describe("MessagesTimeline", () => { 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-[min(18rem,50dvh)]", + "max-h-96", ); expect(JSON.stringify(renderer!.toJSON())).toContain("Investigating a long reasoning trace."); } finally { @@ -1925,20 +1925,20 @@ describe("MessagesTimeline", () => { expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(2); expect(markup).toContain("I will check the API."); - expect(markup).toContain("Full reasoning"); + expect(markup).toContain("Thought"); expect(markup).toContain('aria-expanded="false"'); expect(markup).not.toContain("Private raw trace."); }); - it("preserves raw reasoning blocks when opened", async () => { + 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\n" }, - { id: "raw-second", messageId: "reasoning:raw:second", text: "\nsecond block " }, + { 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 { @@ -1969,9 +1969,10 @@ describe("MessagesTimeline", () => { .find((button) => button.props["aria-expanded"] === false); expect(toggle).toBeDefined(); await act(() => toggle!.props.onClick()); - expect(renderer!.root.findByProps({ "data-reasoning-scroll": true }).children).toEqual([ - " first block\n\n\nsecond block ", - ]); + 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(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 88d0211d07b4..ff2212555ee0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2634,32 +2634,57 @@ function RawReasoningTimelineRow({ const detailsId = useId(); const messageId = row.messages[0]!.id; const expanded = ctx.expandedReasoningMessageIds.has(messageId); - const text = expanded ? row.messages.map((message) => message.text).join("\n") : null; + const label = `Thought${row.messages.length > 1 ? ` (×${row.messages.length})` : ""}`; return ( -
+
- {text !== null ? ( + {expanded ? (
- {text} + {row.messages.map((message) => ( + + ))}
) : null}
diff --git a/docs/user/activity-groups.md b/docs/user/activity-groups.md index 1145b5b94f70..f8abb1742459 100644 --- a/docs/user/activity-groups.md +++ b/docs/user/activity-groups.md @@ -2,7 +2,7 @@ On web and desktop, expand a completed turn in chat to see the agent's reasoning and tool calls in order. Short reasoning summaries read like the agent's other -text. When a provider also exposes a full reasoning trace, open Full reasoning +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 From 7145dec0682ef5854e58dd65a2fb0ee2cd84efe0 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 22 Sep 2026 23:33:45 +0200 Subject: [PATCH 03/43] fix(web): keep short reasoning chunks inline --- .../chat/MessagesTimeline.logic.test.ts | 37 +++++++++++++++++-- .../components/chat/MessagesTimeline.logic.ts | 19 +++------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3f87e606c268..e28cf4f77987 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1928,23 +1928,54 @@ describe("deriveMessagesTimelineRows", () => { expect( reasoningDisplayKind( { ...base, id: MessageId.make("reasoning:summary:item"), text: "x".repeat(5_000) }, - { hasSummary: true, rawCharacters: 5_000, rawLines: 0 }, + { hasSummary: true }, ), ).toBe("summary"); expect( reasoningDisplayKind( { ...base, id: MessageId.make("reasoning:raw:item"), text: "short" }, - { hasSummary: true, rawCharacters: 5, rawLines: 0 }, + { hasSummary: true }, ), ).toBe("raw"); expect( reasoningDisplayKind( { ...base, id: MessageId.make("reasoning:raw:item"), text: "short" }, - { hasSummary: false, rawCharacters: 5, rawLines: 0 }, + { hasSummary: false }, ), ).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, + }); + + 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("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; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 34b4a375799b..70379283d63c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -287,8 +287,6 @@ const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; interface ReasoningTurnStats { hasSummary: boolean; - rawCharacters: number; - rawLines: number; } function reasoningStatsByTurn( @@ -299,14 +297,9 @@ function reasoningStatsByTurn( if (entry.kind !== "message" || entry.message.role !== "reasoning") continue; const message = entry.message; const key = message.turnId ?? message.id; - const turn = stats.get(key) ?? { hasSummary: false, rawCharacters: 0, rawLines: 0 }; + const turn = stats.get(key) ?? { hasSummary: false }; if (message.id.startsWith("reasoning:summary:")) { turn.hasSummary = true; - } else { - turn.rawCharacters += message.text.length; - if (turn.rawCharacters <= 4_000 && turn.rawLines <= 24) { - turn.rawLines += message.text.split("\n", 26).length - 1; - } } stats.set(key, turn); } @@ -318,7 +311,9 @@ export function reasoningDisplayKind( turn: ReasoningTurnStats, ): "summary" | "raw" { if (message.id.startsWith("reasoning:summary:")) return "summary"; - return turn.hasSummary || turn.rawCharacters > 4_000 || turn.rawLines > 24 ? "raw" : "summary"; + return turn.hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25 + ? "raw" + : "summary"; } export type MessagesTimelineRow = @@ -1092,11 +1087,7 @@ export function deriveMessagesTimelineRows(input: { const displayKind = (message: ChatMessage) => reasoningDisplayKind( message, - reasoningTurns.get(message.turnId ?? message.id) ?? { - hasSummary: false, - rawCharacters: message.text.length, - rawLines: message.text.split("\n", 26).length - 1, - }, + reasoningTurns.get(message.turnId ?? message.id) ?? { hasSummary: false }, ); for (let index = 0; index < input.timelineEntries.length; index += 1) { From 93c6a73f230c46747d594a759dfbe6a36b3797c9 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Wed, 23 Sep 2026 17:21:32 +0200 Subject: [PATCH 04/43] fix(opencode): preserve progress and fold settled reasoning --- .../src/features/threads/ThreadFeed.tsx | 20 ++++- apps/mobile/src/lib/threadActivity.test.ts | 59 +++++++++++- apps/mobile/src/lib/threadActivity.ts | 76 ++++++++++++---- .../Layers/ProviderRuntimeIngestion.test.ts | 64 +++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 24 +++-- .../src/orchestration/projector.test.ts | 3 +- apps/server/src/orchestration/projector.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 89 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 44 +++++++++ .../chat/MessagesTimeline.logic.test.ts | 66 ++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 17 ++-- .../src/components/chat/MessagesTimeline.tsx | 44 ++++++--- .../src/state/threadReducer.test.ts | 44 +++++++++ .../client-runtime/src/state/threadReducer.ts | 1 + packages/contracts/src/orchestration.ts | 5 +- packages/contracts/src/providerRuntime.ts | 1 + 16 files changed, 500 insertions(+), 58 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20848cd2ad10..caab5ded451d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1486,6 +1486,22 @@ function renderFeedEntry( const { message } = entry; if (message.role === "reasoning") { const messages = entry.reasoningMessages ?? [message]; + if (entry.reasoningKind === "summary") { + return ( + + {messages.map((reasoningMessage) => ( + + ))} + + ); + } return ( { 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,7 +2333,7 @@ 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}.`, turnId, @@ -2397,7 +2412,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 +2440,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 +2593,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 +2602,47 @@ describe("buildThreadFeed", () => { id: message.id, createdAt: message.createdAt, message, + ...(message.turnId ? { 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("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..f9ef402579f8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -151,6 +151,7 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | (Extract & { readonly reasoningMessages?: OrchestrationThread["messages"]; + readonly reasoningKind?: "summary" | "raw"; }) | { readonly type: "activity-group"; @@ -1691,17 +1692,8 @@ function deriveThreadFeedTurnFolds( if (turnId === unsettledTurnId) { 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", - ) - ) { - 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); @@ -1972,8 +1964,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,8 +2002,17 @@ 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) }); } @@ -2004,19 +2020,42 @@ function groupConsecutiveReasoningMessages( feed: ReadonlyArray, ): ThreadFeedEntry[] { const result: ThreadFeedEntry[] = []; + const turnsWithSummary = new Set( + feed.flatMap((entry) => + entry.type === "message" && + entry.message.role === "reasoning" && + entry.message.turnId && + (entry.message.id.startsWith("reasoning:summary:") || + entry.message.id.startsWith("assistant:")) + ? [entry.message.turnId] + : [], + ), + ); + const kindOf = (message: OrchestrationThread["messages"][number]) => + message.id.startsWith("reasoning:raw:") || + message.text.length > 2_000 || + message.text.split("\n", 26).length > 25 + ? "raw" + : message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:") + ? "summary" + : message.turnId && turnsWithSummary.has(message.turnId) + ? "raw" + : "summary"; for (let index = 0; index < feed.length; index += 1) { const entry = feed[index]!; if (entry.type !== "message" || entry.message.role !== "reasoning" || !entry.message.turnId) { 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 +2063,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/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d61739f72c21..5be60389dfac 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1439,6 +1439,70 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).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") => + 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 the reviews.", "evt-progress-text"); + complete("progress-part", "evt-progress-complete"); + 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: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:progress-part", "reasoning", "Checking the reviews."], + ]); + }); + 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..ae3897a91ba8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1480,6 +1480,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 +1492,8 @@ const make = Effect.gen(function* () { : ""; const hasRenderableText = hasRenderableAssistantText(text); - const isReasoning = messageStreamRoleOf(input.messageId) === "reasoning"; + const isReasoning = + input.presentation === "progress" || messageStreamRoleOf(input.messageId) === "reasoning"; if (hasRenderableText) { yield* orchestrationEngine.dispatch({ @@ -2232,6 +2234,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 +2263,10 @@ const make = Effect.gen(function* () { const activeAssistantMessageId = turnId ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) : Option.none(); - const assistantMessageId = Option.getOrElse( - activeAssistantMessageId, - () => assistantCompletion.messageId, - ); + const assistantMessageId = + assistantCompletion.presentation === "progress" + ? assistantCompletion.messageId + : Option.getOrElse(activeAssistantMessageId, () => assistantCompletion.messageId); const [existingAssistantMessage, hasAssistantMessagesForTurn] = yield* Effect.all([ getThreadMessageById(thread.id, assistantMessageId), turnId === undefined @@ -2278,6 +2281,7 @@ const make = Effect.gen(function* () { !existingAssistantMessage || existingAssistantMessage.text.length === 0; const shouldSkipRedundantCompletion = + assistantCompletion.presentation !== "progress" && Option.isNone(activeAssistantMessageId) && turnId !== undefined && hasAssistantMessagesForTurn && @@ -2293,10 +2297,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 +2317,7 @@ const make = Effect.gen(function* () { } } - if (turnId) { + if (turnId && assistantCompletion.presentation !== "progress") { yield* clearAssistantSegmentStateForTurn(thread.id, turnId); } } diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..a6c9716abbba 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -684,7 +684,7 @@ describe("orchestration projector", () => { payload: { threadId: "thread-1", messageId: "assistant:msg-1", - role: "assistant", + role: "reasoning", text: "", turnId: "turn-1", streaming: false, @@ -697,6 +697,7 @@ 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); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..88f1696988f2 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 diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 601917d35864..f2e27abfa745 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -6973,6 +6973,95 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "marks OpenCode continuation text as progress while keeping stopped text as output", + () => + 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(10), + 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-0", "progress"], + ["part-progress-1", undefined], + ["part-progress-1", "progress"], + ["part-progress-2", undefined], + ], + ); + }), + ); + it.effect("emits tool lifecycle events before late assistant metadata", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..855d2de6b09e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -332,6 +332,7 @@ type OpenCodeTextPartState = Pick, "id" | "tokens">; @@ -616,6 +617,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); @@ -1613,6 +1615,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, @@ -1641,6 +1671,9 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); + if (part.progressClassified) { + yield* emitAssistantProgressCompletion(context, part, turnId, raw); + } } if (part.type === "text" && part.time?.end !== undefined && !part.completed) { @@ -2387,6 +2420,14 @@ 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" || + event.properties.info.finish === "length") + ) { + yield* emitAssistantProgressCompletion(context, part, turnId, event); + } } } break; @@ -2443,6 +2484,9 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); + if (existingPart.progressClassified) { + yield* emitAssistantProgressCompletion(context, existingPart, turnId, event); + } break; } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e28cf4f77987..4a28a34f70c5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1943,6 +1943,12 @@ describe("deriveMessagesTimelineRows", () => { { 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", () => { @@ -1976,6 +1982,32 @@ describe("deriveMessagesTimelineRows", () => { ).toBe(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.\n".repeat(300); + 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"); + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [raw, progress, answer], + 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.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 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; @@ -2132,6 +2164,40 @@ describe("deriveMessagesTimelineRows", () => { 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", () => { // A steer ends the previous turn early: its only message completes the // instant it is created, and trailing work entries land after it. The diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 70379283d63c..5fdf6061bcd2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -310,7 +310,9 @@ export function reasoningDisplayKind( message: ChatMessage, turn: ReasoningTurnStats, ): "summary" | "raw" { - if (message.id.startsWith("reasoning:summary:")) return "summary"; + if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { + return "summary"; + } return turn.hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25 ? "raw" : "summary"; @@ -633,7 +635,6 @@ function deriveTurnFolds(input: { 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 @@ -667,7 +668,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. @@ -681,12 +681,6 @@ 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; - } } } @@ -695,9 +689,8 @@ function deriveTurnFolds(input: { if (input.unfoldedTurnIds.has(turnId)) { continue; } - if (group.hasStreamingMessage) { - 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) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ff2212555ee0..69c15fe306ce 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2601,6 +2601,7 @@ function SummaryReasoningTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); + const [expanded, setExpanded] = useState(false); const text = row.messages .map((message) => message.text.trim()) .filter(Boolean) @@ -2608,19 +2609,32 @@ function SummaryReasoningTimelineRow({ if (text.length === 0) { return null; } + const longSummary = text.length > 2_000 || text.split("\n", 26).length > 25; return (
- message.streaming)} - lineBreaks={shouldPreserveAssistantLineBreaks(text)} - skills={ctx.skills} - headingLevelOffset={MESSAGE_HEADING_LEVEL} - onUseArtifactTemplate={ctx.onUseArtifactTemplate} - onImageExpand={ctx.onImageExpand} - /> +
+ message.streaming)} + lineBreaks={shouldPreserveAssistantLineBreaks(text)} + skills={ctx.skills} + headingLevelOffset={MESSAGE_HEADING_LEVEL} + onUseArtifactTemplate={ctx.onUseArtifactTemplate} + onImageExpand={ctx.onImageExpand} + /> +
+ {longSummary ? ( + + ) : null}
); } @@ -2634,6 +2648,7 @@ function RawReasoningTimelineRow({ const detailsId = useId(); const messageId = row.messages[0]!.id; const expanded = ctx.expandedReasoningMessageIds.has(messageId); + const streaming = row.messages.some((message) => message.streaming); const label = `Thought${row.messages.length > 1 ? ` (×${row.messages.length})` : ""}`; return (
@@ -2648,7 +2663,12 @@ function RawReasoningTimelineRow({ - + {label} diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index dbee48d7808c..509b5dc6cc61 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -575,6 +575,50 @@ describe("applyThreadDetailEvent", () => { } }); + it("updates a streamed assistant message to a progress summary", () => { + const messageId = MessageId.make("assistant:progress-part"); + const thread = { + ...baseThread, + 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.", + }); + }); + 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..592061033d16 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 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..589458eeeec4 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -434,6 +434,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), From 322d3934c4b00212ced51ab69ecaf514ec32e170 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Wed, 23 Sep 2026 21:06:29 +0200 Subject: [PATCH 05/43] fix(opencode): keep reasoning and final reports in one turn --- .../src/features/threads/ThreadFeed.tsx | 26 +-- apps/mobile/src/lib/threadActivity.test.ts | 73 +++++++ apps/mobile/src/lib/threadActivity.ts | 54 +++-- .../Layers/ProviderRuntimeIngestion.test.ts | 65 ++++++ .../Layers/ProviderRuntimeIngestion.ts | 36 +++- .../provider/Layers/OpenCodeAdapter.test.ts | 199 ++++++++++++++++-- .../src/provider/Layers/OpenCodeAdapter.ts | 101 +++++++-- .../chat/MessagesTimeline.logic.test.ts | 80 ++++++- .../components/chat/MessagesTimeline.logic.ts | 29 ++- packages/contracts/src/providerRuntime.ts | 1 + 10 files changed, 584 insertions(+), 80 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index caab5ded451d..67b2d1bae068 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1488,18 +1488,20 @@ function renderFeedEntry( const messages = entry.reasoningMessages ?? [message]; if (entry.reasoningKind === "summary") { return ( - - {messages.map((reasoningMessage) => ( - - ))} - + + + {messages.map((reasoningMessage) => ( + + ))} + + ); } return ( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index df57f55977a3..3496e82daed5 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2643,6 +2643,79 @@ describe("buildThreadFeed", () => { expect(collapsed[1]).toMatchObject({ message: messages[1], reasoningKind: "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"]); + }); + + 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); + }); + 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 f9ef402579f8..727d309ad6c6 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -5,13 +5,16 @@ 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"; @@ -1636,15 +1639,23 @@ interface ThreadFeedTurnFold { function deriveThreadFeedTurnFolds( feed: ReadonlyArray, latestTurn: ThreadFeedLatestTurn | null, + isWorking: boolean, ): ReadonlyMap { const firstAssistantMessageIdByTurn = new Map(); const terminalAssistantMessageIdByTurn = new Map(); + let unkeyedResponseTurnId: TurnId | null = null; 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); + if (entry.type === "message" && entry.message.role === "user") { + unkeyedResponseTurnId = TurnId.make(`unkeyed-response:${entry.message.id}`); + continue; + } + if (entry.type === "message" && entry.message.role === "assistant") { + const turnId = entry.message.turnId ?? unkeyedResponseTurnId; + if (!turnId) continue; + if (!firstAssistantMessageIdByTurn.has(turnId)) { + firstAssistantMessageIdByTurn.set(turnId, entry.id); } - terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + terminalAssistantMessageIdByTurn.set(turnId, entry.id); } } @@ -1654,9 +1665,11 @@ function deriveThreadFeedTurnFolds( } const groupsByTurnId = new Map(); let pendingUserBoundary: string | null = null; + unkeyedResponseTurnId = null; for (const entry of feed) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; + unkeyedResponseTurnId = TurnId.make(`unkeyed-response:${entry.message.id}`); continue; } // Thinking is work, so it folds with the rest of it. A provider that @@ -1666,7 +1679,8 @@ function deriveThreadFeedTurnFolds( const turnId = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "reasoning") - ? entry.message.turnId + ? (entry.message.turnId ?? + (entry.message.role === "assistant" ? unkeyedResponseTurnId : null)) : entry.type === "activity-group" ? entry.turnId : null; @@ -1689,6 +1703,9 @@ function deriveThreadFeedTurnFolds( const foldsByAnchorId = new Map(); for (const [turnId, group] of groupsByTurnId) { const { entries } = group; + if (isWorking && String(turnId).startsWith("unkeyed-response:")) { + continue; + } if (turnId === unsettledTurnId) { continue; } @@ -1697,11 +1714,12 @@ function deriveThreadFeedTurnFolds( const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId); const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); + const unkeyedResponse = String(turnId).startsWith("unkeyed-response:"); const hiddenEntryIds = new Set( entries .filter( (entry) => - entry.id !== firstAssistantMessageId && + (unkeyedResponse || entry.id !== firstAssistantMessageId) && entry.id !== terminalAssistantMessageId && !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), ) @@ -1710,17 +1728,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; } @@ -1783,9 +1807,9 @@ 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 = deriveUnsettledTurnId(latestTurn); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedTurnIds.has(fold.turnId)) { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 5be60389dfac..e8d9d3d708db 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1503,6 +1503,71 @@ describe("ProviderRuntimeIngestion", () => { ]); }); + 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 reviews.", "evt-progress-second", "assistant_progress_text"); + emitText("answer-part", "Review complete.", "evt-answer", "assistant_text"); + complete("answer-part", "evt-answer-complete"); + + 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.", + ), + ); + 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."], + ]); + }); + 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 ae3897a91ba8..ee98dda3446c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1762,6 +1762,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" ) { @@ -1940,6 +1941,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" || @@ -2075,6 +2080,30 @@ 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}`, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.message.reasoning.delta", + commandId: yield* providerCommandId(event, "assistant-progress-delta"), + threadId: thread.id, + messageId, + delta: assistantProgressDelta, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + yield* orchestrationEngine.dispatch({ + type: "thread.message.reasoning.complete", + commandId: yield* providerCommandId(event, "assistant-progress-complete"), + threadId: thread.id, + messageId, + ...(turnId ? { turnId } : {}), + createdAt: now, + }); + } + const pauseForUserTurnId = event.type === "request.opened" || (event.type === "user-input.requested" && event.payload.responseMode !== "message") @@ -2317,7 +2346,12 @@ const make = Effect.gen(function* () { } } - if (turnId && assistantCompletion.presentation !== "progress") { + if ( + turnId && + (assistantCompletion.presentation !== "progress" || + (Option.isSome(activeAssistantMessageId) && + activeAssistantMessageId.value === assistantMessageId)) + ) { yield* clearAssistantSegmentStateForTurn(thread.id, turnId); } } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index f2e27abfa745..54671a66877a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -3275,8 +3275,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { 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 +3321,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: "stop", + }, + }, + }); + yield* advanceTestClock(2_000); + const completed = Option.getOrUndefined( yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), ); @@ -3389,15 +3413,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 +3430,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 +3481,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 +3497,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 +3571,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")), ); @@ -7033,7 +7072,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { event.threadId === threadId && (event.type === "content.delta" || event.type === "item.completed"), ), - Stream.take(10), + Stream.take(9), Stream.runCollect, Effect.forkChild, ); @@ -7053,12 +7092,26 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { [ ["part-progress-0", undefined], ["part-progress-0", "progress"], - ["part-progress-0", "progress"], ["part-progress-1", undefined], ["part-progress-1", "progress"], ["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"], + ], + ); }), ); @@ -7189,6 +7242,95 @@ 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 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); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("processes late assistant metadata without visiting completed turns", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -7215,9 +7357,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", @@ -7320,6 +7476,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", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 855d2de6b09e..509b490c87ed 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -222,6 +222,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; @@ -356,6 +358,9 @@ interface OpenCodeSessionContext { readonly textPartsByMessageId: Map>; turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; activeTurnId: TurnId | undefined; + activePromptMessageId: string | undefined; + lastCompletedPrompt: { messageId: string; turnId: TurnId } | undefined; + readonly lateAssistantMessageIds: Set; activeAgent: string | undefined; activeVariant: string | undefined; cancellation: OpenCodeCancellation | undefined; @@ -598,8 +603,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( @@ -1135,6 +1146,10 @@ export function makeOpenCodeAdapter( context.pendingIdleReconciliation = undefined; } const tokenUsage = takeOpenCodeTurnTokenUsage(context, true); + if (context.activePromptMessageId) { + context.lastCompletedPrompt = { messageId: context.activePromptMessageId, turnId }; + } + context.activePromptMessageId = undefined; context.activeTurnId = undefined; context.activeAgent = undefined; context.activeVariant = undefined; @@ -1448,14 +1463,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; @@ -1476,7 +1491,7 @@ export function makeOpenCodeAdapter( } if ( isIdle && - promptAdmission.messageObserved && + promptAdmission.assistantResponseFinished && promptAdmission.recoveryRaw !== undefined ) { context.promptAdmission = undefined; @@ -1492,6 +1507,26 @@ 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) { + yield* Deferred.await(promptAdmission.responseReceipt); + 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), @@ -1671,9 +1706,6 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); - if (part.progressClassified) { - yield* emitAssistantProgressCompletion(context, part, turnId, raw); - } } if (part.type === "text" && part.time?.end !== undefined && !part.completed) { @@ -2297,7 +2329,29 @@ export function makeOpenCodeAdapter( return; } - const turnId = context.activeTurnId; + const lastCompletedPrompt = context.lastCompletedPrompt; + if ( + context.activeTurnId === undefined && + lastCompletedPrompt && + event.type === "message.updated" && + event.properties.info.role === "assistant" && + event.properties.info.parentID === lastCompletedPrompt.messageId + ) { + context.lateAssistantMessageIds.add(event.properties.info.id); + } + const lateAssistantMessageId = + 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 = + context.activeTurnId ?? + (lateAssistantMessageId && context.lateAssistantMessageIds.has(lateAssistantMessageId) + ? lastCompletedPrompt?.turnId + : undefined); yield* writeNativeEventBestEffort(context.session.threadId, { observedAt: yield* nowIso, event: { @@ -2369,15 +2423,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); @@ -2385,6 +2431,14 @@ export function makeOpenCodeAdapter( context.textPartsByMessageId.delete(event.properties.info.id); } if (event.properties.info.role === "assistant") { + if ( + promptAdmission?.messageId === event.properties.info.parentID && + event.properties.info.finish === "stop" + ) { + promptAdmission.assistantResponseFinished = true; + yield* Deferred.succeed(promptAdmission.responseReceipt, undefined); + yield* schedulePromptAdmissionRecovery(context, event); + } const usage = context.turnTokenUsage; const parentMessageId = typeof event.properties.info.parentID === "string" && @@ -2484,9 +2538,6 @@ export function makeOpenCodeAdapter( delta: deltaToEmit, }, }); - if (existingPart.progressClassified) { - yield* emitAssistantProgressCompletion(context, existingPart, turnId, event); - } break; } @@ -3063,6 +3114,9 @@ export function makeOpenCodeAdapter( messageRoleById: new Map(), turnTokenUsage: undefined, activeTurnId: undefined, + activePromptMessageId: undefined, + lastCompletedPrompt: undefined, + lateAssistantMessageIds: new Set(), activeAgent: undefined, activeVariant: undefined, cancellation: undefined, @@ -3231,6 +3285,8 @@ export function makeOpenCodeAdapter( idleDuringAdmission: undefined, idleObservedAfterMessage: false, messageObserved: false, + assistantResponseFinished: false, + responseReceipt: Deferred.makeUnsafe(), busyObserved: false, idleStatusConfirmations: 0, accepted: false, @@ -3244,6 +3300,9 @@ export function makeOpenCodeAdapter( context.promptAdmission = promptAdmission; context.activeTurnId = turnId; + context.activePromptMessageId = messageId; + context.lastCompletedPrompt = undefined; + context.lateAssistantMessageIds.clear(); if (steeringTurnId === undefined) { context.turnTokenUsage = makeOpenCodeTurnTokenUsageAccumulator(); } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 4a28a34f70c5..f90433eb457d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1984,7 +1984,7 @@ describe("deriveMessagesTimelineRows", () => { 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.\n".repeat(300); + 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."; @@ -2008,6 +2008,35 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "message" && row.id === answer.id)).toBe(true); }); + 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: [user, ...reports], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: 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); + }); + 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; @@ -2116,7 +2145,7 @@ describe("deriveMessagesTimelineRows", () => { ).toEqual([first.id, second.id, answer.id]); }); - it("leaves a thought-only turn visible", () => { + 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")], @@ -2125,7 +2154,52 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect(rows.map((row) => row.kind)).toEqual(["reasoning-run", "message"]); + expect(rows.map((row) => row.kind)).toEqual(["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", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5fdf6061bcd2..a79acea6f4cf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -34,7 +34,7 @@ import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { type MessageId, type OrchestrationLatestTurn, - type TurnId, + TurnId, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; @@ -298,7 +298,7 @@ function reasoningStatsByTurn( const message = entry.message; const key = message.turnId ?? message.id; const turn = stats.get(key) ?? { hasSummary: false }; - if (message.id.startsWith("reasoning:summary:")) { + if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { turn.hasSummary = true; } stats.set(key, turn); @@ -631,6 +631,7 @@ function deriveTurnFolds(input: { terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unfoldedTurnIds: ReadonlySet; + isWorking: boolean; }): ReadonlyMap { interface TurnGroup { entries: Array; @@ -646,9 +647,11 @@ 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 = TurnId.make(`unkeyed-response:${entry.message.id}`); continue; } // Reasoning reads inline between tools while the turn is live. Once it @@ -656,7 +659,8 @@ function deriveTurnFolds(input: { const turnId = entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "reasoning") - ? (entry.message.turnId ?? null) + ? (entry.message.turnId ?? + (entry.message.role === "assistant" ? unkeyedResponseTurnId : null)) : entry.kind === "work" ? (entry.entry.turnId ?? null) : null; @@ -686,6 +690,9 @@ function deriveTurnFolds(input: { const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { + if (input.isWorking && String(turnId).startsWith("unkeyed-response:")) { + continue; + } if (input.unfoldedTurnIds.has(turnId)) { continue; } @@ -737,16 +744,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. Reasoning alone also stays - // visible rather than sitting behind a fold with no other work. + // 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; } @@ -968,6 +982,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()) { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 589458eeeec4..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", From 9ca3ae5ebb3464791582a0eeccb786d9829b0239 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:24:23 +0200 Subject: [PATCH 06/43] fix(server): settle OpenCode prompt admission after terminal response --- .../provider/Layers/OpenCodeAdapter.test.ts | 40 +++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 96 ++++++++++++++++--- 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 54671a66877a..d33cc585f616 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2143,10 +2143,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 +2216,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 +2994,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 +3096,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")), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 509b490c87ed..5499821b4dce 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; @@ -886,6 +887,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); } @@ -1511,7 +1515,13 @@ export function makeOpenCodeAdapter( // the turn owned until that response arrives; its terminal update // restarts reconciliation without polling an idle session indefinitely. if (promptAdmission.messageObserved && !promptAdmission.assistantResponseFinished) { - yield* Deferred.await(promptAdmission.responseReceipt); + 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 && @@ -2437,7 +2447,14 @@ export function makeOpenCodeAdapter( ) { promptAdmission.assistantResponseFinished = true; yield* Deferred.succeed(promptAdmission.responseReceipt, undefined); - yield* schedulePromptAdmissionRecovery(context, event); + 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 = @@ -2689,8 +2706,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", @@ -2720,9 +2746,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; } @@ -2757,6 +2797,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 @@ -3275,7 +3323,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, @@ -3593,13 +3646,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); @@ -3728,6 +3797,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); } From 1f3b7baf18ab5ba75ad4ae00c8caa92af7ab862a Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:27:07 +0200 Subject: [PATCH 07/43] fix(chat): keep prior response folds closed during a new turn --- apps/mobile/src/lib/threadActivity.test.ts | 17 +++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 7 ++++++- .../chat/MessagesTimeline.logic.test.ts | 11 +++++++++++ .../components/chat/MessagesTimeline.logic.ts | 7 ++++++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 3496e82daed5..11de0cd2d90b 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2714,6 +2714,23 @@ describe("buildThreadFeed", () => { "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("shows one Thinking row while a turn works without live tool activity", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 727d309ad6c6..042649f5c42f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1701,9 +1701,14 @@ 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 (isWorking && String(turnId).startsWith("unkeyed-response:")) { + if ( + String(turnId).startsWith("unkeyed-response:") && + (turnId === activeUnkeyedResponseTurnId || + entries.some((entry) => entry.type === "message" && entry.message.streaming)) + ) { continue; } if (turnId === unsettledTurnId) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f90433eb457d..3e712a43d53c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2035,6 +2035,17 @@ describe("deriveMessagesTimelineRows", () => { 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 provider summaries and raw traces in separate reasoning rows", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a79acea6f4cf..92d7039b3462 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -689,8 +689,13 @@ function deriveTurnFolds(input: { } const foldsByAnchorEntryId = new Map(); + const activeUnkeyedResponseTurnId = input.isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { - if (input.isWorking && String(turnId).startsWith("unkeyed-response:")) { + if ( + String(turnId).startsWith("unkeyed-response:") && + (turnId === activeUnkeyedResponseTurnId || + group.entries.some((entry) => entry.kind === "message" && entry.message.streaming)) + ) { continue; } if (input.unfoldedTurnIds.has(turnId)) { From 359a721137a65f7feb0e4457b7fd7f0633081120 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:28:37 +0200 Subject: [PATCH 08/43] fix(mobile): classify OpenCode reasoning like web --- apps/mobile/src/lib/threadActivity.test.ts | 32 +++++++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 20 +++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 11de0cd2d90b..b3957ddd2217 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2335,7 +2335,7 @@ describe("buildThreadFeed", () => { const messages: OrchestrationThread["messages"] = [1, 2, 3, 4].map((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`, @@ -2643,6 +2643,36 @@ describe("buildThreadFeed", () => { expect(collapsed[1]).toMatchObject({ message: messages[1], reasoningKind: "summary" }); }); + 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"] = [ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 042649f5c42f..eb5aee4f01e1 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2060,19 +2060,19 @@ function groupConsecutiveReasoningMessages( : [], ), ); - const kindOf = (message: OrchestrationThread["messages"][number]) => - message.id.startsWith("reasoning:raw:") || - message.text.length > 2_000 || - message.text.split("\n", 26).length > 25 + const kindOf = (message: OrchestrationThread["messages"][number]) => { + if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { + return "summary"; + } + return (message.turnId && turnsWithSummary.has(message.turnId)) || + message.text.length > 2_000 || + message.text.split("\n", 26).length > 25 ? "raw" - : message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:") - ? "summary" - : message.turnId && turnsWithSummary.has(message.turnId) - ? "raw" - : "summary"; + : "summary"; + }; 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; } From 319aa03f53ecf2177df28b935c753066426b39ac Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:30:58 +0200 Subject: [PATCH 09/43] fix(server): keep late OpenCode reports on their original turn --- .../provider/Layers/OpenCodeAdapter.test.ts | 9 +++++ .../src/provider/Layers/OpenCodeAdapter.ts | 40 ++++++------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index d33cc585f616..8720940ff389 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7323,6 +7323,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ); 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) => @@ -7363,6 +7371,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { 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); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 5499821b4dce..ce0784da33b6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -354,14 +354,12 @@ 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>; turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; activeTurnId: TurnId | undefined; - activePromptMessageId: string | undefined; - lastCompletedPrompt: { messageId: string; turnId: TurnId } | undefined; - readonly lateAssistantMessageIds: Set; activeAgent: string | undefined; activeVariant: string | undefined; cancellation: OpenCodeCancellation | undefined; @@ -1150,10 +1148,6 @@ export function makeOpenCodeAdapter( context.pendingIdleReconciliation = undefined; } const tokenUsage = takeOpenCodeTurnTokenUsage(context, true); - if (context.activePromptMessageId) { - context.lastCompletedPrompt = { messageId: context.activePromptMessageId, turnId }; - } - context.activePromptMessageId = undefined; context.activeTurnId = undefined; context.activeAgent = undefined; context.activeVariant = undefined; @@ -2339,17 +2333,14 @@ export function makeOpenCodeAdapter( return; } - const lastCompletedPrompt = context.lastCompletedPrompt; - if ( - context.activeTurnId === undefined && - lastCompletedPrompt && - event.type === "message.updated" && - event.properties.info.role === "assistant" && - event.properties.info.parentID === lastCompletedPrompt.messageId - ) { - context.lateAssistantMessageIds.add(event.properties.info.id); + 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) { + context.messageTurnIdById.set(event.properties.info.id, parentTurnId); + } } - const lateAssistantMessageId = + const assistantMessageId = event.type === "message.updated" && event.properties.info.role === "assistant" ? event.properties.info.id : event.type === "message.part.updated" @@ -2358,10 +2349,8 @@ export function makeOpenCodeAdapter( ? event.properties.messageID : undefined; const turnId = - context.activeTurnId ?? - (lateAssistantMessageId && context.lateAssistantMessageIds.has(lateAssistantMessageId) - ? lastCompletedPrompt?.turnId - : undefined); + (assistantMessageId ? context.messageTurnIdById.get(assistantMessageId) : undefined) ?? + context.activeTurnId; yield* writeNativeEventBestEffort(context.session.threadId, { observedAt: yield* nowIso, event: { @@ -3160,11 +3149,9 @@ export function makeOpenCodeAdapter( pendingQuestions: new Map(), textPartsByMessageId: new Map(), messageRoleById: new Map(), + messageTurnIdById: new Map(), turnTokenUsage: undefined, activeTurnId: undefined, - activePromptMessageId: undefined, - lastCompletedPrompt: undefined, - lateAssistantMessageIds: new Set(), activeAgent: undefined, activeVariant: undefined, cancellation: undefined, @@ -3353,9 +3340,7 @@ export function makeOpenCodeAdapter( context.promptAdmission = promptAdmission; context.activeTurnId = turnId; - context.activePromptMessageId = messageId; - context.lastCompletedPrompt = undefined; - context.lateAssistantMessageIds.clear(); + context.messageTurnIdById.set(messageId, turnId); if (steeringTurnId === undefined) { context.turnTokenUsage = makeOpenCodeTurnTokenUsageAccumulator(); } @@ -4150,6 +4135,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; From 81de1457b22a09fa15c634902bcd0f41c94f462e Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:36:40 +0200 Subject: [PATCH 10/43] fix(chat): clear assistant anchors when progress becomes reasoning --- .../Layers/ProjectionPipeline.test.ts | 85 +++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 30 ++++++- .../src/orchestration/projector.test.ts | 19 ++++- apps/server/src/orchestration/projector.ts | 20 +++++ .../src/state/threadReducer.test.ts | 21 +++++ .../client-runtime/src/state/threadReducer.ts | 23 ++++- 6 files changed, 194 insertions(+), 4 deletions(-) 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..a53fe4749ada 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1566,7 +1566,35 @@ 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 messages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const replacement = messages.findLast( + (message) => + message.role === "assistant" && + message.turnId === event.payload.turnId && + message.messageId !== event.payload.messageId, + ); + yield* projectionTurnRepository.upsertByTurnId({ + ...existingTurn.value, + assistantMessageId: replacement?.messageId ?? null, + }); + } + return; + } + if (event.payload.role !== "assistant") { return; } // A completed assistant message only settles the turn once the diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index a6c9716abbba..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", @@ -701,6 +717,7 @@ describe("orchestration projector", () => { 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 88f1696988f2..970c1b209ebc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -800,11 +800,31 @@ export function projectEvent( ) : [...thread.messages, message]; const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + const reclassifiedAssistant = + existingMessage?.role === "assistant" && message.role === "reasoning"; + const replacementAssistantMessageId = reclassifiedAssistant + ? (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/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 509b5dc6cc61..7c96d6f8a5da 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -579,6 +579,25 @@ describe("applyThreadDetailEvent", () => { 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, @@ -617,6 +636,8 @@ describe("applyThreadDetailEvent", () => { role: "reasoning", text: "Checking the reviews.", }); + expect(result.thread.latestTurn?.assistantMessageId).toBeNull(); + expect(result.thread.checkpoints[0]?.assistantMessageId).toBeNull(); }); it("appends a new message", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 592061033d16..506c7b5e30b1 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -410,6 +410,14 @@ 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 + ? (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 @@ -421,7 +429,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 && @@ -455,7 +463,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, @@ -463,6 +471,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", From 5af33e4b506e39e99109e757185dee934511cd2a Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:39:05 +0200 Subject: [PATCH 11/43] fix(chat): keep live and failed reasoning visible --- apps/mobile/src/lib/threadActivity.test.ts | 42 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 14 +++++++ .../chat/MessagesTimeline.logic.test.ts | 35 ++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 17 ++++++++ 4 files changed, 108 insertions(+) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index b3957ddd2217..323d5dfbf2cd 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2708,6 +2708,48 @@ describe("buildThreadFeed", () => { expect(rows.map((row) => row.type)).toEqual(["turn-fold", "message"]); }); + 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("folds earlier turnless reports from one completed response", () => { const messages: OrchestrationThread["messages"] = [ { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index eb5aee4f01e1..f2fd18d63d5c 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1704,6 +1704,9 @@ function deriveThreadFeedTurnFolds( const activeUnkeyedResponseTurnId = isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { const { entries } = group; + if (isWorking && entries.some((entry) => entry.type === "message" && entry.message.streaming)) { + continue; + } if ( String(turnId).startsWith("unkeyed-response:") && (turnId === activeUnkeyedResponseTurnId || @@ -1719,6 +1722,17 @@ function deriveThreadFeedTurnFolds( 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.tone === "error"), + )) + ) { + continue; + } const unkeyedResponse = String(turnId).startsWith("unkeyed-response:"); const hiddenEntryIds = new Set( entries diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3e712a43d53c..9bcfd68fe367 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2249,6 +2249,41 @@ describe("deriveMessagesTimelineRows", () => { 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); + }); + 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 = { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 92d7039b3462..cbfff671d0f5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -691,6 +691,12 @@ function deriveTurnFolds(input: { const foldsByAnchorEntryId = new Map(); const activeUnkeyedResponseTurnId = input.isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { + if ( + input.isWorking && + group.entries.some((entry) => entry.kind === "message" && entry.message.streaming) + ) { + continue; + } if ( String(turnId).startsWith("unkeyed-response:") && (turnId === activeUnkeyedResponseTurnId || @@ -701,6 +707,17 @@ function deriveTurnFolds(input: { if (input.unfoldedTurnIds.has(turnId)) { continue; } + 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(); From cd7879343ad1accfca8a89d9675420ec080028d3 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:40:55 +0200 Subject: [PATCH 12/43] fix(web): retain and bound expanded reasoning summaries --- apps/web/src/components/chat/MessagesTimeline.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 69c15fe306ce..a56573e6afde 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2601,20 +2601,23 @@ function SummaryReasoningTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); - const [expanded, setExpanded] = useState(false); + const detailsId = useId(); + const messageId = row.messages[0]!.id; + const expanded = ctx.expandedReasoningMessageIds.has(messageId); const text = row.messages .map((message) => message.text.trim()) .filter(Boolean) .join(" "); if (text.length === 0) { - return null; + return row.messages.some((message) => message.streaming) ? : null; } const longSummary = text.length > 2_000 || text.split("\n", 26).length > 25; + const preview = text.slice(0, 2_000).split("\n").slice(0, 26).join("\n"); return (
-
+
message.streaming)} @@ -2630,7 +2633,8 @@ function SummaryReasoningTimelineRow({ type="button" className="mt-1 cursor-pointer text-secondary-label text-xs hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70" aria-expanded={expanded} - onClick={() => setExpanded((current) => !current)} + aria-controls={detailsId} + onClick={() => ctx.onToggleReasoning(messageId, !expanded, row.id)} > {expanded ? "Show less" : "Show more"} From ab2e9389fecc5d86b0875dfc2596833d5f1ad94a Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:46:19 +0200 Subject: [PATCH 13/43] fix(chat): fold turnless reasoning with its answer --- apps/mobile/src/lib/threadActivity.test.ts | 38 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 3 +- .../chat/MessagesTimeline.logic.test.ts | 18 +++++++++ .../components/chat/MessagesTimeline.logic.ts | 3 +- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 323d5dfbf2cd..928d1c37132d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2805,6 +2805,44 @@ describe("buildThreadFeed", () => { 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("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 f2fd18d63d5c..5bbb882194bd 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1679,8 +1679,7 @@ function deriveThreadFeedTurnFolds( const turnId = entry.type === "message" && (entry.message.role === "assistant" || entry.message.role === "reasoning") - ? (entry.message.turnId ?? - (entry.message.role === "assistant" ? unkeyedResponseTurnId : null)) + ? (entry.message.turnId ?? unkeyedResponseTurnId) : entry.type === "activity-group" ? entry.turnId : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 9bcfd68fe367..5f0bc9b76dea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2048,6 +2048,24 @@ describe("deriveMessagesTimelineRows", () => { expect(nextTurnRows.filter((row) => row.kind === "turn-fold")).toHaveLength(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: [user, thought, answer], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.kind)).toEqual(["message", "turn-fold", "message"]); + }); + 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; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index cbfff671d0f5..4167fe94c691 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -659,8 +659,7 @@ function deriveTurnFolds(input: { const turnId = entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "reasoning") - ? (entry.message.turnId ?? - (entry.message.role === "assistant" ? unkeyedResponseTurnId : null)) + ? (entry.message.turnId ?? unkeyedResponseTurnId) : entry.kind === "work" ? (entry.entry.turnId ?? null) : null; From 5c711f73d71b43d32a140cc2581fba9d0b2f4f4c Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:48:43 +0200 Subject: [PATCH 14/43] fix(chat): place trailing reasoning fold before the answer --- apps/mobile/src/lib/threadActivity.test.ts | 30 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 8 +++-- .../chat/MessagesTimeline.logic.test.ts | 14 +++++++++ .../components/chat/MessagesTimeline.logic.ts | 11 +++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 928d1c37132d..df9a77574060 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2843,6 +2843,36 @@ describe("buildThreadFeed", () => { 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 5bbb882194bd..77987a686b2b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1798,9 +1798,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, }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5f0bc9b76dea..f5cf3ee8887c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2250,6 +2250,20 @@ 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 rows = deriveMessagesTimelineRows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4167fe94c691..93949ee3df3b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -817,10 +817,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, }); From 123942145f7078cad31f351fb0f0daecd37d31f0 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 13:49:59 +0200 Subject: [PATCH 15/43] fix(mobile): keep compaction visible beside folded work --- apps/mobile/src/lib/threadActivity.test.ts | 42 ++++++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 1 + 2 files changed, 43 insertions(+) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index df9a77574060..7ac509888560 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -485,6 +485,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({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 77987a686b2b..d73dd617f67a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1739,6 +1739,7 @@ function deriveThreadFeedTurnFolds( (entry) => (unkeyedResponse || entry.id !== firstAssistantMessageId) && entry.id !== terminalAssistantMessageId && + !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) && !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), ) .map((entry) => entry.id), From 3e23db999937b2c752780f7f77fb7c06f563fd0d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 14:17:16 +0200 Subject: [PATCH 16/43] fix(server): finish OpenCode progress once per part --- .../Layers/ProviderRuntimeIngestion.test.ts | 36 ++++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 35 +++++++++++++----- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index e8d9d3d708db..3fa5c15d8e97 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1543,8 +1543,25 @@ describe("ProviderRuntimeIngestion", () => { emitText("progress-part", "Checking ", "evt-progress-first", "assistant_text"); complete("progress-part", "evt-progress-classified", "progress"); emitText("progress-part", "the reviews.", "evt-progress-second", "assistant_progress_text"); + await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:progress-part" && + message.text === "Checking the reviews." && + message.streaming, + ), + ); 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, @@ -1555,7 +1572,9 @@ describe("ProviderRuntimeIngestion", () => { ) && entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === "assistant:progress-part" && message.text === "Checking the reviews.", + message.id === "assistant:progress-part" && + message.text === "Checking the reviews." && + !message.streaming, ), ); expect( @@ -1566,6 +1585,21 @@ describe("ProviderRuntimeIngestion", () => { ["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 () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index ee98dda3446c..dae1a3f7e0a3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2085,6 +2085,9 @@ const make = Effect.gen(function* () { const messageId = MessageId.make( `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); + if (turnId) { + yield* rememberAssistantMessageId(thread.id, turnId, messageId); + } yield* orchestrationEngine.dispatch({ type: "thread.message.reasoning.delta", commandId: yield* providerCommandId(event, "assistant-progress-delta"), @@ -2094,14 +2097,27 @@ const make = Effect.gen(function* () { ...(turnId ? { turnId } : {}), createdAt: now, }); - yield* orchestrationEngine.dispatch({ - type: "thread.message.reasoning.complete", - commandId: yield* providerCommandId(event, "assistant-progress-complete"), - threadId: thread.id, - messageId, - ...(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* orchestrationEngine.dispatch({ + type: "thread.message.reasoning.complete", + commandId: yield* providerCommandId(event, "assistant-progress-complete"), + threadId: thread.id, + messageId, + createdAt: now, + }); + } } const pauseForUserTurnId = @@ -2426,6 +2442,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 } + : {}), }), ), ), From 425283ecdec3ac1f164bb62bec63455e3a361678 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 14:17:33 +0200 Subject: [PATCH 17/43] fix(web): show settled empty assistant responses --- apps/web/src/components/chat/MessagesTimeline.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a56573e6afde..1084f5749969 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2346,7 +2346,11 @@ 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 = + row.message.streaming && + (row.message.turnId ? row.message.turnId === activity.unsettledTurnId : activity.isWorking); + const messageText = row.message.text || (isStreaming ? "" : "(empty response)"); return ( <> @@ -2363,7 +2367,7 @@ function AssistantTimelineRow({ row }: { row: Extract Date: Thu, 24 Sep 2026 14:19:42 +0200 Subject: [PATCH 18/43] test(web): remove vacuous reasoning markup check --- .../components/chat/MessagesTimeline.test.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 57a7063bb1b4..ef5f30e545a1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1842,25 +1842,6 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("renders reasoning markdown with the same typography as assistant text", () => { - const turnId = TurnId.make("turn-thought"); - const thought = buildAssistantTimelineEntry("**Viewing image first** with `code`."); - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Viewing image first"); - expect(markup).toContain(""); - expect(markup).toContain(" { const turnId = TurnId.make("turn-thought-chunks"); const chunks = ["Launching subagents.", "Selecting direct calls.", "Sequencing the work."]; From d6981982ab1d7f9fd9bf682d9dfc29c88e7bf833 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 14:20:13 +0200 Subject: [PATCH 19/43] docs(chat): describe completed turn disclosure --- docs/user/activity-groups.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/activity-groups.md b/docs/user/activity-groups.md index f8abb1742459..8e57c49277ae 100644 --- a/docs/user/activity-groups.md +++ b/docs/user/activity-groups.md @@ -6,5 +6,5 @@ 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 activity group in the same thread view. +away and back, or collapse and reopen the turn in the same thread view. Select the tool call again to close its details. From 6630252aa77948b24a8f34278ed28b227fd8f529 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 15:51:09 +0200 Subject: [PATCH 20/43] fix(mobile): read error tone from work entry --- apps/mobile/src/lib/threadActivity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d73dd617f67a..e3568ae14d28 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1727,7 +1727,7 @@ function deriveThreadFeedTurnFolds( entries.some( (entry) => entry.type === "activity-group" && - entry.activities.some((activity) => activity.tone === "error"), + entry.activities.some((activity) => activity.workEntry.tone === "error"), )) ) { continue; From 69d0d0f30d79b1c94bf26cd9945fcefe81987a24 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 15:54:07 +0200 Subject: [PATCH 21/43] fix(chat): fold prior turns with stranded streams --- apps/mobile/src/lib/threadActivity.test.ts | 26 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 17 +++++++----- .../chat/MessagesTimeline.logic.test.ts | 10 +++++-- .../components/chat/MessagesTimeline.logic.ts | 12 +-------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 7ac509888560..16b641d2d807 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2748,6 +2748,32 @@ describe("buildThreadFeed", () => { 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("leaves failed work visible when the turn has no answer", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e3568ae14d28..322818c7ff47 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1666,7 +1666,11 @@ function deriveThreadFeedTurnFolds( const groupsByTurnId = new Map(); let pendingUserBoundary: string | null = null; unkeyedResponseTurnId = null; - for (const entry of feed) { + 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 = TurnId.make(`unkeyed-response:${entry.message.id}`); @@ -1686,6 +1690,9 @@ function deriveThreadFeedTurnFolds( if (!turnId) { continue; } + if (isWorking && index > lastUserMessageIndex) { + activeVisualTurnIds.add(turnId); + } let group = groupsByTurnId.get(turnId); if (!group) { group = { @@ -1703,14 +1710,10 @@ function deriveThreadFeedTurnFolds( const activeUnkeyedResponseTurnId = isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { const { entries } = group; - if (isWorking && entries.some((entry) => entry.type === "message" && entry.message.streaming)) { + if (activeVisualTurnIds.has(turnId)) { continue; } - if ( - String(turnId).startsWith("unkeyed-response:") && - (turnId === activeUnkeyedResponseTurnId || - entries.some((entry) => entry.type === "message" && entry.message.streaming)) - ) { + if (String(turnId).startsWith("unkeyed-response:") && turnId === activeUnkeyedResponseTurnId) { continue; } if (turnId === unsettledTurnId) { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f5cf3ee8887c..69a19b525537 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2266,14 +2266,20 @@ describe("deriveMessagesTimelineRows", () => { 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: false, - activeTurnStartedAt: null, + isWorking: true, + runningTurnId: TurnId.make("turn-2"), + activeTurnStartedAt: "2026-01-01T00:00:04Z", turnDiffSummaries: [], supportsConversationRollback: false, }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 93949ee3df3b..5b2e0fd1d029 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -690,17 +690,7 @@ function deriveTurnFolds(input: { const foldsByAnchorEntryId = new Map(); const activeUnkeyedResponseTurnId = input.isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { - if ( - input.isWorking && - group.entries.some((entry) => entry.kind === "message" && entry.message.streaming) - ) { - continue; - } - if ( - String(turnId).startsWith("unkeyed-response:") && - (turnId === activeUnkeyedResponseTurnId || - group.entries.some((entry) => entry.kind === "message" && entry.message.streaming)) - ) { + if (String(turnId).startsWith("unkeyed-response:") && turnId === activeUnkeyedResponseTurnId) { continue; } if (input.unfoldedTurnIds.has(turnId)) { From f39670c908e099f66ce5e0c314548094c743bfc3 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 15:55:15 +0200 Subject: [PATCH 22/43] fix(web): keep compaction visible beside folded work --- .../chat/MessagesTimeline.logic.test.ts | 23 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 10 ++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 69a19b525537..143c803c2971 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2186,6 +2186,29 @@ describe("deriveMessagesTimelineRows", () => { 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(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"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5b2e0fd1d029..ed8f8c427b82 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -728,6 +728,9 @@ function deriveTurnFolds(input: { } const isCompaction = entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"; + if (isCompaction) { + continue; + } const isSingleTrailingActivity = trailingEntryCount === 1 && entry.kind === "work" && @@ -735,12 +738,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. From 92a8ac4441c8b0b3cdf137faa414e31cfbd65c77 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:00:22 +0200 Subject: [PATCH 23/43] fix(server): preserve completed OpenCode turns without an answer --- .../Layers/ProviderRuntimeIngestion.test.ts | 42 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 19 +++++++++ 2 files changed, 61 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3fa5c15d8e97..2ffae8ac2b7a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1439,6 +1439,48 @@ 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.id === `assistant:empty:${turnId}` && !message.streaming, + ), + ); + expect( + thread.messages.find((message: ProviderRuntimeTestMessage) => message.role === "reasoning") + ?.text, + ).toBe("Checked the branch."); + expect( + thread.messages.find( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:empty:${turnId}`, + ), + ).toMatchObject({ role: "assistant", text: "", streaming: false, turnId }); + }); + it("reclassifies completed OpenCode progress text without changing the final answer", async () => { const harness = await createHarness(); const threadId = asThreadId("thread-1"); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index dae1a3f7e0a3..595dc2ae2113 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2450,6 +2450,25 @@ const make = Effect.gen(function* () { ), { concurrency: 1 }, ).pipe(Effect.asVoid); + if ( + String(event.provider) === "opencode" && + event.type === "turn.completed" && + event.payload.state === "completed" && + !(yield* projectionThreadMessages.hasAssistantMessageForTurn({ + threadId: thread.id, + turnId, + streamingOnly: false, + })) + ) { + yield* orchestrationEngine.dispatch({ + type: "thread.message.assistant.complete", + commandId: yield* providerCommandId(event, "empty-assistant-complete"), + threadId: thread.id, + messageId: MessageId.make(`assistant:empty:${turnId}`), + turnId, + createdAt: now, + }); + } yield* clearAssistantMessageIdsForTurn(thread.id, turnId); yield* clearAssistantSegmentStateForTurn(thread.id, turnId); yield* clearAssistantSegmentStateForTurn(thread.id, turnId, "reasoning"); From 8d6f5684b2a3fb4ae66e84d68c1cf2c3af47809e Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:00:30 +0200 Subject: [PATCH 24/43] fix(mobile): show empty completed answers and fold prior work --- .../src/features/threads/ThreadFeed.tsx | 6 ++- apps/mobile/src/lib/threadActivity.test.ts | 41 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 7 +++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 67b2d1bae068..a41ff0853437 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1533,7 +1533,11 @@ function renderFeedEntry( ); } const isUser = message.role === "user"; - const renderedText = renderAssistantCitationsAsText(message.text); + const renderedText = renderAssistantCitationsAsText( + message.role === "assistant" && !message.streaming && message.text.trim().length === 0 + ? "(empty response)" + : message.text, + ); const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 16b641d2d807..679ca0422348 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2776,6 +2776,47 @@ describe("buildThreadFeed", () => { 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: "" }, + }); + }); + it("leaves failed work visible when the turn has no answer", () => { const turnId = TurnId.make("failed-without-answer"); const latestTurn = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 322818c7ff47..c38745454839 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1576,7 +1576,10 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th for (const entry of entries) { // Skip empty messages so they don't break activity grouping. - if (isEmptyMessage(entry)) { + if ( + isEmptyMessage(entry) && + (entry.type !== "message" || !entry.message.id.startsWith("assistant:empty:")) + ) { continue; } @@ -1690,7 +1693,7 @@ function deriveThreadFeedTurnFolds( if (!turnId) { continue; } - if (isWorking && index > lastUserMessageIndex) { + if (isWorking && lastUserMessageIndex >= 0 && index > lastUserMessageIndex) { activeVisualTurnIds.add(turnId); } let group = groupsByTurnId.get(turnId); From a0dbb27500e74ac1389b258a114c14f236e08727 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:00:38 +0200 Subject: [PATCH 25/43] fix(web): label whitespace-only completed responses --- apps/web/src/components/chat/MessagesTimeline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1084f5749969..58d5b116cb53 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2350,7 +2350,8 @@ function AssistantTimelineRow({ row }: { row: Extract 0 ? row.message.text : isStreaming ? "" : "(empty response)"; return ( <> From e0a1820d55b39ae98f58aba9356e0698a40ca769 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:12:56 +0200 Subject: [PATCH 26/43] fix(mobile): classify raw reasoning across separated runs --- apps/mobile/src/lib/threadActivity.test.ts | 27 ++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 33 +++++++++++----------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 679ca0422348..5befd29898ec 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2685,6 +2685,33 @@ describe("buildThreadFeed", () => { 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 = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c38745454839..7e638445a285 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1839,6 +1839,17 @@ export function deriveThreadFeedPresentation( const isWorking = activeWorkStartedAt !== null; const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn, isWorking); const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const turnsWithSummary = new Set( + sourceFeed.flatMap((entry) => + entry.type === "message" && + entry.message.role === "reasoning" && + entry.message.turnId && + (entry.message.id.startsWith("reasoning:summary:") || + entry.message.id.startsWith("assistant:")) + ? [entry.message.turnId] + : [], + ), + ); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedTurnIds.has(fold.turnId)) { @@ -1902,6 +1913,7 @@ export function deriveThreadFeedPresentation( unsettledTurnId, isWorking, run.at(-1) === activeTailGroup, + turnsWithSummary.has(runTurnId), ); index = end - 1; continue; @@ -1964,6 +1976,7 @@ function appendMixedActivityRun( unsettledTurnId: TurnId | null, isWorking: boolean, activeTail: boolean, + hasSummary: boolean, ) { const first = run[0]!; const last = run.at(-1)!; @@ -1975,7 +1988,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 && @@ -1986,7 +1999,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, @@ -2071,26 +2084,14 @@ function appendMixedActivityRun( function groupConsecutiveReasoningMessages( feed: ReadonlyArray, + hasSummary: boolean, ): ThreadFeedEntry[] { const result: ThreadFeedEntry[] = []; - const turnsWithSummary = new Set( - feed.flatMap((entry) => - entry.type === "message" && - entry.message.role === "reasoning" && - entry.message.turnId && - (entry.message.id.startsWith("reasoning:summary:") || - entry.message.id.startsWith("assistant:")) - ? [entry.message.turnId] - : [], - ), - ); const kindOf = (message: OrchestrationThread["messages"][number]) => { if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { return "summary"; } - return (message.turnId && turnsWithSummary.has(message.turnId)) || - message.text.length > 2_000 || - message.text.split("\n", 26).length > 25 + return hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25 ? "raw" : "summary"; }; From 9331e03e1ba4dda5d521c5226530f43b6b03ee2f Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:13:03 +0200 Subject: [PATCH 27/43] fix(opencode): keep length-limited replies visible --- apps/server/src/provider/Layers/OpenCodeAdapter.test.ts | 5 ++--- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 8720940ff389..c3feb14c41a6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7049,7 +7049,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ); it.effect( - "marks OpenCode continuation text as progress while keeping stopped text as output", + "marks tool-call continuation text as progress while keeping length and stop output visible", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -7108,7 +7108,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { event.threadId === threadId && (event.type === "content.delta" || event.type === "item.completed"), ), - Stream.take(9), + Stream.take(8), Stream.runCollect, Effect.forkChild, ); @@ -7129,7 +7129,6 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ["part-progress-0", undefined], ["part-progress-0", "progress"], ["part-progress-1", undefined], - ["part-progress-1", "progress"], ["part-progress-2", undefined], ], ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ce0784da33b6..9de6c6268cb6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2431,8 +2431,9 @@ export function makeOpenCodeAdapter( } if (event.properties.info.role === "assistant") { if ( - promptAdmission?.messageId === event.properties.info.parentID && - event.properties.info.finish === "stop" + 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); @@ -2483,8 +2484,7 @@ export function makeOpenCodeAdapter( if ( part.type === "text" && !part.progressClassified && - (event.properties.info.finish === "tool-calls" || - event.properties.info.finish === "length") + event.properties.info.finish === "tool-calls" ) { yield* emitAssistantProgressCompletion(context, part, turnId, event); } From b77e872352a0bef69fce6e814d1ff573c1e1d63e Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:14:14 +0200 Subject: [PATCH 28/43] fix(mobile): bound unusually long reasoning summaries --- apps/mobile/src/features/threads/ThreadFeed.tsx | 13 ++++++++++++- docs/user/activity-groups.md | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a41ff0853437..d3fdba55a0aa 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1487,7 +1487,7 @@ function renderFeedEntry( if (message.role === "reasoning") { const messages = entry.reasoningMessages ?? [message]; if (entry.reasoningKind === "summary") { - return ( + const summaryContent = ( {messages.map((reasoningMessage) => ( @@ -1503,6 +1503,17 @@ function renderFeedEntry( ); + const longSummary = + messages.reduce((length, reasoningMessage) => length + reasoningMessage.text.length, 0) > + 2_000 || + messages.some((reasoningMessage) => reasoningMessage.text.split("\n", 26).length > 25); + return longSummary ? ( + + {summaryContent} + + ) : ( + summaryContent + ); } return ( Date: Thu, 24 Sep 2026 16:17:39 +0200 Subject: [PATCH 29/43] fix(server): buffer OpenCode progress text before projection --- .../Layers/ProviderRuntimeIngestion.test.ts | 11 +--- .../Layers/ProviderRuntimeIngestion.ts | 51 ++++++++++++++----- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 2ffae8ac2b7a..e27e08bce3ad 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1584,15 +1584,8 @@ describe("ProviderRuntimeIngestion", () => { emitText("progress-part", "Checking ", "evt-progress-first", "assistant_text"); complete("progress-part", "evt-progress-classified", "progress"); - emitText("progress-part", "the reviews.", "evt-progress-second", "assistant_progress_text"); - await waitForThread(harness.readModel, (entry) => - entry.messages.some( - (message: ProviderRuntimeTestMessage) => - message.id === "assistant:progress-part" && - message.text === "Checking the reviews." && - message.streaming, - ), - ); + 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({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 595dc2ae2113..19486a9a27e2 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. @@ -1399,6 +1404,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 +1428,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), @@ -1493,7 +1501,9 @@ const make = Effect.gen(function* () { const hasRenderableText = hasRenderableAssistantText(text); const isReasoning = - input.presentation === "progress" || messageStreamRoleOf(input.messageId) === "reasoning"; + input.presentation === "progress" || + messageStreamRoleOf(input.messageId) === "reasoning" || + Option.isSome(yield* Cache.getOption(progressMessageIds, input.messageId)); if (hasRenderableText) { yield* orchestrationEngine.dispatch({ @@ -2088,15 +2098,26 @@ const make = Effect.gen(function* () { if (turnId) { yield* rememberAssistantMessageId(thread.id, turnId, messageId); } - yield* orchestrationEngine.dispatch({ - type: "thread.message.reasoning.delta", - commandId: yield* providerCommandId(event, "assistant-progress-delta"), - threadId: thread.id, + yield* Cache.set(progressMessageIds, messageId, true); + const streamingMode = yield* resolveResponseStreamingMode(thread.projectId); + const progressMode = streamingMode === "token" ? "paragraph" : streamingMode; + const spillChunk = yield* appendBufferedAssistantText( messageId, - delta: assistantProgressDelta, - ...(turnId ? { turnId } : {}), - createdAt: now, - }); + 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 @@ -2110,12 +2131,16 @@ const make = Effect.gen(function* () { progressTurn.value.state !== "pending" && progressTurn.value.state !== "running"; if (!turnId || isSettledProgressTurn) { - yield* orchestrationEngine.dispatch({ - type: "thread.message.reasoning.complete", - commandId: yield* providerCommandId(event, "assistant-progress-complete"), + 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", }); } } From bbc316b02ea1dbaaa6bc4d1f936c8e78383de2ba Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:18:36 +0200 Subject: [PATCH 30/43] test(opencode): cover length-limited reply without stop --- apps/server/src/provider/Layers/OpenCodeAdapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index c3feb14c41a6..ea4b1944ed77 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -3305,7 +3305,7 @@ 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"); @@ -3370,7 +3370,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { id: "msg-delayed-assistant", role: "assistant", parentID: messageId, - finish: "stop", + finish: "length", }, }, }); From 0f3f8adfd102709a60d125ccc26b89ff02e70990 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:20:07 +0200 Subject: [PATCH 31/43] fix(web): keep streaming work visible before turn state arrives --- apps/web/src/components/chat/MessagesTimeline.logic.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ed8f8c427b82..d89eb2a3de59 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -595,6 +595,16 @@ 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) continue; + const turnId = timelineEntryTurnId(entry); + if (turnId !== null) turnIds.add(turnId); + break; + } + } return turnIds; } From fe19b0ba3d807f95d57738b8578d75ae86f6d508 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 16:23:41 +0200 Subject: [PATCH 32/43] fix(web): keep timeline activity helper private --- apps/web/src/components/chat/MessagesTimeline.logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index d89eb2a3de59..9de22d1f4d2a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -623,7 +623,7 @@ function deriveActiveVisualResponseTurnIds(input: { return turnIds; } -export function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { +function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { return ( entry.toolLifecycleStatus === "inProgress" || (entry.toolLifecycleStatus === undefined && From e067ac0c78a9601138d8c200293788f40f19f13d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 19:41:21 +0200 Subject: [PATCH 33/43] fix(mobile): preserve reasoning and failed work across turn states --- .../src/features/threads/ThreadFeed.tsx | 84 ++++++++---- apps/mobile/src/lib/threadActivity.test.ts | 125 +++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 45 ++++++- 3 files changed, 212 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d3fdba55a0aa..1e950415e5d6 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, @@ -1487,32 +1487,49 @@ function renderFeedEntry( if (message.role === "reasoning") { const messages = entry.reasoningMessages ?? [message]; if (entry.reasoningKind === "summary") { + const text = messages + .map((item) => item.text.trim()) + .filter(Boolean) + .join(" "); + if (text.length === 0) return null; + const longSummary = text.length > 2_000 || text.split("\n", 26).length > 25; + const expanded = props.expandedReasoningMessageIds.has(entry.id); + const preview = text.slice(0, 2_000).split("\n").slice(0, 26).join("\n"); const summaryContent = ( - {messages.map((reasoningMessage) => ( - - ))} + ); - const longSummary = - messages.reduce((length, reasoningMessage) => length + reasoningMessage.text.length, 0) > - 2_000 || - messages.some((reasoningMessage) => reasoningMessage.text.split("\n", 26).length > 25); - return longSummary ? ( - - {summaryContent} - - ) : ( - 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 ( @@ -1521,7 +1538,13 @@ function renderFeedEntry( iconSubtleColor={iconSubtleColor} expanded={props.expandedReasoningMessageIds.has(entry.id)} label={`Thought${messages.length > 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", ]); @@ -2644,7 +2654,7 @@ describe("buildThreadFeed", () => { id: message.id, createdAt: message.createdAt, message, - ...(message.turnId ? { reasoningKind: "summary" } : {}), + reasoningKind: "summary", })), ); }, @@ -2842,6 +2852,23 @@ describe("buildThreadFeed", () => { 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("leaves failed work visible when the turn has no answer", () => { @@ -2886,6 +2913,90 @@ describe("buildThreadFeed", () => { 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"] = [ { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 7e638445a285..c298a1ad290e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -31,6 +31,7 @@ import { toolGroupAction, toolGroupSummaryKind, workEntryIndicatesToolFailure, + workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, type ToolGroupSummaryKind, @@ -1575,11 +1576,8 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th }; for (const entry of entries) { - // Skip empty messages so they don't break activity grouping. - if ( - isEmptyMessage(entry) && - (entry.type !== "message" || !entry.message.id.startsWith("assistant:empty:")) - ) { + // Keep empty assistant responses visible; they are the turn's terminal answer. + if (isEmptyMessage(entry) && (entry.type !== "message" || entry.message.role !== "assistant")) { continue; } @@ -1632,6 +1630,31 @@ export function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): return settled ? null : latestTurn.turnId; } +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; + } + return null; +} + interface ThreadFeedTurnFold { readonly turnId: TurnId; readonly createdAt: string; @@ -1733,7 +1756,11 @@ function deriveThreadFeedTurnFolds( entries.some( (entry) => entry.type === "activity-group" && - entry.activities.some((activity) => activity.workEntry.tone === "error"), + entry.activities.some( + (activity) => + activity.status === "failure" || + workEntryDisplayIndicatesToolFailure(activity.workEntry), + ), )) ) { continue; @@ -1838,7 +1865,7 @@ export function deriveThreadFeedPresentation( ); const isWorking = activeWorkStartedAt !== null; const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn, isWorking); - const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const unsettledTurnId = deriveActiveFeedTurnId(sourceFeed, latestTurn, activeWorkStartedAt); const turnsWithSummary = new Set( sourceFeed.flatMap((entry) => entry.type === "message" && @@ -1919,6 +1946,10 @@ export function deriveThreadFeedPresentation( continue; } } + if (entry.type === "message" && entry.message.role === "reasoning" && runTurnId === null) { + result.push(...groupConsecutiveReasoningMessages([entry], false)); + continue; + } appendPresentedFeedEntry( result, entry, From 16142fea6d7bbd4469d7b315522f24a2a228e31c Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 19:41:35 +0200 Subject: [PATCH 34/43] fix(web): keep live reasoning and work visible during projection races --- .../chat/MessagesTimeline.logic.test.ts | 50 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 11 +++- .../src/components/chat/MessagesTimeline.tsx | 26 +++++++--- 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 143c803c2971..b565b660cbe7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3053,6 +3053,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: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9de22d1f4d2a..5d7b09e7f982 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -599,7 +599,16 @@ function deriveActiveVisualResponseTurnIds(input: { 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) continue; + 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; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 58d5b116cb53..de5f069c561f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2606,6 +2606,7 @@ function SummaryReasoningTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); + const activity = use(TimelineRowActivityCtx); const detailsId = useId(); const messageId = row.messages[0]!.id; const expanded = ctx.expandedReasoningMessageIds.has(messageId); @@ -2613,19 +2614,27 @@ function SummaryReasoningTimelineRow({ .map((message) => message.text.trim()) .filter(Boolean) .join(" "); - if (text.length === 0) { - return row.messages.some((message) => message.streaming) ? : null; - } + const streaming = row.messages.some( + (message) => + message.streaming && + (message.turnId ? message.turnId === activity.unsettledTurnId : activity.isWorking), + ); + if (text.length === 0) return streaming ? : null; const longSummary = text.length > 2_000 || text.split("\n", 26).length > 25; const preview = text.slice(0, 2_000).split("\n").slice(0, 26).join("\n"); return (
-
+
message.streaming)} + isStreaming={streaming} lineBreaks={shouldPreserveAssistantLineBreaks(text)} skills={ctx.skills} headingLevelOffset={MESSAGE_HEADING_LEVEL} @@ -2654,10 +2663,15 @@ function RawReasoningTimelineRow({ row: Extract; }) { const ctx = use(TimelineRowCtx); + 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) => message.streaming); + const streaming = row.messages.some( + (message) => + message.streaming && + (message.turnId ? message.turnId === activity.unsettledTurnId : activity.isWorking), + ); const label = `Thought${row.messages.length > 1 ? ` (×${row.messages.length})` : ""}`; return (
From 1786b1699d64eced3f8eeb80808f2fdae0e56417 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 19:47:32 +0200 Subject: [PATCH 35/43] fix(opencode): bound recent message turn associations --- .../server/src/provider/Layers/OpenCodeAdapter.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 9de6c6268cb6..28d6fdb33907 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -699,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": @@ -2337,7 +2348,7 @@ export function makeOpenCodeAdapter( const parentId = event.properties.info.parentID; const parentTurnId = parentId ? context.messageTurnIdById.get(parentId) : undefined; if (parentTurnId) { - context.messageTurnIdById.set(event.properties.info.id, parentTurnId); + rememberMessageTurnId(context, event.properties.info.id, parentTurnId); } } const assistantMessageId = @@ -3340,7 +3351,7 @@ export function makeOpenCodeAdapter( context.promptAdmission = promptAdmission; context.activeTurnId = turnId; - context.messageTurnIdById.set(messageId, turnId); + rememberMessageTurnId(context, messageId, turnId); if (steeringTurnId === undefined) { context.turnTokenUsage = makeOpenCodeTurnTokenUsageAccumulator(); } From 84ef2d8a4aa800c0053810d5ee1d06b2df69fa7a Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Thu, 24 Sep 2026 20:56:17 +0200 Subject: [PATCH 36/43] fix(web): align reasoning rows with current React and lint rules --- apps/web/src/components/chat/MessagesTimeline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index de5f069c561f..9bb473e91f53 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -62,6 +62,7 @@ import { use, useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, @@ -2683,7 +2684,7 @@ function RawReasoningTimelineRow({ onClick={() => ctx.onToggleReasoning(messageId, !expanded, row.id)} > - + Date: Thu, 24 Sep 2026 21:03:53 +0200 Subject: [PATCH 37/43] fix(chat): keep live reasoning visible and bound summaries --- .../src/features/threads/ThreadFeed.tsx | 18 ++++-- .../chat/MessagesTimeline.logic.test.ts | 55 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 6 +- .../components/chat/MessagesTimeline.test.tsx | 30 ++++++++++ .../src/components/chat/MessagesTimeline.tsx | 17 +++--- 5 files changed, 111 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1e950415e5d6..72a0dcb43ed2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1487,14 +1487,20 @@ function renderFeedEntry( if (message.role === "reasoning") { const messages = entry.reasoningMessages ?? [message]; if (entry.reasoningKind === "summary") { - const text = messages - .map((item) => item.text.trim()) - .filter(Boolean) - .join(" "); + 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 || text.split("\n", 26).length > 25; + 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 = text.slice(0, 2_000).split("\n").slice(0, 26).join("\n"); + const preview = chunks + .slice(0, 25) + .join(" ") + .slice(0, 2_000) + .split("\n") + .slice(0, 25) + .join("\n"); const summaryContent = ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b565b660cbe7..bfb3387b6363 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2646,6 +2646,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: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5d7b09e7f982..ffd67bd584a8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -610,8 +610,10 @@ function deriveActiveVisualResponseTurnIds(input: { continue; } const turnId = timelineEntryTurnId(entry); - if (turnId !== null) turnIds.add(turnId); - break; + if (turnId !== null) { + turnIds.add(turnId); + break; + } } } return turnIds; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ef5f30e545a1..9396640c35cb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1911,6 +1911,36 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Private raw trace."); }); + it("bounds many short summary chunks without separating their visible text", () => { + const turnId = TurnId.make("turn-many-summaries"); + const timelineEntries = Array.from({ length: 26 }, (_, index) => { + const entry = buildAssistantTimelineEntry(`Step ${index + 1}.`); + return { + ...entry, + id: `summary-entry-${index}`, + message: { + ...entry.message, + id: MessageId.make(`reasoning:summary:${index}`), + role: "reasoning" as const, + turnId, + }, + }; + }); + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(1); + expect(markup).toContain("Step 1. Step 2."); + expect(markup).toContain("Show more"); + expect(markup).not.toContain("Step 26."); + }); + it("renders raw reasoning blocks with their original spacing when opened", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9bb473e91f53..e2e5455e4fc0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2611,18 +2611,18 @@ function SummaryReasoningTimelineRow({ const detailsId = useId(); const messageId = row.messages[0]!.id; const expanded = ctx.expandedReasoningMessageIds.has(messageId); - const text = row.messages - .map((message) => message.text.trim()) - .filter(Boolean) - .join(" "); + const chunks = row.messages.map((message) => message.text.trim()).filter(Boolean); + const text = chunks.join(" "); const streaming = row.messages.some( (message) => message.streaming && (message.turnId ? message.turnId === activity.unsettledTurnId : activity.isWorking), ); if (text.length === 0) return streaming ? : null; - const longSummary = text.length > 2_000 || text.split("\n", 26).length > 25; - const preview = text.slice(0, 2_000).split("\n").slice(0, 26).join("\n"); + 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 (
Date: Thu, 24 Sep 2026 21:35:54 +0200 Subject: [PATCH 38/43] fix(opencode): recover missed replies and keep reasoning live --- apps/mobile/src/lib/threadActivity.test.ts | 43 +++++++++ apps/mobile/src/lib/threadActivity.ts | 19 +++- .../Layers/ProjectionPipeline.ts | 17 ++-- .../Layers/ProjectionThreadMessages.test.ts | 39 +++++++++ .../Layers/ProjectionThreadMessages.ts | 28 ++++++ .../Services/ProjectionThreadMessages.ts | 13 +++ .../provider/Layers/OpenCodeAdapter.test.ts | 87 ++++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 31 +++++++ .../src/components/chat/MessagesTimeline.tsx | 27 +++--- 9 files changed, 271 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 57e99618fdb6..5eadb236e27d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2871,6 +2871,49 @@ describe("buildThreadFeed", () => { ]); }); + 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 = { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c298a1ad290e..564c13a7ee7b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1549,6 +1549,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 = () => { @@ -1575,11 +1581,20 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th openGroupActivities = []; }; - for (const entry of entries) { - // Keep empty assistant responses visible; they are the turn's terminal answer. + 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; + } if (entry.type !== "activity") { flushGroup(); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index a53fe4749ada..e83ca444928f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1578,18 +1578,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Option.isSome(existingTurn) && existingTurn.value.assistantMessageId === event.payload.messageId ) { - const messages = yield* projectionThreadMessageRepository.listByThreadId({ - threadId: event.payload.threadId, - }); - const replacement = messages.findLast( - (message) => - message.role === "assistant" && - message.turnId === event.payload.turnId && - message.messageId !== 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: replacement?.messageId ?? null, + assistantMessageId: Option.getOrNull(replacement), }); } return; 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 ea4b1944ed77..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) { @@ -7851,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 28d6fdb33907..ed8dda572210 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1383,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); @@ -1459,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; @@ -1520,6 +1522,35 @@ export function makeOpenCodeAdapter( // 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"), ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e2e5455e4fc0..310f105ed1e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -309,6 +309,12 @@ 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!); @@ -2348,9 +2354,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const activity = use(TimelineRowActivityCtx); - const isStreaming = - row.message.streaming && - (row.message.turnId ? row.message.turnId === activity.unsettledTurnId : activity.isWorking); + const isStreaming = messageIsLiveStreaming(row.message, activity); const messageText = row.message.text.trim().length > 0 ? row.message.text : isStreaming ? "" : "(empty response)"; @@ -2613,11 +2617,7 @@ function SummaryReasoningTimelineRow({ 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) => - message.streaming && - (message.turnId ? message.turnId === activity.unsettledTurnId : activity.isWorking), - ); + const streaming = row.messages.some((message) => messageIsLiveStreaming(message, activity)); if (text.length === 0) return streaming ? : null; const longSummary = text.length > 2_000 || @@ -2668,11 +2668,7 @@ function RawReasoningTimelineRow({ const detailsId = useId(); const messageId = row.messages[0]!.id; const expanded = ctx.expandedReasoningMessageIds.has(messageId); - const streaming = row.messages.some( - (message) => - message.streaming && - (message.turnId ? message.turnId === activity.unsettledTurnId : activity.isWorking), - ); + const streaming = row.messages.some((message) => messageIsLiveStreaming(message, activity)); const label = `Thought${row.messages.length > 1 ? ` (×${row.messages.length})` : ""}`; return (
@@ -2721,10 +2717,7 @@ function RawReasoningTimelineRow({ text={message.text} cwd={ctx.markdownCwd} threadRef={ctx.threadRef ?? undefined} - isStreaming={ - message.streaming && - (message.turnId ? message.turnId === activity.unsettledTurnId : activity.isWorking) - } + isStreaming={messageIsLiveStreaming(message, activity)} lineBreaks skills={ctx.skills} headingLevelOffset={MESSAGE_HEADING_LEVEL} From 95f43582feddc4ffc18925d293206561a229a3e6 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 25 Sep 2026 14:18:05 +0200 Subject: [PATCH 39/43] fix(opencode): address remaining reasoning review findings --- .../src/features/threads/ThreadFeed.tsx | 2 + .../TestProviderAdapter.integration.test.ts | 44 +++++++++++++ .../TestProviderAdapter.integration.ts | 6 +- .../chat/MessagesTimeline.logic.test.ts | 63 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 62 +++++++++--------- 5 files changed, 146 insertions(+), 31 deletions(-) create mode 100644 apps/server/integration/TestProviderAdapter.integration.test.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 72a0dcb43ed2..a05cf404743c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2356,6 +2356,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setupWorkingStartedAt: props.setupWorkingStartedAt, dispatchingMessageId: props.dispatchingMessageId, unsettledTurnId, + isWorking: props.activeWorkStartedAt !== null, copiedRowId, expandedWorkRows, expandedReasoningMessageIds, @@ -2372,6 +2373,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.setupWorkingStartedAt, props.dispatchingMessageId, unsettledTurnId, + props.activeWorkStartedAt, copiedRowId, expandedWorkRows, expandedReasoningMessageIds, 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/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bfb3387b6363..0e1575ca9eec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2084,6 +2084,69 @@ describe("deriveMessagesTimelineRows", () => { ).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 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 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: [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 = [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ffd67bd584a8..7172ceaef8a4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -285,35 +285,42 @@ export type TimelineLatestTurn = Pick< const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; -interface ReasoningTurnStats { +interface ReasoningResponseStats { hasSummary: boolean; } -function reasoningStatsByTurn( +function reasoningStatsByMessage( entries: ReadonlyArray, -): Map { - const stats = new Map(); +): 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; - const key = message.turnId ?? message.id; - const turn = stats.get(key) ?? { hasSummary: false }; + // 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 (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { - turn.hasSummary = true; + response.hasSummary = true; } - stats.set(key, turn); + statsByResponse.set(key, response); + statsByMessageId.set(message.id, response); } - return stats; + return statsByMessageId; } export function reasoningDisplayKind( message: ChatMessage, - turn: ReasoningTurnStats, + response: ReasoningResponseStats, ): "summary" | "raw" { if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { return "summary"; } - return turn.hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25 + return response.hasSummary || + message.text.length > 2_000 || + message.text.split("\n", 26).length > 25 ? "raw" : "summary"; } @@ -1126,12 +1133,9 @@ export function deriveMessagesTimelineRows(input: { hasActivityRow ||= activeWorkRow.active; }; - const reasoningTurns = reasoningStatsByTurn(input.timelineEntries); + const reasoningStats = reasoningStatsByMessage(input.timelineEntries); const displayKind = (message: ChatMessage) => - reasoningDisplayKind( - message, - reasoningTurns.get(message.turnId ?? message.id) ?? { hasSummary: false }, - ); + reasoningDisplayKind(message, reasoningStats.get(message.id) ?? { hasSummary: false }); for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; @@ -1352,7 +1356,9 @@ export function deriveMessagesTimelineRows(input: { reasoningKind, messages, }); - hasActivityRow ||= entryBelongsToActiveTurn(timelineEntry, index); + hasActivityRow ||= + entryBelongsToActiveTurn(timelineEntry, index) && + messages.some((message) => message.streaming); index = cursor - 1; continue; } @@ -1509,8 +1515,8 @@ function replaceStreamingMessageRows( return null; } const replacements = new Map(); - let previousReasoningTurns: Map | null = null; - let nextReasoningTurns: Map | null = null; + 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; @@ -1525,17 +1531,15 @@ function replaceStreamingMessageRows( if (entry.message === previousEntry.message) continue; if (!isStreamingMessageTextUpdate(previousEntry.message, entry.message)) return null; if (entry.message.role === "reasoning") { - previousReasoningTurns ??= reasoningStatsByTurn(previousEntries); - nextReasoningTurns ??= reasoningStatsByTurn(timelineEntries); - const previousTurn = previousReasoningTurns.get( - previousEntry.message.turnId ?? previousEntry.message.id, - ); - const nextTurn = nextReasoningTurns.get(entry.message.turnId ?? entry.message.id); + previousReasoningStats ??= reasoningStatsByMessage(previousEntries); + nextReasoningStats ??= reasoningStatsByMessage(timelineEntries); + const previousStats = previousReasoningStats.get(previousEntry.message.id); + const nextStats = nextReasoningStats.get(entry.message.id); if ( - previousTurn && - nextTurn && - reasoningDisplayKind(previousEntry.message, previousTurn) !== - reasoningDisplayKind(entry.message, nextTurn) + previousStats && + nextStats && + reasoningDisplayKind(previousEntry.message, previousStats) !== + reasoningDisplayKind(entry.message, nextStats) ) { return null; } From a34a394f88ba7622dd4755068e0372ebb224a7d4 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 25 Sep 2026 16:50:36 +0200 Subject: [PATCH 40/43] refactor(chat): share reasoning and turn presentation rules --- .../src/features/threads/ThreadFeed.tsx | 2 + apps/mobile/src/lib/threadActivity.ts | 61 ++++++++----------- .../components/chat/MessagesTimeline.logic.ts | 31 ++++------ packages/client-runtime/package.json | 4 ++ .../src/threadMessagePresentation.ts | 36 +++++++++++ 5 files changed, 80 insertions(+), 54 deletions(-) create mode 100644 packages/client-runtime/src/threadMessagePresentation.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a05cf404743c..717d19b6283c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2526,6 +2526,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds, expandedWorkGroupIds, props.activeWorkStartedAt, + unsettledTurnId, ), props.feed, props.queuedMessages, @@ -2537,6 +2538,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.activeWorkStartedAt, props.feed, props.latestTurn, + unsettledTurnId, ], ); const setupAnchorIndex = presentedFeed.findIndex( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 564c13a7ee7b..3db9243d52fb 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,4 +1,11 @@ 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 { @@ -1638,11 +1645,7 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null { } export function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { - if (!latestTurn) { - return null; - } - const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; - return settled ? null : latestTurn.turnId; + return unsettledTurnId(latestTurn); } export function deriveActiveFeedTurnId( @@ -1684,21 +1687,6 @@ function deriveThreadFeedTurnFolds( ): ReadonlyMap { const firstAssistantMessageIdByTurn = new Map(); const terminalAssistantMessageIdByTurn = new Map(); - let unkeyedResponseTurnId: TurnId | null = null; - for (const entry of feed) { - if (entry.type === "message" && entry.message.role === "user") { - unkeyedResponseTurnId = TurnId.make(`unkeyed-response:${entry.message.id}`); - continue; - } - if (entry.type === "message" && entry.message.role === "assistant") { - const turnId = entry.message.turnId ?? unkeyedResponseTurnId; - if (!turnId) continue; - if (!firstAssistantMessageIdByTurn.has(turnId)) { - firstAssistantMessageIdByTurn.set(turnId, entry.id); - } - terminalAssistantMessageIdByTurn.set(turnId, entry.id); - } - } interface TurnGroup { readonly entries: ThreadFeedEntry[]; @@ -1706,7 +1694,7 @@ function deriveThreadFeedTurnFolds( } const groupsByTurnId = new Map(); let pendingUserBoundary: string | null = null; - unkeyedResponseTurnId = null; + let unkeyedResponseTurnId: TurnId | null = null; const lastUserMessageIndex = feed.findLastIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); @@ -1714,7 +1702,7 @@ function deriveThreadFeedTurnFolds( for (const [index, entry] of feed.entries()) { if (entry.type === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; - unkeyedResponseTurnId = TurnId.make(`unkeyed-response:${entry.message.id}`); + unkeyedResponseTurnId = makeUnkeyedResponseTurnId(entry.message.id); continue; } // Thinking is work, so it folds with the rest of it. A provider that @@ -1731,6 +1719,12 @@ function deriveThreadFeedTurnFolds( 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); } @@ -1754,7 +1748,7 @@ function deriveThreadFeedTurnFolds( if (activeVisualTurnIds.has(turnId)) { continue; } - if (String(turnId).startsWith("unkeyed-response:") && turnId === activeUnkeyedResponseTurnId) { + if (isUnkeyedResponseTurnId(turnId) && turnId === activeUnkeyedResponseTurnId) { continue; } if (turnId === unsettledTurnId) { @@ -1780,7 +1774,7 @@ function deriveThreadFeedTurnFolds( ) { continue; } - const unkeyedResponse = String(turnId).startsWith("unkeyed-response:"); + const unkeyedResponse = isUnkeyedResponseTurnId(turnId); const hiddenEntryIds = new Set( entries .filter( @@ -1867,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) => @@ -1880,14 +1875,16 @@ export function deriveThreadFeedPresentation( ); const isWorking = activeWorkStartedAt !== null; const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn, isWorking); - const unsettledTurnId = deriveActiveFeedTurnId(sourceFeed, latestTurn, activeWorkStartedAt); + 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 && - (entry.message.id.startsWith("reasoning:summary:") || - entry.message.id.startsWith("assistant:")) + isReasoningSummaryMessage(entry.message) ? [entry.message.turnId] : [], ), @@ -2133,14 +2130,8 @@ function groupConsecutiveReasoningMessages( hasSummary: boolean, ): ThreadFeedEntry[] { const result: ThreadFeedEntry[] = []; - const kindOf = (message: OrchestrationThread["messages"][number]) => { - if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { - return "summary"; - } - return hasSummary || message.text.length > 2_000 || message.text.split("\n", 26).length > 25 - ? "raw" - : "summary"; - }; + 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") { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7172ceaef8a4..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"; @@ -302,7 +309,7 @@ function reasoningStatsByMessage( // 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 (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { + if (isReasoningSummaryMessage(message)) { response.hasSummary = true; } statsByResponse.set(key, response); @@ -315,14 +322,7 @@ export function reasoningDisplayKind( message: ChatMessage, response: ReasoningResponseStats, ): "summary" | "raw" { - if (message.id.startsWith("reasoning:summary:") || message.id.startsWith("assistant:")) { - return "summary"; - } - return response.hasSummary || - message.text.length > 2_000 || - message.text.split("\n", 26).length > 25 - ? "raw" - : "summary"; + return sharedReasoningDisplayKind(message, response.hasSummary); } export type MessagesTimelineRow = @@ -561,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 { @@ -679,7 +672,7 @@ function deriveTurnFolds(input: { for (const entry of input.timelineEntries) { if (entry.kind === "message" && entry.message.role === "user") { pendingUserBoundary = entry.message.createdAt; - unkeyedResponseTurnId = TurnId.make(`unkeyed-response:${entry.message.id}`); + unkeyedResponseTurnId = makeUnkeyedResponseTurnId(entry.message.id); continue; } // Reasoning reads inline between tools while the turn is live. Once it @@ -718,7 +711,7 @@ function deriveTurnFolds(input: { const foldsByAnchorEntryId = new Map(); const activeUnkeyedResponseTurnId = input.isWorking ? unkeyedResponseTurnId : null; for (const [turnId, group] of groupsByTurnId) { - if (String(turnId).startsWith("unkeyed-response:") && turnId === activeUnkeyedResponseTurnId) { + if (isUnkeyedResponseTurnId(turnId) && turnId === activeUnkeyedResponseTurnId) { continue; } if (input.unfoldedTurnIds.has(turnId)) { 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/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:"); +} From 51558c4f2ff66dddd74432235c1b5e154836d787 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 25 Sep 2026 16:50:57 +0200 Subject: [PATCH 41/43] fix(chat): avoid unrelated assistant anchors on reclassification --- apps/server/src/orchestration/projector.ts | 11 ++++++----- packages/client-runtime/src/state/threadReducer.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 970c1b209ebc..4ad470dc66c0 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -802,11 +802,12 @@ export function projectEvent( const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); const reclassifiedAssistant = existingMessage?.role === "assistant" && message.role === "reasoning"; - const replacementAssistantMessageId = reclassifiedAssistant - ? (cappedMessages.findLast( - (entry) => entry.role === "assistant" && entry.turnId === message.turnId, - )?.id ?? null) - : null; + 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 } diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 506c7b5e30b1..8a4515be2d00 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -413,11 +413,12 @@ export function applyThreadDetailEvent( const reclassifiedAssistant = message.role === "reasoning" && thread.messages.some((entry) => entry.id === message.id && entry.role === "assistant"); - const replacementAssistantMessageId = reclassifiedAssistant - ? (messages.findLast( - (entry) => entry.role === "assistant" && entry.turnId === message.turnId, - )?.id ?? null) - : null; + 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 From 4d5b30a45333c15b30c10da18ab25ddf5051749d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 25 Sep 2026 16:51:15 +0200 Subject: [PATCH 42/43] fix(opencode): isolate progress parts and omit empty replies --- .../Layers/ProviderRuntimeIngestion.test.ts | 21 ++++--- .../Layers/ProviderRuntimeIngestion.ts | 58 ++++++++++--------- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index e27e08bce3ad..2f984c444e6d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1467,7 +1467,9 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread(harness.readModel, (entry) => entry.messages.some( (message: ProviderRuntimeTestMessage) => - message.id === `assistant:empty:${turnId}` && !message.streaming, + message.role === "reasoning" && + message.text === "Checked the branch." && + !message.streaming, ), ); expect( @@ -1475,10 +1477,8 @@ describe("ProviderRuntimeIngestion", () => { ?.text, ).toBe("Checked the branch."); expect( - thread.messages.find( - (message: ProviderRuntimeTestMessage) => message.id === `assistant:empty:${turnId}`, - ), - ).toMatchObject({ role: "assistant", text: "", streaming: false, turnId }); + thread.messages.some((message: ProviderRuntimeTestMessage) => message.role === "assistant"), + ).toBe(false); }); it("reclassifies completed OpenCode progress text without changing the final answer", async () => { @@ -1497,7 +1497,7 @@ describe("ProviderRuntimeIngestion", () => { itemId: asItemId(item), payload: { streamKind: "assistant_text", delta: text }, }); - const complete = (item: string, event: string, presentation?: "progress") => + const complete = (item: string, event: string, presentation?: "progress", detail?: string) => harness.emit({ type: "item.completed", eventId: asEventId(event), @@ -1510,11 +1510,13 @@ describe("ProviderRuntimeIngestion", () => { 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"); + 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"); @@ -1535,12 +1537,15 @@ describe("ProviderRuntimeIngestion", () => { expect( thread.messages .filter((message: ProviderRuntimeTestMessage) => - ["assistant:progress-part", "assistant:answer-part"].includes(message.id), + ["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."], ]); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 19486a9a27e2..88937cabdc36 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1248,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, }); }); @@ -2333,8 +2349,13 @@ const make = Effect.gen(function* () { const activeAssistantMessageId = turnId ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) : Option.none(); + const activeMessageMatchesItem = + Option.isSome(activeAssistantMessageId) && + (activeAssistantMessageId.value === assistantCompletion.messageId || + activeAssistantMessageId.value.startsWith(`${assistantCompletion.messageId}:segment:`)); const assistantMessageId = - assistantCompletion.presentation === "progress" + assistantCompletion.presentation === "progress" || + (event.provider === "opencode" && !activeMessageMatchesItem) ? assistantCompletion.messageId : Option.getOrElse(activeAssistantMessageId, () => assistantCompletion.messageId); const [existingAssistantMessage, hasAssistantMessagesForTurn] = yield* Effect.all([ @@ -2475,25 +2496,6 @@ const make = Effect.gen(function* () { ), { concurrency: 1 }, ).pipe(Effect.asVoid); - if ( - String(event.provider) === "opencode" && - event.type === "turn.completed" && - event.payload.state === "completed" && - !(yield* projectionThreadMessages.hasAssistantMessageForTurn({ - threadId: thread.id, - turnId, - streamingOnly: false, - })) - ) { - yield* orchestrationEngine.dispatch({ - type: "thread.message.assistant.complete", - commandId: yield* providerCommandId(event, "empty-assistant-complete"), - threadId: thread.id, - messageId: MessageId.make(`assistant:empty:${turnId}`), - turnId, - createdAt: now, - }); - } yield* clearAssistantMessageIdsForTurn(thread.id, turnId); yield* clearAssistantSegmentStateForTurn(thread.id, turnId); yield* clearAssistantSegmentStateForTurn(thread.id, turnId, "reasoning"); From 3e4e64b8eb9360239d27319c8d40dea3701b651c Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 25 Sep 2026 16:56:26 +0200 Subject: [PATCH 43/43] test(web): remove redundant reasoning markup assertions --- .../components/chat/MessagesTimeline.test.tsx | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 9396640c35cb..c0ffa5345831 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1842,105 +1842,6 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); - it("renders adjacent thinking chunks as one compact paragraph", () => { - const turnId = TurnId.make("turn-thought-chunks"); - const chunks = ["Launching subagents.", "Selecting direct calls.", "Sequencing the work."]; - const markup = renderToStaticMarkup( - ({ - id: `thought-entry-${index}`, - kind: "message" as const, - createdAt: MESSAGE_CREATED_AT, - message: { - id: MessageId.make(`reasoning:raw:thought-${index}`), - role: "reasoning" as const, - text, - turnId, - createdAt: MESSAGE_CREATED_AT, - updatedAt: MESSAGE_CREATED_AT, - streaming: false, - }, - }))} - />, - ); - expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(1); - expect(markup).toContain("Launching subagents. Selecting direct calls. Sequencing the work."); - }); - - it("shows a provider summary beside a collapsed raw trace", () => { - const turnId = TurnId.make("turn-mixed-reasoning"); - const summary = buildAssistantTimelineEntry("I will check the API."); - const raw = buildAssistantTimelineEntry("Private raw trace."); - const markup = renderToStaticMarkup( - , - ); - - expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(2); - expect(markup).toContain("I will check the API."); - expect(markup).toContain("Thought"); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).not.toContain("Private raw trace."); - }); - - it("bounds many short summary chunks without separating their visible text", () => { - const turnId = TurnId.make("turn-many-summaries"); - const timelineEntries = Array.from({ length: 26 }, (_, index) => { - const entry = buildAssistantTimelineEntry(`Step ${index + 1}.`); - return { - ...entry, - id: `summary-entry-${index}`, - message: { - ...entry.message, - id: MessageId.make(`reasoning:summary:${index}`), - role: "reasoning" as const, - turnId, - }, - }; - }); - const markup = renderToStaticMarkup( - , - ); - - expect(markup.match(/data-timeline-row-kind="reasoning-run"/g)).toHaveLength(1); - expect(markup).toContain("Step 1. Step 2."); - expect(markup).toContain("Show more"); - expect(markup).not.toContain("Step 26."); - }); - it("renders raw reasoning blocks with their original spacing when opened", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("requestAnimationFrame", () => 0);