From 94564f3588149c5f254c385e67e583178d469a92 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 14 May 2026 13:56:12 -0400 Subject: [PATCH] fix(session): prevent double auto-compaction from filterCompacted reorder (#27545) --- packages/opencode/src/session/message-v2.ts | 27 +++++ packages/opencode/src/session/prompt.ts | 14 +-- .../opencode/test/session/message-v2.test.ts | 107 ++++++++++++++++++ 3 files changed, 135 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 869ef979f2..b25aa17fde 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1067,6 +1067,33 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses return filterCompacted(stream(sessionID)) }) +// 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. +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 + } + const tasks = msgs.flatMap((m) => + finished && m.info.id <= finished.id + ? [] + : m.parts.filter( + (p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask", + ), + ) + return { user, assistant, finished, tasks } +} + export function fromError( e: unknown, ctx: { providerID: ProviderID; aborted?: boolean }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a8a7d150c3..e8b8452478 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1654,19 +1654,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the let msgs = yield* MessageV2.filterCompactedEffect(sessionID) - let lastUser: MessageV2.User | undefined - let lastAssistant: MessageV2.Assistant | undefined - let lastFinished: MessageV2.Assistant | undefined - let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] - for (let i = msgs.length - 1; i >= 0; i--) { - const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info - if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info - if (lastUser && lastFinished) break - const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") - if (task && !lastFinished) tasks.push(...task) - } + const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs) if (!lastUser) throw new Error("No user message found in stream. This should never happen.") diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index f742b7afc8..82bed0e9cc 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1547,3 +1547,110 @@ describe("session.message-v2.fromError", () => { expect(result.name).toBe("MessageAbortedError") }) }) + +describe("session.message-v2.latest", () => { + const TAIL_USER = MessageID.make("msg_001") + const OVERFLOW_ASSISTANT = MessageID.make("msg_002") + const COMPACTION_USER = MessageID.make("msg_003") + const SUMMARY_ASSISTANT = MessageID.make("msg_004") + const CONTINUE_USER = MessageID.make("msg_005") + const NEW_COMPACTION_USER = MessageID.make("msg_006") + + const tailUser: MessageV2.WithParts = { + info: userInfo(TAIL_USER), + parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "original prompt" }] as MessageV2.Part[], + } + + const overflowAssistant: MessageV2.WithParts = { + info: { + ...assistantInfo(OVERFLOW_ASSISTANT, TAIL_USER), + finish: "tool-calls", + tokens: { input: 280_000, output: 200, reasoning: 0, cache: { read: 0, write: 0 }, total: 280_200 }, + } as MessageV2.Assistant, + parts: [], + } + + const compactionUser: MessageV2.WithParts = { + info: userInfo(COMPACTION_USER), + parts: [ + { + ...basePart(COMPACTION_USER, "p1"), + type: "compaction", + auto: true, + tail_start_id: TAIL_USER, + }, + ] as MessageV2.Part[], + } + + const summaryAssistant: MessageV2.WithParts = { + info: { + ...assistantInfo(SUMMARY_ASSISTANT, COMPACTION_USER), + summary: true, + finish: "stop", + tokens: { input: 150_000, output: 1_500, reasoning: 0, cache: { read: 0, write: 0 }, total: 151_500 }, + } as MessageV2.Assistant, + parts: [], + } + + const continueUser: MessageV2.WithParts = { + info: userInfo(CONTINUE_USER), + parts: [ + { + ...basePart(CONTINUE_USER, "p1"), + type: "text", + text: "Continue if you have next steps...", + synthetic: true, + metadata: { compaction_continue: true }, + }, + ] as MessageV2.Part[], + } + + // 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 + // overflow assistant and bypassed the `summary !== true` overflow guard + // in SessionPrompt.runLoop, firing a second compaction.create immediately. + test("finished is the chronologically-latest finished assistant, not the array-latest", () => { + const filtered = MessageV2.filterCompacted([ + continueUser, + summaryAssistant, + compactionUser, + overflowAssistant, + tailUser, + ]) + + const state = MessageV2.latest(filtered) + + expect(state.finished?.id).toBe(SUMMARY_ASSISTANT) + expect(state.finished?.summary).toBe(true) + expect(state.user?.id).toBe(CONTINUE_USER) + expect(state.tasks).toEqual([]) + }) + + test("a fresh compaction-user newer than the latest summary surfaces in tasks", () => { + const newCompactionUser: MessageV2.WithParts = { + info: userInfo(NEW_COMPACTION_USER), + parts: [ + { + ...basePart(NEW_COMPACTION_USER, "p1"), + type: "compaction", + auto: true, + }, + ] as MessageV2.Part[], + } + + const state = MessageV2.latest([ + tailUser, + overflowAssistant, + compactionUser, + summaryAssistant, + continueUser, + newCompactionUser, + ]) + + expect(state.finished?.id).toBe(SUMMARY_ASSISTANT) + expect(state.user?.id).toBe(NEW_COMPACTION_USER) + expect(state.tasks).toHaveLength(1) + expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true }) + }) +})