Skip to content

Commit de29ad3

Browse files
opencode-agent[bot]thdxr
authored andcommitted
fix(opencode): order legacy message loop by time (#40990)
Co-authored-by: Dax <mail@thdxr.com>
1 parent 6799d6a commit de29ad3

4 files changed

Lines changed: 120 additions & 10 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -602,29 +602,32 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses
602602

603603
// filterCompacted reorders messages for model consumption
604604
// ([compaction-user, summary, ...retained tail..., continue-user]), so array
605-
// position is not chronological. Derive each binding by max id (MessageID
606-
// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail
607-
// assistant doesn't get mistaken for the most recent turn. tasks are
608-
// compaction/subtask parts attached to user messages newer than the latest
609-
// finished assistant — i.e. unprocessed work.
605+
// position is not chronological. IDs are only a deterministic tie-breaker
606+
// because imported messages do not necessarily have monotonic IDs.
610607
export function latest(msgs: WithParts[]) {
611608
let user: User | undefined
612609
let assistant: Assistant | undefined
613610
let finished: Assistant | undefined
614611
for (const msg of msgs) {
615612
const info = msg.info
616-
if (info.role === "user" && (!user || info.id > user.id)) user = info
617-
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
618-
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
613+
if (info.role === "user" && isAfter(info, user)) user = info
614+
if (info.role === "assistant" && isAfter(info, assistant)) assistant = info
615+
if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info
619616
}
620617
const tasks = msgs.flatMap((m) =>
621-
finished && m.info.id <= finished.id
618+
finished && !isAfter(m.info, finished)
622619
? []
623620
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
624621
)
625622
return { user, assistant, finished, tasks }
626623
}
627624

625+
function isAfter(info: Info, other?: Info) {
626+
if (!other) return true
627+
if (info.time.created !== other.time.created) return info.time.created > other.time.created
628+
return info.id > other.id
629+
}
630+
628631
export function fromError(
629632
e: unknown,
630633
ctx: { providerID: ProviderV2.ID; aborted?: boolean },

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1112,7 +1112,7 @@ const layer = Layer.effect(
11121112
lastAssistant?.finish &&
11131113
!["tool-calls"].includes(lastAssistant.finish) &&
11141114
!hasToolCalls &&
1115-
lastUser.id < lastAssistant.id
1115+
lastAssistant.parentID === lastUser.id
11161116
) {
11171117
const orphan = lastAssistantMsg?.parts.find(
11181118
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),

packages/opencode/test/session/message-v2.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,44 @@ describe("session.message-v2.latest", () => {
16221622
] as SessionV1.Part[],
16231623
}
16241624

1625+
test("selects latest messages by creation time when IDs are nonmonotonic", () => {
1626+
const oldUser = { ...userInfo("msg_z_user"), time: { created: 100 } }
1627+
const newUser = { ...userInfo("msg_a_user"), time: { created: 200 } }
1628+
const oldAssistant = {
1629+
...assistantInfo("msg_z_assistant", oldUser.id),
1630+
time: { created: 300 },
1631+
finish: "stop",
1632+
} as SessionV1.Assistant
1633+
const newAssistant = {
1634+
...assistantInfo("msg_a_assistant", newUser.id),
1635+
time: { created: 400 },
1636+
finish: "stop",
1637+
} as SessionV1.Assistant
1638+
1639+
const state = MessageV2.latest([
1640+
{ info: newAssistant, parts: [] },
1641+
{ info: oldUser, parts: [] },
1642+
{ info: oldAssistant, parts: [] },
1643+
{ info: newUser, parts: [] },
1644+
])
1645+
1646+
expect(state.user?.id).toBe(newUser.id)
1647+
expect(state.assistant?.id).toBe(newAssistant.id)
1648+
expect(state.finished?.id).toBe(newAssistant.id)
1649+
})
1650+
1651+
test("uses ID as a deterministic tie-breaker for equal creation times", () => {
1652+
const lower = { ...userInfo("msg_a_user"), time: { created: 100 } }
1653+
const higher = { ...userInfo("msg_z_user"), time: { created: 100 } }
1654+
1655+
const state = MessageV2.latest([
1656+
{ info: higher, parts: [] },
1657+
{ info: lower, parts: [] },
1658+
])
1659+
1660+
expect(state.user?.id).toBe(higher.id)
1661+
})
1662+
16251663
// Regression for double auto-compaction. The reorder in filterCompacted
16261664
// (#27145) returns [compaction-user, summary, ...tail..., continue-user],
16271665
// so picking lastFinished by array position landed on the pre-compaction
@@ -1670,4 +1708,33 @@ describe("session.message-v2.latest", () => {
16701708
expect(state.tasks).toHaveLength(1)
16711709
expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true })
16721710
})
1711+
1712+
test("selects compaction and subtask work after the finished boundary by creation time", () => {
1713+
const finished = {
1714+
...assistantInfo("msg_z_finished", "msg_parent"),
1715+
time: { created: 200 },
1716+
finish: "stop",
1717+
} as SessionV1.Assistant
1718+
const oldTask: SessionV1.WithParts = {
1719+
info: { ...userInfo("msg_z_old"), time: { created: 100 } },
1720+
parts: [{ ...basePart("msg_z_old", "old"), type: "compaction", auto: true }] as SessionV1.Part[],
1721+
}
1722+
const newTask: SessionV1.WithParts = {
1723+
info: { ...userInfo("msg_a_new"), time: { created: 300 } },
1724+
parts: [
1725+
{
1726+
...basePart("msg_a_new", "new"),
1727+
type: "subtask",
1728+
prompt: "inspect",
1729+
description: "inspect ordering",
1730+
agent: "general",
1731+
},
1732+
] as SessionV1.Part[],
1733+
}
1734+
1735+
const state = MessageV2.latest([newTask, { info: finished, parts: [] }, oldTask])
1736+
1737+
expect(state.tasks).toHaveLength(1)
1738+
expect(state.tasks[0]).toMatchObject({ type: "subtask", prompt: "inspect" })
1739+
})
16731740
})

packages/opencode/test/session/prompt.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,46 @@ noLLMServer.instance(
460460
{ config: cfg },
461461
)
462462

463+
noLLMServer.instance(
464+
"loop exits for a completed parent turn with nonmonotonic message IDs",
465+
() =>
466+
Effect.gen(function* () {
467+
const prompt = yield* SessionPrompt.Service
468+
const sessions = yield* Session.Service
469+
const chat = yield* sessions.create({ title: "Pinned" })
470+
const userID = MessageID.make("msg_z_user")
471+
const assistantID = MessageID.make("msg_a_assistant")
472+
yield* sessions.updateMessage({
473+
id: userID,
474+
role: "user",
475+
sessionID: chat.id,
476+
agent: "build",
477+
model: ref,
478+
time: { created: 100 },
479+
})
480+
yield* sessions.updateMessage({
481+
id: assistantID,
482+
role: "assistant",
483+
parentID: userID,
484+
sessionID: chat.id,
485+
mode: "build",
486+
agent: "build",
487+
cost: 0,
488+
path: { cwd: "/tmp", root: "/tmp" },
489+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
490+
modelID: ref.modelID,
491+
providerID: ref.providerID,
492+
time: { created: 200, completed: 201 },
493+
finish: "stop",
494+
})
495+
496+
const result = yield* prompt.loop({ sessionID: chat.id })
497+
498+
expect(result.info.id).toBe(assistantID)
499+
}),
500+
{ config: cfg },
501+
)
502+
463503
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
464504
Effect.gen(function* () {
465505
const { llm } = yield* useServerConfig(providerCfg)

0 commit comments

Comments
 (0)