From 2db7ccb453a2673af32637054fa679102e6cbeb6 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:13:11 -0500 Subject: [PATCH] fix(core): restore resilient compaction (#36163) --- packages/core/src/session/compaction.ts | 118 ++++++++++++++------ packages/core/test/session-runner.test.ts | 126 +++++++++++++++++++++- 2 files changed, 205 insertions(+), 39 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index ab5e41e405a..d680ff8f166 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,7 +1,8 @@ export * as SessionCompaction from "./compaction" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" -import { Context, DateTime, Effect, Layer, Stream } from "effect" +import { SessionError } from "@opencode-ai/schema/session-error" +import { Context, Effect, Layer, Stream } from "effect" import { Config } from "../config" import { EventV2 } from "../event" import { makeLocationNode } from "../effect/app-node" @@ -10,6 +11,7 @@ import { SessionEvent } from "./event" import type { SessionMessage } from "./message" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" +import { toSessionError } from "./to-session-error" import { Token } from "../util/token" const DEFAULT_BUFFER = 20_000 @@ -59,7 +61,7 @@ type Dependencies = { readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream } - readonly config: readonly Config.Entry[] + readonly config: Settings } export type AutoInput = { @@ -191,7 +193,7 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly ].join("\n\n") const make = (dependencies: Dependencies) => { - const config = settings(dependencies.config) + const config = dependencies.config const compact = Effect.fn("SessionCompaction.compact")(function* (input: { readonly sessionID: SessionSchema.ID readonly model: Model @@ -202,12 +204,9 @@ const make = (dependencies: Dependencies) => { readonly output?: number readonly inputID?: SessionMessage.ID }) { - const context = input.model.route.defaults.limits?.context - if (context === undefined || context <= 0) return false const output = input.output ?? input.model.route.defaults.limits?.output ?? 0 const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context }) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) - if (Token.estimate(summaryPrompt) > context - summaryOutput) return false yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, reason: input.reason, @@ -216,7 +215,7 @@ const make = (dependencies: Dependencies) => { }) const chunks: string[] = [] - let failed = false + let failure: SessionError.Error | undefined const summarized = yield* dependencies.llm .stream( LLM.request({ @@ -228,7 +227,11 @@ const make = (dependencies: Dependencies) => { ) .pipe( Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) failed = true + if (LLMEvent.is.providerError(event)) + failure = { + type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", + message: event.message, + } if (LLMEvent.is.textDelta(event)) { chunks.push(event.text) return dependencies.events.publish(SessionEvent.Compaction.Delta, { @@ -239,7 +242,12 @@ const make = (dependencies: Dependencies) => { return Effect.void }), Effect.as(true), - Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.catchTag("LLM.Error", (error) => + Effect.sync(() => { + failure = toSessionError(error) + return false + }), + ), Effect.onInterrupt(() => input.reason === "auto" ? dependencies.events.publish(SessionEvent.Compaction.Failed, { @@ -252,11 +260,11 @@ const make = (dependencies: Dependencies) => { ), ) const summary = chunks.join("") - if (!summarized || failed || !summary.trim()) { + if (!summarized || failure || !summary.trim()) { yield* dependencies.events.publish(SessionEvent.Compaction.Failed, { sessionID: input.sessionID, reason: input.reason, - error: { type: "compaction.failed", message: "Compaction produced no summary" }, + error: failure ?? { type: "compaction.failed", message: "Compaction produced no summary" }, inputID: input.inputID, }) return false @@ -269,49 +277,43 @@ const make = (dependencies: Dependencies) => { }) return true }) - const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) { - return yield* compactSelected({ - sessionID: input.sessionID, - messages: input.messages, - model: input.request.model, - reason: "auto", - force: false, - output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0, - }) - }) - const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* ( + const compactAvailable = Effect.fn("SessionCompaction.compactAvailable")(function* ( input: CompactInput & { readonly reason: SessionMessage.Compaction["reason"] - readonly force: boolean readonly output?: number }, ) { - const context = input.model.route.defaults.limits?.context - if (context === undefined || context <= 0) return false const selected = select(input.messages, config.tokens) if (!selected) return false const previousSummary = input.messages.find( (message) => message.type === "compaction" && message.status === "completed", ) - const hasHead = selected.head.length > 0 - if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false - const forcedShortContext = input.force && !hasHead + const summarizeRecent = selected.head.length === 0 const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : "" return yield* compact({ sessionID: input.sessionID, model: input.model, reason: input.reason, previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined, - context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter( + context: (summarizeRecent ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter( Boolean, ), - recent: forcedShortContext ? "" : selected.recent, + recent: summarizeRecent ? "" : selected.recent, output: input.output, inputID: input.inputID, }) }) + const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) { + return yield* compactAvailable({ + sessionID: input.sessionID, + messages: input.messages, + model: input.request.model, + reason: "auto", + output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0, + }) + }) const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) { - return yield* compactSelected({ ...input, reason: "manual", force: true }) + return yield* compactAvailable({ ...input, reason: "manual" }) }) const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) { if (!config.auto) return false @@ -323,7 +325,34 @@ const make = (dependencies: Dependencies) => { context - Math.max(output, config.buffer) ) return false - return yield* compactAfterOverflow(input) + const selected = select(input.messages, config.tokens) + if (!selected) return false + const previousSummary = input.messages.find( + (message) => message.type === "compaction" && message.status === "completed", + ) + if (!selected.head && previousSummary?.type !== "compaction") return false + const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : "" + const summaryContext = [previousRecent, selected.head].filter(Boolean) + const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) + if ( + Token.estimate( + buildPrompt({ + previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined, + context: summaryContext, + }), + ) > + context - summaryOutput + ) + return false + return yield* compact({ + sessionID: input.sessionID, + model: input.request.model, + reason: "auto", + previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined, + context: summaryContext, + recent: selected.recent, + output, + }) }) return { compactIfNeeded, @@ -339,13 +368,34 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const config = yield* Config.Service const models = yield* SessionRunnerModel.Service - const compaction = make({ events, llm, config: yield* config.entries() }) + const configured = settings(yield* config.entries()) + const compaction = make({ events, llm, config: configured }) return Service.of({ compactIfNeeded: compaction.compactIfNeeded, compactAfterOverflow: compaction.compactAfterOverflow, compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) { - const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!select(input.messages, configured.tokens)) { + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.session.id, + reason: "manual", + error: { type: "compaction.unavailable", message: "Nothing to compact yet" }, + inputID: input.inputID, + }) + return false + } + const resolved = yield* models.resolve(input.session).pipe( + Effect.catch((error) => + events + .publish(SessionEvent.Compaction.Failed, { + sessionID: input.session.id, + reason: "manual", + error: toSessionError(error), + inputID: input.inputID, + }) + .pipe(Effect.as(undefined)), + ), + ) if (!resolved) return false return yield* compaction.compactManual({ sessionID: input.session.id, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index f870c5b2b77..73e1cbf4611 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -128,6 +128,11 @@ const compactModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }), }) +const undersizedContextModel = Model.make({ + id: "undersized-context", + provider: "fake", + route: OpenAIChat.route.with({ limits: { context: 1, output: 1_000 } }), +}) const recoveryModel = Model.make({ id: "recovery", provider: "fake", @@ -1518,11 +1523,12 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("settles an admitted manual compaction that cannot start", () => + it.effect("explains when manual compaction has no history", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const compaction = yield* session.compact({ sessionID }) + modelResolveHook = Effect.die("model resolution should not run") yield* session.resume(sessionID) @@ -1531,7 +1537,7 @@ describe("SessionRunnerLLM", () => { type: "compaction", status: "failed", reason: "manual", - error: { message: "Compaction could not start" }, + error: { type: "compaction.unavailable", message: "Nothing to compact yet" }, }) expect( (yield* recordedEventTypes(sessionID)).filter( @@ -1541,10 +1547,73 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("manually compacts when the model has no context limit", () => + Effect.gen(function* () { + const session = yield* setup + response = reply.text("Earlier answer", "text-manual-unknown-history") + yield* admit(session, "Earlier question") + yield* session.resume(sessionID) + + requests.length = 0 + response = reply.text("Manual summary", "text-manual-unknown-summary") + const compaction = yield* session.compact({ sessionID }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(userTexts(requests[0])[0]).toContain("Earlier question") + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "completed", + summary: "Manual summary", + }) + }), + ) + + it.effect("preserves provider errors from manual compaction", () => + Effect.gen(function* () { + const session = yield* setup + response = reply.text("Earlier answer", "text-manual-provider-history") + yield* admit(session, "Earlier question") + yield* session.resume(sessionID) + + response = [LLMEvent.providerError({ message: "summary unavailable" })] + const compaction = yield* session.compact({ sessionID }) + yield* session.resume(sessionID) + + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + error: { type: "provider.error", message: "summary unavailable" }, + }) + }), + ) + + it.effect("preserves typed provider failures from manual compaction", () => + Effect.gen(function* () { + const session = yield* setup + response = reply.text("Earlier answer", "text-manual-failure-history") + yield* admit(session, "Earlier question") + yield* session.resume(sessionID) + + responseStream = Stream.fail(providerUnavailable()) + const compaction = yield* session.compact({ sessionID }) + yield* session.resume(sessionID) + + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + error: { type: "provider.transport", message: "Provider unavailable" }, + }) + }), + ) + it.effect("settles an admitted manual compaction when pre-start resolution throws", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup + response = reply.text("Earlier answer", "text-manual-resolution-history") + yield* admit(session, "Earlier question") + yield* session.resume(sessionID) + const compaction = yield* session.compact({ sessionID }) modelResolveHook = Effect.die("model resolution failed") @@ -1643,6 +1712,46 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("recovers from provider context overflow without a configured context limit", () => + Effect.gen(function* () { + const session = yield* setupOverflowRecovery + currentModel = model + responses = [ + [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], + reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"), + reply.text("Recovered", "text-final-unknown-limit"), + ] + yield* admit(session, "Continue") + yield* session.resume(sessionID) + + expect(requests).toHaveLength(3) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "compaction", summary: "## Objective\n- Recover unknown limit" }, + { type: "assistant", finish: "stop" }, + ]) + }), + ) + + it.effect("recovers from provider context overflow despite an undersized configured context limit", () => + Effect.gen(function* () { + const session = yield* setupOverflowRecovery + currentModel = undersizedContextModel + responses = [ + [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], + reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"), + reply.text("Recovered", "text-final-undersized-limit"), + ] + yield* admit(session, "Continue") + yield* session.resume(sessionID) + + expect(requests).toHaveLength(3) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "compaction", summary: "## Objective\n- Recover undersized limit" }, + { type: "assistant", finish: "stop" }, + ]) + }), + ) + it.effect("persists a second context overflow after one recovery", () => Effect.gen(function* () { const session = yield* setupOverflowRecovery @@ -1702,7 +1811,14 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) const context = yield* session.context(sessionID) - expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" })) + expect(context).toContainEqual( + expect.objectContaining({ + type: "compaction", + status: "failed", + reason: "auto", + error: { type: "provider.error", message: "summary unavailable" }, + }), + ) expect(context.slice(-3)).toMatchObject([ { type: "user", text: "Continue" }, { type: "compaction", status: "failed", reason: "auto" },