From 36a93842d3ed526a3bba08392fc4f2b1dbb121fe Mon Sep 17 00:00:00 2001 From: Stefan Weil Date: Thu, 24 Sep 2026 15:17:18 +0200 Subject: [PATCH] fix(session): drop oldest turns when compaction head exceeds model context When the serialized conversation head no longer fits the model's usable context, compaction gave up with "Session too large to compact" and the session became unusable. Shrink the head from the oldest complete turns instead so the summarizer request fits the model context. Assisted-by: OpenCode / qwen3.8-27b-thinking (Alibaba Cloud) Signed-off-by: Stefan Weil --- packages/opencode/src/session/compaction.ts | 45 ++++++++++++++++++- .../opencode/test/session/compaction.test.ts | 37 +++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..3f1e2a0b9973 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -31,6 +31,9 @@ const TOOL_OUTPUT_MAX_CHARS = 2_000 const PRUNE_PROTECTED_TOOLS = ["skill"] const MIN_PRESERVE_RECENT_TOKENS = 2_000 const MAX_PRESERVE_RECENT_TOKENS = 15_000 +// Prompt template, previous summary, and plugin context that accompany the +// serialized head in the summarizer request. +const SUMMARIZER_PROMPT_RESERVE = 8_000 type Turn = { start: number end: number @@ -162,6 +165,40 @@ function splitTurn(input: { }) } +// The summarizer request only carries the serialized head. When it no longer +// fits the model's usable context, drop the oldest complete turns instead of +// giving up with "session too large to compact". +function shrinkHeadToBudget(input: { + messages: SessionV1.WithParts[] + model: Provider.Model + budget: number + estimate: (input: { messages: SessionV1.WithParts[]; model: Provider.Model }) => Effect.Effect +}) { + return Effect.gen(function* () { + let total = yield* input.estimate({ messages: input.messages, model: input.model }) + if (total <= input.budget) return input.messages + const all = turns(input.messages) + let start = 0 + let i = 0 + while (total > input.budget && i < all.length - 1) { + const next = all[i + 1] + total -= yield* input.estimate({ + messages: input.messages.slice(start, next.start), + model: input.model, + }) + start = next.start + i++ + } + if (start === 0) return input.messages + yield* Effect.logInfo("head exceeds budget; dropping oldest turns", { + dropped: start, + kept: input.messages.length - start, + budget: input.budget, + }) + return input.messages.slice(start) + }) +} + export interface Interface { readonly isOverflow: (input: { tokens: SessionV1.Assistant["tokens"] @@ -375,7 +412,13 @@ const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const msgs = structuredClone(selected.head) + const head = yield* shrinkHeadToBudget({ + messages: selected.head, + model, + budget: Math.max(0, usable({ cfg, model, outputTokenMax: flags.outputTokenMax }) - SUMMARIZER_PROMPT_RESERVE), + estimate, + }) + const msgs = structuredClone(head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const conversation = msgs.map(serialize).filter(Boolean).join("\n\n") const nextPrompt = diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c76dd98b8614..03165e5219ef 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1051,6 +1051,43 @@ describe("session.compaction.process", () => { { git: true }, ) + itCompaction.instance( + "drops oldest turns when the head exceeds the model context budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "x".repeat(24_000)) + yield* createUserMessage(session.id, "y".repeat(8_000)) + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + expect(result).toBe("continue") + expect(captured).toContain("yyyy") + expect(captured).not.toContain("xxxx") + }).pipe( + withCompaction({ + llm: stub.llmLayer, + provider: ProviderTest.fake({ model: createModel({ context: 20_000, output: 8_000 }) }), + config: cfg({ preserve_recent_tokens: 100 }), + }), + ) + }, + { git: true }, + ) + itCompaction.instance( "retains a split turn suffix when a later message fits the preserve token budget", () => {