From 574b2c21708baf872faa358152c1fa705437aa6c Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 22 Apr 2026 20:37:32 +0530 Subject: [PATCH] fix(session): improve session compaction (#23870) --- .../opencode/src/agent/prompt/compaction.txt | 19 +- packages/opencode/src/session/compaction.ts | 198 ++++++++++--- packages/opencode/src/session/message-v2.ts | 14 +- .../opencode/test/session/compaction.test.ts | 279 ++++++++++++++++-- .../opencode/test/session/message-v2.test.ts | 70 +++++ .../test/session/messages-pagination.test.ts | 64 ++++ 6 files changed, 561 insertions(+), 83 deletions(-) diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt index c5831bb30e..c7cb838bba 100644 --- a/packages/opencode/src/agent/prompt/compaction.txt +++ b/packages/opencode/src/agent/prompt/compaction.txt @@ -1,16 +1,9 @@ -You are a helpful AI assistant tasked with summarizing conversations. +You are an anchored context summarization assistant for coding sessions. -When asked to summarize, provide a detailed but concise summary of the older conversation history. -The most recent turns may be preserved verbatim outside your summary, so focus on information that would still be needed to continue the work with that recent context available. -Focus on information that would be helpful for continuing the conversation, including: -- What was done -- What is currently being worked on -- Which files are being modified -- What needs to be done next -- Key user requests, constraints, or preferences that should persist -- Important technical decisions and why they were made +Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work. -Your summary should be comprehensive enough to provide context but concise enough to be quickly understood. +If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts. -Do not respond to any questions in the conversation, only output the summary. -Respond in the same language the user used in the conversation. +Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. + +Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation. diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 037543064e..defdb870d7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -32,16 +32,105 @@ export const Event = { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 +const TOOL_OUTPUT_MAX_CHARS = 2_000 const PRUNE_PROTECTED_TOOLS = ["skill"] const DEFAULT_TAIL_TURNS = 2 const MIN_PRESERVE_RECENT_TOKENS = 2_000 const MAX_PRESERVE_RECENT_TOKENS = 8_000 +const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged: +--- +## Goal +- [single-sentence task summary] + +## Constraints & Preferences +- [user constraints, preferences, specs, or "(none)"] + +## Progress +### Done +- [completed work or "(none)"] + +### In Progress +- [current work or "(none)"] + +### Blocked +- [blockers or "(none)"] + +## Key Decisions +- [decision and why, or "(none)"] + +## Next Steps +- [ordered next actions or "(none)"] + +## Critical Context +- [important technical facts, errors, open questions, or "(none)"] + +## Relevant Files +- [file or directory path: why it matters, or "(none)"] +--- + +Rules: +- Keep every section, even when empty. +- Use terse bullets, not prose paragraphs. +- Preserve exact file paths, commands, error strings, and identifiers when known. +- Do not mention the summary process or that context was compacted.` type Turn = { start: number end: number id: MessageID } +type Tail = { + start: number + id: MessageID +} + +type CompletedCompaction = { + userIndex: number + assistantIndex: number + summary: string | undefined +} + +function summaryText(message: MessageV2.WithParts) { + const text = message.parts + .filter((part): part is MessageV2.TextPart => part.type === "text") + .map((part) => part.text.trim()) + .filter(Boolean) + .join("\n\n") + .trim() + return text || undefined +} + +function completedCompactions(messages: MessageV2.WithParts[]) { + const users = new Map() + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.info.role !== "user") continue + if (!msg.parts.some((part) => part.type === "compaction")) continue + users.set(msg.info.id, i) + } + + return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => { + if (msg.info.role !== "assistant") return [] + if (!msg.info.summary || !msg.info.finish || msg.info.error) return [] + const userIndex = users.get(msg.info.parentID) + if (userIndex === undefined) return [] + return [{ userIndex, assistantIndex, summary: summaryText(msg) }] + }) +} + +function buildPrompt(input: { previousSummary?: string; context: string[] }) { + const anchor = input.previousSummary + ? [ + "Update the anchored summary below using the conversation history above.", + "Preserve still-true details, remove stale details, and merge in the new facts.", + "", + input.previousSummary, + "", + ].join("\n") + : "Create a new anchored summary from the conversation history above." + return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") +} + function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -67,6 +156,31 @@ function turns(messages: MessageV2.WithParts[]) { return result } +function splitTurn(input: { + messages: MessageV2.WithParts[] + turn: Turn + model: Provider.Model + budget: number + estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect +}) { + return Effect.gen(function* () { + if (input.budget <= 0) return undefined + if (input.turn.end - input.turn.start <= 1) return undefined + for (let start = input.turn.start + 1; start < input.turn.end; start++) { + const size = yield* input.estimate({ + messages: input.messages.slice(start, input.turn.end), + model: input.model, + }) + if (size > input.budget) continue + return { + start, + id: input.messages[start]!.info.id, + } satisfies Tail + } + return undefined + }) +} + export interface Interface { readonly isOverflow: (input: { tokens: MessageV2.Assistant["tokens"] @@ -147,18 +261,28 @@ export const layer: Layer.Layer< }), { concurrency: 1 }, ) - if (sizes.at(-1)! > budget) { - log.info("tail fallback", { budget, size: sizes.at(-1) }) - return { head: input.messages, tail_start_id: undefined } - } let total = 0 - let keep: Turn | undefined + let keep: Tail | undefined for (let i = recent.length - 1; i >= 0; i--) { + const turn = recent[i]! const size = sizes[i] - if (total + size > budget) break - total += size - keep = recent[i] + if (total + size <= budget) { + total += size + keep = { start: turn.start, id: turn.id } + continue + } + const remaining = budget - total + const split = yield* splitTurn({ + messages: input.messages, + turn, + model: input.model, + budget: remaining, + estimate, + }) + if (split) keep = split + else if (!keep) log.info("tail fallback", { budget, size, total }) + break } if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } @@ -192,17 +316,15 @@ export const layer: Layer.Layer< if (msg.info.role === "assistant" && msg.info.summary) break loop for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { const part = msg.parts[partIndex] - if (part.type === "tool") - if (part.state.status === "completed") { - if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue - if (part.state.time.compacted) break loop - const estimate = Token.estimate(part.state.output) - total += estimate - if (total > PRUNE_PROTECT) { - pruned += estimate - toPrune.push(part) - } - } + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue + if (part.state.time.compacted) break loop + const estimate = Token.estimate(part.state.output) + total += estimate + if (total <= PRUNE_PROTECT) continue + pruned += estimate + toPrune.push(part) } } @@ -263,8 +385,11 @@ export const layer: Layer.Layer< : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) const cfg = yield* config.get() const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages + const prior = completedCompactions(history) + const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) + const previousSummary = prior.at(-1)?.summary const selected = yield* select({ - messages: history, + messages: history.filter((_, index) => !hidden.has(index)), cfg, model, }) @@ -274,34 +399,13 @@ export const layer: Layer.Layer< { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const defaultPrompt = `When constructing the summary, try to stick to this template: ---- -## Goal - -[What goal(s) is the user trying to accomplish?] - -## Instructions - -- [What important instructions did the user give you that are relevant] -- [If there is a plan or spec, include information about it so next agent can continue using it] - -## Discoveries - -[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work] - -## Accomplished - -[What work has been completed, what work is still in progress, and what work is left?] - -## Relevant files / directories - -[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.] ----` - - const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) + const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -345,7 +449,7 @@ export const layer: Layer.Layer< ...modelMessages, { role: "user", - content: [{ type: "text", text: prompt }], + content: [{ type: "text", text: nextPrompt }], }, ], model, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 123f7b5401..980dd4da84 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -319,6 +319,12 @@ export const ToolStateCompleted = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateCompleted = Types.DeepMutable> +function truncateToolOutput(text: string, maxChars?: number) { + if (!maxChars || text.length <= maxChars) return text + const omitted = text.length - maxChars + return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` +} + export const ToolStateError = Schema.Struct({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Any), @@ -700,7 +706,7 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -839,7 +845,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { - const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output + const outputText = part.state.time.compacted + ? "[Old tool result content cleared]" + : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) // For providers that don't support media in tool results, extract media files @@ -955,7 +963,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 0e2b179f00..2188d8d7cd 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -143,6 +143,43 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string return msg } +async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + } + await svc.updateMessage(msg) + await svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +} + +async function lastCompactionPart(sessionID: SessionID) { + return (await svc.messages({ sessionID })).at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") +} + function fake( input: Parameters[0], result: "continue" | "compact", @@ -946,12 +983,9 @@ describe("session.compaction.process", () => { ), ) - const part = (await svc.messages({ sessionID: session.id })) - .at(-2) - ?.parts.find((item) => item.type === "compaction") - + const part = await lastCompactionPart(session.id) expect(part?.type).toBe("compaction") - if (part?.type === "compaction") expect(part.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBe(keep.id) } finally { await rt.dispose() } @@ -991,12 +1025,9 @@ describe("session.compaction.process", () => { ), ) - const part = (await svc.messages({ sessionID: session.id })) - .at(-2) - ?.parts.find((item) => item.type === "compaction") - + const part = await lastCompactionPart(session.id) expect(part?.type).toBe("compaction") - if (part?.type === "compaction") expect(part.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBe(keep.id) } finally { await rt.dispose() } @@ -1042,12 +1073,9 @@ describe("session.compaction.process", () => { ), ) - const part = (await svc.messages({ sessionID: session.id })) - .at(-2) - ?.parts.find((item) => item.type === "compaction") - + const part = await lastCompactionPart(session.id) expect(part?.type).toBe("compaction") - if (part?.type === "compaction") expect(part.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("yyyy") } finally { await rt.dispose() @@ -1103,12 +1131,9 @@ describe("session.compaction.process", () => { ), ) - const part = (await svc.messages({ sessionID: session.id })) - .at(-2) - ?.parts.find((item) => item.type === "compaction") - + const part = await lastCompactionPart(session.id) expect(part?.type).toBe("compaction") - if (part?.type === "compaction") expect(part.tail_start_id).toBeUndefined() + expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("recent image turn") expect(captured).toContain("Attached image/png: big.png") } finally { @@ -1118,6 +1143,76 @@ describe("session.compaction.process", () => { }) }) + test("retains a split turn suffix when a later message fits the preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const recent = await user(session.id, "recent turn") + const large = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: large.id, + sessionID: session.id, + type: "text", + text: "z".repeat(2_000), + }) + const keep = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keep.id, + sessionID: session.id, + type: "text", + text: "keep tail", + }) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + expect(captured).toContain("zzzz") + expect(captured).not.toContain("keep tail") + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered[0]?.info.id).toBe(keep.id) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + } finally { + await rt.dispose() + } + }, + }) + }) + test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -1530,6 +1625,80 @@ describe("session.compaction.process", () => { }) }) + test("anchors repeated compactions with the previous summary", async () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older context") + await user(session.id, "keep this turn") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide()) + try { + let msgs = await svc.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + await user(session.id, "latest turn") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + } finally { + await rt.dispose() + } + }, + }) + }) + test("keeps recent pre-compaction turns across repeated compactions", async () => { const stub = llm() stub.push(reply("summary one")) @@ -1604,6 +1773,76 @@ describe("session.compaction.process", () => { }, }) }) + + test("ignores previous summaries when sizing the retained tail", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const keep = await user(session.id, "keep this turn") + const keepReply = await assistant(session.id, keep.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) + + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) + + const recent = await user(session.id, "recent turn") + const recentReply = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) + + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 55ae65c560..231d58c21a 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -585,6 +585,76 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("truncates tool output when requested", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "abcdefghij", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 4 })).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { + type: "text", + value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]", + }, + }, + ], + }, + ]) + }) + test("converts assistant tool error into error-text tool result", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index d8dcf5e7cb..df2d18b9f1 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -837,6 +837,70 @@ describe("MessageV2.filterCompacted", () => { }) }) + test("retains an assistant tail when compaction starts inside a turn", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "tail reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, a3) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary", + }) + + const u3 = await addUser(session.id, "third") + const a4 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a4, + type: "text", + text: "third reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4]) + + await svc.remove(session.id) + }, + }) + }) + test("prefers latest compaction boundary when repeated compactions exist", async () => { await Instance.provide({ directory: root,