From 3dd84d629d81e4c23fdf60b5f94e3afe9327e04b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:44:47 +0000 Subject: [PATCH] fix(session-ui): preserve timeline message order (#44652) --- .../session-ui/src/timeline/projection.ts | 88 ++++++++++--------- .../src/timeline/rows-current.test.ts | 88 +++++++++++++++++++ 2 files changed, 135 insertions(+), 41 deletions(-) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 61a74d44e23..ffab708ec2c 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -221,39 +221,33 @@ export namespace Timeline { ) { const rows: TimelineRow.TimelineRow[] = [] const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : [])) + const lastAssistant = assistantMessages.at(-1) const previousUserMessage = index > 0 const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction") - const error = assistantMessages.at(-1)?.error - const retry = assistantMessages.at(-1)?.retry - const interrupted = error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt") - const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) => - contentEntries(message) - .filter((entry) => renderable(entry.content, showReasoning)) - .map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })), - ) - const delegating = assistantPartRefs.some( - (entry) => - entry.content.type === "tool" && - entry.content.name === "subagent" && - (entry.content.state.status === "streaming" || entry.content.state.status === "running"), + const delegating = assistantMessages.some((message) => + message.content.some( + (content) => + content.type === "tool" && + content.name === "subagent" && + (content.state.status === "streaming" || content.state.status === "running"), + ), ) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID })) if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID })) let assistantGroupIndex = 0 - const appendAssistants = (messages: SessionMessageAssistant[]) => { - const ids = new Set(messages.map((message) => message.id)) - const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID)) - const interruptedAt = messages.findIndex( - (message) => - message.error?.type.toLowerCase().includes("abort") || - message.error?.type.toLowerCase().includes("interrupt"), + // An assistant message can produce several rows because its content parts are + // rendered separately. Notices end a segment so none of those rows cross it. + const appendAssistantSegment = (messages: SessionMessageAssistant[]) => { + const refs = messages.flatMap((message, messageIndex) => + contentEntries(message) + .filter((entry) => renderable(entry.content, showReasoning)) + .map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })), ) - const interruptedID = messages[interruptedAt]?.id - const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID) - const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs - const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : [] + const interruptedAt = messages.findIndex((message) => isInterrupted(message.error)) + const before = interruptedAt < 0 ? refs : refs.filter((ref) => ref.messageIndex <= interruptedAt) + const after = interruptedAt < 0 ? [] : refs.filter((ref) => ref.messageIndex > interruptedAt) const appendGroups = (items: typeof refs) => groupContent(items).forEach((group) => { rows.push( @@ -267,29 +261,42 @@ export namespace Timeline { }) appendGroups(before) - if (interruptedAt >= 0 && !compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID })) - appendGroups(after) + if (interruptedAt >= 0) { + if (!compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID })) + appendGroups(after) + } + + if (messages.at(-1) !== lastAssistant) return + if (isActive && lastAssistant?.retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID })) + else if (lastAssistant?.error && !isInterrupted(lastAssistant.error)) + rows.push( + new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(lastAssistant.error.message) }), + ) } let assistantSegment: SessionMessageAssistant[] = [] entries.forEach((entry) => { - if (entry.type === "assistant") { - assistantSegment.push(entry.message) - return + switch (entry.type) { + case "assistant": + assistantSegment.push(entry.message) + return + case "notice": + appendAssistantSegment(assistantSegment) + assistantSegment = [] + rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id })) } - appendAssistants(assistantSegment) - assistantSegment = [] - rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id })) }) - appendAssistants(assistantSegment) + appendAssistantSegment(assistantSegment) if ( isActive && status.type === "busy" && - !error && - !retry && + !lastAssistant?.error && + !lastAssistant?.retry && !delegating && - (showReasoning ? assistantPartRefs.length === 0 : true) + (showReasoning + ? !assistantMessages.some((message) => message.content.some((content) => renderable(content, true))) + : true) ) { const heading = assistantMessages .flatMap((message) => message.content) @@ -299,11 +306,6 @@ export namespace Timeline { rows.push(new TimelineRow.Thinking({ userMessageID: turnID, reasoningHeading: heading })) } - if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID })) - else if (error && !interrupted) { - rows.push(new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(error.message) })) - } - return rows } @@ -321,6 +323,10 @@ export namespace Timeline { } } +function isInterrupted(error: SessionMessageAssistant["error"]) { + return error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt") +} + export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { if (!previous?.length) return rows const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index fc83c1a442f..ca07bd742ae 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -233,6 +233,94 @@ describe("current session timeline rows", () => { expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"]) }) + test("keeps assistant errors and retries before later notices", () => { + const result = Timeline.constructSessionMessageRows( + [ + { id: "msg_user", type: "user", text: "continue", time: { created: 1 } }, + { + id: "msg_blocked", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "partial" }], + error: { type: "provider.content-filter", message: "Provider blocked the response" }, + time: { created: 2, completed: 3 }, + }, + { + id: "msg_model", + type: "model-switched", + model: { id: "next", providerID: "provider" }, + time: { created: 4 }, + }, + ], + true, + { type: "idle" }, + ) + + expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart", "Error", "Notice"]) + + const retry = Timeline.constructSessionMessageRows( + [ + { id: "msg_user", type: "user", text: "retry", time: { created: 1 } }, + { + id: "msg_retry", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + error: { type: "ProviderError", message: "rate limited" }, + retry: { attempt: 2, at: 10, error: { type: "ProviderError", message: "rate limited" } }, + time: { created: 2 }, + }, + { + id: "msg_model", + type: "model-switched", + model: { id: "next", providerID: "provider" }, + time: { created: 3 }, + }, + ], + true, + { type: "retry", attempt: 2, next: 10, message: "rate limited" }, + ) + + expect(retry.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry", "Notice"]) + }) + + test("suppresses an earlier error when the turn recovers across a notice", () => { + const source = [ + { id: "msg_user", type: "user", text: "recover", time: { created: 1 } }, + { + id: "msg_failed", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + error: { type: "ProviderError", message: "temporary failure" }, + time: { created: 2, completed: 3 }, + }, + { + id: "msg_model", + type: "model-switched", + model: { id: "next", providerID: "provider" }, + time: { created: 4 }, + }, + { + id: "msg_recovery", + type: "assistant", + agent: "build", + model: { id: "next", providerID: "provider" }, + content: [{ type: "text", text: "recovered" }], + time: { created: 5, completed: 6 }, + }, + ] satisfies SessionMessageInfo[] + + expect(Timeline.constructSessionMessageRows(source, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual([ + "UserMessage", + "Notice", + "AssistantPart", + ]) + }) + test("does not render the retry error twice", () => { const source = [ { id: "msg_user", type: "user", text: "retry", time: { created: 1 } },