From 931ebcdeae20fdac47da72e61ff18ad32ec128a9 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:20:50 +0800 Subject: [PATCH 1/2] feat(compaction): preserve original user prompt across compaction --- .../opencode/src/server/routes/session.ts | 1 + packages/opencode/src/session/compaction.ts | 20 ++++++ packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/session/prompt.ts | 2 + .../opencode/test/session/compaction.test.ts | 68 +++++++++++++++++++ .../opencode/test/session/message-v2.test.ts | 27 +++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 7 files changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 1195529e06a..18b9e3651af 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -535,6 +535,7 @@ export const SessionRoutes = lazy(() => modelID: body.modelID, }, auto: body.auto, + prompt: SessionCompaction.lastPrompt(msgs), }) await SessionPrompt.loop({ sessionID }) return c.json(true) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 9245426057c..f13da5d4d15 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -228,6 +228,23 @@ When constructing the summary, try to stick to this template: return "continue" } + export function lastPrompt(msgs: MessageV2.WithParts[]) { + const match = msgs.findLast((m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && !p.synthetic)) + if (!match) return undefined + const text = match.parts + .filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("\n") + return text || undefined + } + + const PROMPT_MAX = 2500 + + export function truncate(text: string, max = PROMPT_MAX) { + if (text.length <= max) return text + return text.slice(0, max) + "..." + } + export const create = fn( z.object({ sessionID: Identifier.schema("session"), @@ -237,8 +254,10 @@ When constructing the summary, try to stick to this template: modelID: z.string(), }), auto: z.boolean(), + prompt: z.string().optional(), }), async (input) => { + const prompt = input.prompt ? truncate(input.prompt) : undefined const msg = await Session.updateMessage({ id: Identifier.ascending("message"), role: "user", @@ -255,6 +274,7 @@ When constructing the summary, try to stick to this template: sessionID: msg.sessionID, type: "compaction", auto: input.auto, + prompt, }) }, ) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227..f1af8362f37 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -196,6 +196,7 @@ export namespace MessageV2 { export const CompactionPart = PartBase.extend({ type: z.literal("compaction"), auto: z.boolean(), + prompt: z.string().optional(), }).meta({ ref: "CompactionPart", }) @@ -573,7 +574,7 @@ export namespace MessageV2 { if (part.type === "compaction") { userMessage.parts.push({ type: "text", - text: "What did we do so far?", + text: part.prompt ? `[Compacted]\n\nOriginal request: ${part.prompt}` : "[Compacted]", }) } if (part.type === "subtask") { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 43ad9a09d39..1429a19f976 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -549,6 +549,7 @@ export namespace SessionPrompt { agent: lastUser.agent, model: lastUser.model, auto: true, + prompt: SessionCompaction.lastPrompt(msgs), }) continue } @@ -709,6 +710,7 @@ export namespace SessionPrompt { agent: lastUser.agent, model: lastUser.model, auto: true, + prompt: SessionCompaction.lastPrompt(msgs), }) } continue diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 452926d12e1..7b39b30af21 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { SessionCompaction } from "../../src/session/compaction" +import { MessageV2 } from "../../src/session/message-v2" import { Token } from "../../src/util/token" import { Instance } from "../../src/project/instance" import { Log } from "../../src/util/log" @@ -227,6 +228,73 @@ describe("session.compaction.isOverflow", () => { }) }) +describe("session.compaction.truncate", () => { + test("returns text unchanged when within limit", () => { + const text = "a".repeat(2500) + expect(SessionCompaction.truncate(text)).toBe(text) + }) + + test("truncates and appends ellipsis when over limit", () => { + const text = "a".repeat(2501) + const result = SessionCompaction.truncate(text) + expect(result).toBe("a".repeat(2500) + "...") + expect(result.length).toBe(2503) + }) + + test("respects custom max", () => { + expect(SessionCompaction.truncate("abcdef", 3)).toBe("abc...") + }) + + test("returns empty string as-is", () => { + expect(SessionCompaction.truncate("")).toBe("") + }) +}) + +describe("session.compaction.lastPrompt", () => { + const user = (id: string, parts: MessageV2.Part[]) => + ({ + info: { + id, + role: "user", + sessionID: "s1", + time: { created: 0 }, + agent: "user", + model: { providerID: "test", modelID: "test" }, + }, + parts, + }) as unknown as MessageV2.WithParts + + const part = (id: string, msgID: string, text: string, synthetic?: boolean) => + ({ + id, + messageID: msgID, + sessionID: "s1", + type: "text", + text, + ...(synthetic ? { synthetic } : {}), + }) as MessageV2.Part + + test("returns last non-synthetic user text", () => { + expect(SessionCompaction.lastPrompt([user("m1", [part("p1", "m1", "hello"), part("p2", "m1", "world")])])).toBe( + "hello\nworld", + ) + }) + + test("skips synthetic text parts", () => { + expect( + SessionCompaction.lastPrompt([user("m1", [part("p1", "m1", "real"), part("p2", "m1", "synthetic", true)])]), + ).toBe("real") + }) + + test("returns undefined when no user messages", () => { + const msg = { + info: { id: "m1", role: "assistant", sessionID: "s1", time: { created: 0 } }, + parts: [], + } as unknown as MessageV2.WithParts + expect(SessionCompaction.lastPrompt([msg])).toBeUndefined() + }) +}) + describe("util.token.estimate", () => { test("estimates tokens from text (4 chars per token)", () => { const text = "x".repeat(4000) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index c043754bdb4..110b7780f8d 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -256,13 +256,38 @@ describe("session.message-v2.toModelMessage", () => { filename: "img.png", data: "https://example.com/img.png", }, - { type: "text", text: "What did we do so far?" }, + { type: "text", text: "[Compacted]" }, { type: "text", text: "The following tool was executed by the user" }, ], }, ]) }) + test("compaction part with prompt includes original request", () => { + const messageID = "m-user" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(messageID), + parts: [ + { + ...basePart(messageID, "p1"), + type: "compaction", + auto: true, + prompt: "fix the login bug", + }, + ] as MessageV2.Part[], + }, + ] + + expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "[Compacted]\n\nOriginal request: fix the login bug" }], + }, + ]) + }) + test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index efb7e202e12..77ed832da17 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -505,6 +505,7 @@ export type CompactionPart = { messageID: string type: "compaction" auto: boolean + prompt?: string } export type Part = From 8516fae46f15aa2c94974e4c4aac3f93cd42dc89 Mon Sep 17 00:00:00 2001 From: leohenon <77656081+lhenon999@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:37:58 +0800 Subject: [PATCH 2/2] fix(compaction): exclude ignored text parts from lastPrompt --- packages/opencode/src/session/compaction.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f13da5d4d15..c8cfb48577e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -229,10 +229,12 @@ When constructing the summary, try to stick to this template: } export function lastPrompt(msgs: MessageV2.WithParts[]) { - const match = msgs.findLast((m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && !p.synthetic)) + const match = msgs.findLast( + (m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && !p.synthetic && !p.ignored), + ) if (!match) return undefined const text = match.parts - .filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic) + .filter((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic && !p.ignored) .map((p) => p.text) .join("\n") return text || undefined