From db581e47a3a6f4900a6289ad7fddec60fec44e1c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:08:23 +0000 Subject: [PATCH] fix(opencode): order legacy message loop by time (#40990) Co-authored-by: Dax --- packages/opencode/src/session/message-v2.ts | 21 +++--- packages/opencode/src/session/prompt.ts | 2 +- .../opencode/test/session/message-v2.test.ts | 67 +++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 40 +++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3e..9b3f2c46f40 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -577,29 +577,32 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses // filterCompacted reorders messages for model consumption // ([compaction-user, summary, ...retained tail..., continue-user]), so array -// position is not chronological. Derive each binding by max id (MessageID -// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail -// assistant doesn't get mistaken for the most recent turn. tasks are -// compaction/subtask parts attached to user messages newer than the latest -// finished assistant — i.e. unprocessed work. +// position is not chronological. IDs are only a deterministic tie-breaker +// because imported messages do not necessarily have monotonic IDs. export function latest(msgs: WithParts[]) { let user: User | undefined let assistant: Assistant | undefined let finished: Assistant | undefined for (const msg of msgs) { const info = msg.info - if (info.role === "user" && (!user || info.id > user.id)) user = info - if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info - if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info + if (info.role === "user" && isAfter(info, user)) user = info + if (info.role === "assistant" && isAfter(info, assistant)) assistant = info + if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info } const tasks = msgs.flatMap((m) => - finished && m.info.id <= finished.id + finished && !isAfter(m.info, finished) ? [] : m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"), ) return { user, assistant, finished, tasks } } +function isAfter(info: Info, other?: Info) { + if (!other) return true + if (info.time.created !== other.time.created) return info.time.created > other.time.created + return info.id > other.id +} + export function fromError( e: unknown, ctx: { providerID: ProviderV2.ID; aborted?: boolean }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960..22b1d7d99a2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1112,7 +1112,7 @@ const layer = Layer.effect( lastAssistant?.finish && !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && - lastUser.id < lastAssistant.id + lastAssistant.parentID === lastUser.id ) { const orphan = lastAssistantMsg?.parts.find( (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aeddb..734a30e4245 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1611,6 +1611,44 @@ describe("session.message-v2.latest", () => { ] as SessionV1.Part[], } + test("selects latest messages by creation time when IDs are nonmonotonic", () => { + const oldUser = { ...userInfo("msg_z_user"), time: { created: 100 } } + const newUser = { ...userInfo("msg_a_user"), time: { created: 200 } } + const oldAssistant = { + ...assistantInfo("msg_z_assistant", oldUser.id), + time: { created: 300 }, + finish: "stop", + } as SessionV1.Assistant + const newAssistant = { + ...assistantInfo("msg_a_assistant", newUser.id), + time: { created: 400 }, + finish: "stop", + } as SessionV1.Assistant + + const state = MessageV2.latest([ + { info: newAssistant, parts: [] }, + { info: oldUser, parts: [] }, + { info: oldAssistant, parts: [] }, + { info: newUser, parts: [] }, + ]) + + expect(state.user?.id).toBe(newUser.id) + expect(state.assistant?.id).toBe(newAssistant.id) + expect(state.finished?.id).toBe(newAssistant.id) + }) + + test("uses ID as a deterministic tie-breaker for equal creation times", () => { + const lower = { ...userInfo("msg_a_user"), time: { created: 100 } } + const higher = { ...userInfo("msg_z_user"), time: { created: 100 } } + + const state = MessageV2.latest([ + { info: higher, parts: [] }, + { info: lower, parts: [] }, + ]) + + expect(state.user?.id).toBe(higher.id) + }) + // Regression for double auto-compaction. The reorder in filterCompacted // (#27145) returns [compaction-user, summary, ...tail..., continue-user], // so picking lastFinished by array position landed on the pre-compaction @@ -1659,4 +1697,33 @@ describe("session.message-v2.latest", () => { expect(state.tasks).toHaveLength(1) expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true }) }) + + test("selects compaction and subtask work after the finished boundary by creation time", () => { + const finished = { + ...assistantInfo("msg_z_finished", "msg_parent"), + time: { created: 200 }, + finish: "stop", + } as SessionV1.Assistant + const oldTask: SessionV1.WithParts = { + info: { ...userInfo("msg_z_old"), time: { created: 100 } }, + parts: [{ ...basePart("msg_z_old", "old"), type: "compaction", auto: true }] as SessionV1.Part[], + } + const newTask: SessionV1.WithParts = { + info: { ...userInfo("msg_a_new"), time: { created: 300 } }, + parts: [ + { + ...basePart("msg_a_new", "new"), + type: "subtask", + prompt: "inspect", + description: "inspect ordering", + agent: "general", + }, + ] as SessionV1.Part[], + } + + const state = MessageV2.latest([newTask, { info: finished, parts: [] }, oldTask]) + + expect(state.tasks).toHaveLength(1) + expect(state.tasks[0]).toMatchObject({ type: "subtask", prompt: "inspect" }) + }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf4..5a0176abc9b 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -460,6 +460,46 @@ noLLMServer.instance( { config: cfg }, ) +noLLMServer.instance( + "loop exits for a completed parent turn with nonmonotonic message IDs", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + const userID = MessageID.make("msg_z_user") + const assistantID = MessageID.make("msg_a_assistant") + yield* sessions.updateMessage({ + id: userID, + role: "user", + sessionID: chat.id, + agent: "build", + model: ref, + time: { created: 100 }, + }) + yield* sessions.updateMessage({ + id: assistantID, + role: "assistant", + parentID: userID, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: 200, completed: 201 }, + finish: "stop", + }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + + expect(result.info.id).toBe(assistantID) + }), + { config: cfg }, +) + it.instance("loop exits without an LLM request for interrupted orphan tool calls", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg)