From 34e2429c492495d059cbc63b86d02a58a1b3ca65 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:14:53 -0500 Subject: [PATCH] feat: add experimental.compaction.autocontinue hook to disable auto continuing after compaction (#22361) --- packages/opencode/src/session/compaction.ts | 70 ++++++++++++------- .../opencode/test/session/compaction.test.ts | 57 +++++++++++++++ packages/plugin/src/index.ts | 18 +++++ 3 files changed, 120 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index b280971c76..c4934b625f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -310,31 +310,51 @@ When constructing the summary, try to stick to this template: } if (!replay) { - const continueMsg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: userMessage.agent, - model: userMessage.model, - }) - const text = - (input.overflow - ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" - : "") + - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." - yield* session.updatePart({ - id: PartID.ascending(), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text, - time: { - start: Date.now(), - end: Date.now(), - }, - }) + const info = yield* provider.getProvider(userMessage.model.providerID) + if ( + (yield* plugin.trigger( + "experimental.compaction.autocontinue", + { + sessionID: input.sessionID, + agent: userMessage.agent, + model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID), + provider: { + source: info.source, + info, + options: info.options, + }, + message: userMessage, + overflow: input.overflow === true, + }, + { enabled: true }, + )).enabled + ) { + const continueMsg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: userMessage.agent, + model: userMessage.model, + }) + const text = + (input.overflow + ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" + : "") + + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + yield* session.updatePart({ + id: PartID.ascending(), + messageID: continueMsg.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text, + time: { + start: Date.now(), + end: Date.now(), + }, + }) + } } } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2b0908ee9d..206f417d11 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -244,6 +244,20 @@ function plugin(ready: ReturnType) { }) } +function autocontinue(enabled: boolean) { + return Layer.mock(Plugin.Service)({ + trigger: (name: Name, _input: Input, output: Output) => { + if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output) + return Effect.sync(() => { + ;(output as { enabled: boolean }).enabled = enabled + return output + }) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + }) +} + describe("session.compaction.isOverflow", () => { test("returns true when token count exceeds usable context", async () => { await using tmp = await tmpdir() @@ -671,6 +685,49 @@ describe("session.compaction.process", () => { }) }) + test("allows plugins to disable synthetic continue prompt", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const msg = await user(session.id, "hello") + const rt = runtime("continue", autocontinue(false), wide()) + try { + const msgs = await Session.messages({ sessionID: session.id }) + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }), + ), + ) + + const all = await Session.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("assistant") + expect( + all.some( + (msg) => + msg.info.role === "user" && + msg.parts.some( + (part) => + part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), + ), + ), + ).toBe(false) + } finally { + await rt.dispose() + } + }, + }) + }) + test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 49d995c6f7..d53c23a891 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -304,6 +304,24 @@ export interface Hooks { input: { sessionID: string }, output: { context: string[]; prompt?: string }, ) => Promise + /** + * Called after compaction succeeds and before a synthetic user + * auto-continue message is added. + * + * - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic + * user "continue" turn. + */ + "experimental.compaction.autocontinue"?: ( + input: { + sessionID: string + agent: string + model: Model + provider: ProviderContext + message: UserMessage + overflow: boolean + }, + output: { enabled: boolean }, + ) => Promise "experimental.text.complete"?: ( input: { sessionID: string; messageID: string; partID: string }, output: { text: string },