mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 17:32:20 +00:00
fix(session-ui): preserve timeline message order (#44652)
This commit is contained in:
parent
279062d3c9
commit
3dd84d629d
2 changed files with 135 additions and 41 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue