fix(session-ui): preserve timeline message order (#44652)

This commit is contained in:
opencode-agent[bot] 2026-08-24 09:44:47 +00:00 committed by GitHub
parent 279062d3c9
commit 3dd84d629d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 135 additions and 41 deletions

View file

@ -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))

View file

@ -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 } },