From 2a4345f0f5f84a60f91a6ab94d7a095d1e3db3a2 Mon Sep 17 00:00:00 2001 From: Yuxin Zhu Date: Thu, 3 Sep 2026 00:06:06 -0700 Subject: [PATCH] fix(opencode): stop overflow compaction from replaying a message that cannot fit An overflow compaction whose previous summary produced no finished assistant step is a loop, not progress: the replayed user message itself exceeds the window. Fail the turn on the summary row (consuming the pending compaction task) instead of compacting and replaying again. Also refuse the replay outright when its estimated size alone exceeds the usable window. REPL-31509 Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/compaction.ts | 32 +++++ .../opencode/test/session/compaction.test.ts | 110 ++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fa439e4efffa..e085a5c22f1a 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -380,6 +380,38 @@ const layer = Layer.effect( }, } yield* session.updateMessage(msg) + + // NOTE (Yuxin, 2026-09-03, REPL-31509): an overflow compaction whose previous + // summary produced no finished assistant step is a loop, not progress — the + // replayed message itself does not fit. Fail the turn on the summary row so + // the pending compaction task is consumed instead of re-firing next prompt. + // Checked against the full transcript: with a replay, `history` already + // dropped the replayed turn, which is exactly where progress would show. + const previous = prior.at(-1) + const stalled = + input.overflow === true && + previous !== undefined && + !input.messages + .slice(previous.assistantIndex + 1) + .some((m) => m.info.role === "assistant" && m.info.finish && !m.info.error) + const replayTooLarge = + replay !== undefined && Token.estimate(JSON.stringify(replay.parts)) >= usable({ cfg, model }) + if (stalled || replayTooLarge) { + msg.error = new SessionV1.ContextOverflowError({ + message: + "The last message is too large for the model's context window even after compaction. Remove or shorten the large attachment, or start a new chat.", + }).toObject() + msg.finish = "error" + msg.time.completed = Date.now() + yield* session.updateMessage(msg) + yield* Effect.logWarning("compaction stalled on oversized message", { + sessionID: input.sessionID, + stalled, + replayTooLarge, + }) + return "stop" + } + const processor = yield* processors.create({ assistantMessage: msg, sessionID: input.sessionID, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 008f230a2272..10c444d9c2c7 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -895,6 +895,116 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ result: "compact" })), ) + // REPL-31509: an oversized message overflowed, was compacted, replayed, and + // overflowed again ~2,145 times. The second overflow compaction after a + // summary that produced no finished step must fail the turn instead. + itCompaction.instance( + "stops an overflow compaction when the previous summary produced no finished step", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "make this page") + const firstMarker = yield* createUserMessage(session.id, "") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: firstMarker.id, + sessionID: session.id, + type: "compaction", + auto: true, + overflow: true, + }) + yield* createSummaryAssistantMessage(session.id, firstMarker.id, test.directory, "summary") + const replayed = yield* createUserMessage(session.id, "make this page") + yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + mode: "build", + agent: "build", + path: { cwd: test.directory, root: test.directory }, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: replayed.id, + time: { created: Date.now() }, + error: new SessionV1.ContextOverflowError({ message: "prompt is too long" }).toObject(), + }) + const secondMarker = yield* createUserMessage(session.id, "") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: secondMarker.id, + sessionID: session.id, + type: "compaction", + auto: true, + overflow: true, + }) + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: secondMarker.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) + + const summaries = (yield* ssn.messages({ sessionID: session.id })).filter( + (msg) => msg.info.role === "assistant" && msg.info.summary, + ) + const last = summaries.at(-1) + expect(result).toBe("stop") + expect(last?.info.role).toBe("assistant") + if (last?.info.role === "assistant") { + expect(last.info.finish).toBe("error") + expect(JSON.stringify(last.info.error)).toContain("too large for the model's context window") + } + }).pipe(withCompaction({ result: "continue" })), + ) + + itCompaction.instance( + "still compacts on overflow when a finished step followed the previous summary", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "make this page") + const firstMarker = yield* createUserMessage(session.id, "") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: firstMarker.id, + sessionID: session.id, + type: "compaction", + auto: true, + overflow: true, + }) + yield* createSummaryAssistantMessage(session.id, firstMarker.id, test.directory, "summary") + const next = yield* createUserMessage(session.id, "now tweak the header") + yield* createAssistantMessage(session.id, next.id, test.directory) + const secondMarker = yield* createUserMessage(session.id, "") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: secondMarker.id, + sessionID: session.id, + type: "compaction", + auto: true, + overflow: true, + }) + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: secondMarker.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) + + expect(result).toBe("continue") + }).pipe(withCompaction({ result: "continue" })), + ) + it.instance( "adds synthetic continue prompt when auto is enabled", Effect.gen(function* () {