From 69862dc8605f8dddcde943138dfd5c39fdb64175 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 15:30:17 -0700 Subject: [PATCH 1/2] fix(opencode): keep only the replay after an overflow compaction An overflow compaction replays the overflowing user message but computed the retained tail from the history before it, so filterCompacted kept the original next to the replay and every retry was as large as the failure (REPL-31509: 2,161 identical cycles). The marker now retains nothing before itself when it replays, so the next context is [marker, summary, replay]. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/compaction.ts | 6 ++- .../opencode/test/session/compaction.test.ts | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fa439e4efffa..4aa1ccdec65d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -412,10 +412,12 @@ const layer = Layer.effect( return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + // A replay re-asks the overflowing message, so nothing before this marker may survive next to it. + const tailStartID = replay ? input.parentID : selected.tail_start_id + if (compactionPart && tailStartID && compactionPart.tail_start_id !== tailStartID) { yield* session.updatePart({ ...compactionPart, - tail_start_id: selected.tail_start_id, + tail_start_id: tailStartID, }) } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 008f230a2272..b1ce8e55d9ca 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1163,6 +1163,45 @@ describe("session.compaction.process", () => { }), ) + it.instance( + "overflow replay drops the overflowing message from the retained window", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const big = "x".repeat(600_000) + const first = yield* createUserMessage(session.id, big) + yield* createAssistantMessage(session.id, first.id, "/") + const resent = yield* createUserMessage(session.id, big) + yield* SessionCompaction.use.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: true, + overflow: true, + }) + const msgs = yield* MessageV2.filterCompactedEffect(session.id) + const marker = msgs.at(-1)! + + const result = yield* SessionCompaction.use.process({ + parentID: marker.info.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) + expect(result).toBe("continue") + // The fake processor never stamps finish; filterCompacted only honours a finished summary. + const all = yield* ssn.messages({ sessionID: session.id }) + const summary = all.find((m) => m.info.role === "assistant" && m.info.parentID === marker.info.id)! + yield* ssn.updateMessage({ ...(summary.info as SessionV1.Assistant), finish: "stop" }) + + const context = yield* MessageV2.filterCompactedEffect(session.id) + expect(context.map((m) => m.info.role)).toEqual(["user", "assistant", "user"]) + expect(context.some((m) => m.info.id === resent.id)).toBe(false) + expect(context.at(-1)?.parts.some((part) => part.type === "text" && part.text === big)).toBe(true) + }), + ) + it.instance( "falls back to overflow guidance when no replayable turn exists", Effect.gen(function* () { From 3156f19c7d58e15d77814802b308a044dc8fe0c2 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 15:30:18 -0700 Subject: [PATCH 2/2] fix(opencode): stop after one overflow compaction per turn A message that still overflows after compaction replayed it can never fit; a second consecutive overflow now ends the turn with ContextOverflowError instead of compacting again. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/prompt.ts | 32 +++++++++++---- packages/opencode/test/session/prompt.test.ts | 40 +++++++++++++++++-- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 472c1aecf0ab..0fc8e54dbbf2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -61,6 +61,8 @@ import { LLMEvent } from "@opencode-ai/llm" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false +const MAX_OVERFLOW_COMPACTIONS_PER_TURN = 1 + const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 @@ -1087,6 +1089,7 @@ const layer = Layer.effect( const ctx = yield* InstanceState.context let structured: unknown let step = 0 + let overflowCompactions = 0 const turnStart = Date.now() let firstTokenAt: number | undefined let firstTokenReqStart: number | undefined @@ -1376,15 +1379,28 @@ const layer = Layer.effect( } if (result === "stop") return "break" as const - if (result === "compact") { - yield* compaction.create({ - sessionID, - agent: lastUser.agent, - model: lastUser.model, - auto: true, - overflow: !handle.message.finish, - }) + if (result === "continue") { + overflowCompactions = 0 + return "continue" as const + } + const overflow = !handle.message.finish + // Compaction already replayed this message once; overflowing again means it can never fit. + if (overflow && overflowCompactions >= MAX_OVERFLOW_COMPACTIONS_PER_TURN) { + handle.message.error = new SessionV1.ContextOverflowError({ + message: "Message exceeds the model context limit even after compaction", + }).toObject() + handle.message.finish = "error" + yield* sessions.updateMessage(handle.message) + return "break" as const } + if (overflow) overflowCompactions++ + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + overflow, + }) return "continue" as const }).pipe( Effect.ensuring(instruction.clear(handle.message.id)), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9272720dcb07..0650f9a19ca2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -668,6 +668,36 @@ it.instance("loop stops provider overflow instead of auto-compacting when disabl }), ) +it.instance("loop stops after one overflow compaction when the replay still overflows", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.error(413, { error: { message: "prompt is too long" } }) + yield* llm.text("## Summary") + yield* llm.error(413, { error: { message: "prompt is too long" } }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + const messages = yield* sessions.messages({ sessionID: chat.id }) + + expect(yield* llm.hits).toHaveLength(3) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.error?.name).toBe("ContextOverflowError") + expect(result.info.finish).toBe("error") + } + expect(messages.filter((message) => message.parts.some((part) => part.type === "compaction"))).toHaveLength(1) + }), +) + noLLMServer.instance.skip( "prompt emits v2 prompted and synthetic events (v2 projector disabled)", () => @@ -836,10 +866,12 @@ it.instance("turn.done logs prep_ms from the first request even when ttft captur const capture = Logger.make((options) => { captured.push(options.message) }) - yield* prompt.loop({ sessionID: session.id }).pipe( - Effect.provide(Logger.layer([capture], { mergeWithExisting: true })), - Effect.provide(Layer.succeed(References.MinimumLogLevel, "Debug")), - ) + yield* prompt + .loop({ sessionID: session.id }) + .pipe( + Effect.provide(Logger.layer([capture], { mergeWithExisting: true })), + Effect.provide(Layer.succeed(References.MinimumLogLevel, "Debug")), + ) const message = captured.find((item) => Array.isArray(item) && item[0] === "turn.done") expect(message).toBeDefined()