Skip to content

Commit 94564f3

Browse files
authored
fix(session): prevent double auto-compaction from filterCompacted reorder (anomalyco#27545)
1 parent 855bda8 commit 94564f3

3 files changed

Lines changed: 135 additions & 13 deletions

File tree

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,33 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses
10671067
return filterCompacted(stream(sessionID))
10681068
})
10691069

1070+
// filterCompacted reorders messages for model consumption
1071+
// ([compaction-user, summary, ...retained tail..., continue-user]), so array
1072+
// position is not chronological. Derive each binding by max id (MessageID
1073+
// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail
1074+
// assistant doesn't get mistaken for the most recent turn. tasks are
1075+
// compaction/subtask parts attached to user messages newer than the latest
1076+
// finished assistant — i.e. unprocessed work.
1077+
export function latest(msgs: WithParts[]) {
1078+
let user: User | undefined
1079+
let assistant: Assistant | undefined
1080+
let finished: Assistant | undefined
1081+
for (const msg of msgs) {
1082+
const info = msg.info
1083+
if (info.role === "user" && (!user || info.id > user.id)) user = info
1084+
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
1085+
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
1086+
}
1087+
const tasks = msgs.flatMap((m) =>
1088+
finished && m.info.id <= finished.id
1089+
? []
1090+
: m.parts.filter(
1091+
(p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask",
1092+
),
1093+
)
1094+
return { user, assistant, finished, tasks }
1095+
}
1096+
10701097
export function fromError(
10711098
e: unknown,
10721099
ctx: { providerID: ProviderID; aborted?: boolean },

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,19 +1654,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
16541654

16551655
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
16561656

1657-
let lastUser: MessageV2.User | undefined
1658-
let lastAssistant: MessageV2.Assistant | undefined
1659-
let lastFinished: MessageV2.Assistant | undefined
1660-
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
1661-
for (let i = msgs.length - 1; i >= 0; i--) {
1662-
const msg = msgs[i]
1663-
if (!lastUser && msg.info.role === "user") lastUser = msg.info
1664-
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
1665-
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
1666-
if (lastUser && lastFinished) break
1667-
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
1668-
if (task && !lastFinished) tasks.push(...task)
1669-
}
1657+
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)
16701658

16711659
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
16721660

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,3 +1547,110 @@ describe("session.message-v2.fromError", () => {
15471547
expect(result.name).toBe("MessageAbortedError")
15481548
})
15491549
})
1550+
1551+
describe("session.message-v2.latest", () => {
1552+
const TAIL_USER = MessageID.make("msg_001")
1553+
const OVERFLOW_ASSISTANT = MessageID.make("msg_002")
1554+
const COMPACTION_USER = MessageID.make("msg_003")
1555+
const SUMMARY_ASSISTANT = MessageID.make("msg_004")
1556+
const CONTINUE_USER = MessageID.make("msg_005")
1557+
const NEW_COMPACTION_USER = MessageID.make("msg_006")
1558+
1559+
const tailUser: MessageV2.WithParts = {
1560+
info: userInfo(TAIL_USER),
1561+
parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "original prompt" }] as MessageV2.Part[],
1562+
}
1563+
1564+
const overflowAssistant: MessageV2.WithParts = {
1565+
info: {
1566+
...assistantInfo(OVERFLOW_ASSISTANT, TAIL_USER),
1567+
finish: "tool-calls",
1568+
tokens: { input: 280_000, output: 200, reasoning: 0, cache: { read: 0, write: 0 }, total: 280_200 },
1569+
} as MessageV2.Assistant,
1570+
parts: [],
1571+
}
1572+
1573+
const compactionUser: MessageV2.WithParts = {
1574+
info: userInfo(COMPACTION_USER),
1575+
parts: [
1576+
{
1577+
...basePart(COMPACTION_USER, "p1"),
1578+
type: "compaction",
1579+
auto: true,
1580+
tail_start_id: TAIL_USER,
1581+
},
1582+
] as MessageV2.Part[],
1583+
}
1584+
1585+
const summaryAssistant: MessageV2.WithParts = {
1586+
info: {
1587+
...assistantInfo(SUMMARY_ASSISTANT, COMPACTION_USER),
1588+
summary: true,
1589+
finish: "stop",
1590+
tokens: { input: 150_000, output: 1_500, reasoning: 0, cache: { read: 0, write: 0 }, total: 151_500 },
1591+
} as MessageV2.Assistant,
1592+
parts: [],
1593+
}
1594+
1595+
const continueUser: MessageV2.WithParts = {
1596+
info: userInfo(CONTINUE_USER),
1597+
parts: [
1598+
{
1599+
...basePart(CONTINUE_USER, "p1"),
1600+
type: "text",
1601+
text: "Continue if you have next steps...",
1602+
synthetic: true,
1603+
metadata: { compaction_continue: true },
1604+
},
1605+
] as MessageV2.Part[],
1606+
}
1607+
1608+
// Regression for double auto-compaction. The reorder in filterCompacted
1609+
// (#27145) returns [compaction-user, summary, ...tail..., continue-user],
1610+
// so picking lastFinished by array position landed on the pre-compaction
1611+
// overflow assistant and bypassed the `summary !== true` overflow guard
1612+
// in SessionPrompt.runLoop, firing a second compaction.create immediately.
1613+
test("finished is the chronologically-latest finished assistant, not the array-latest", () => {
1614+
const filtered = MessageV2.filterCompacted([
1615+
continueUser,
1616+
summaryAssistant,
1617+
compactionUser,
1618+
overflowAssistant,
1619+
tailUser,
1620+
])
1621+
1622+
const state = MessageV2.latest(filtered)
1623+
1624+
expect(state.finished?.id).toBe(SUMMARY_ASSISTANT)
1625+
expect(state.finished?.summary).toBe(true)
1626+
expect(state.user?.id).toBe(CONTINUE_USER)
1627+
expect(state.tasks).toEqual([])
1628+
})
1629+
1630+
test("a fresh compaction-user newer than the latest summary surfaces in tasks", () => {
1631+
const newCompactionUser: MessageV2.WithParts = {
1632+
info: userInfo(NEW_COMPACTION_USER),
1633+
parts: [
1634+
{
1635+
...basePart(NEW_COMPACTION_USER, "p1"),
1636+
type: "compaction",
1637+
auto: true,
1638+
},
1639+
] as MessageV2.Part[],
1640+
}
1641+
1642+
const state = MessageV2.latest([
1643+
tailUser,
1644+
overflowAssistant,
1645+
compactionUser,
1646+
summaryAssistant,
1647+
continueUser,
1648+
newCompactionUser,
1649+
])
1650+
1651+
expect(state.finished?.id).toBe(SUMMARY_ASSISTANT)
1652+
expect(state.user?.id).toBe(NEW_COMPACTION_USER)
1653+
expect(state.tasks).toHaveLength(1)
1654+
expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true })
1655+
})
1656+
})

0 commit comments

Comments
 (0)