mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-16 09:33:24 +00:00
fix(session): prevent double auto-compaction from filterCompacted reorder (#27545)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user