Procházet zdrojové kódy

fix(session): prevent double auto-compaction from filterCompacted reorder (#27545)

Kit Langton před 3 měsíci
rodič
revize
94564f3588

+ 27 - 0
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 },

+ 1 - 13
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.")
 

+ 107 - 0
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 })
+  })
+})