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/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/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* () { 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()