diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ef007fe74d32..961b7bc1772f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -35,6 +35,8 @@ export const Event = { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 const TOOL_OUTPUT_MAX_CHARS = 2_000 +const RECENT_TAIL_AUDIT_MAX_CHARS = 4_000 +const RECENT_TAIL_AUDIT_PART_MAX_CHARS = 1_000 const PRUNE_PROTECTED_TOOLS = ["skill"] const DEFAULT_TAIL_TURNS = 2 const MIN_PRESERVE_RECENT_TOKENS = 2_000 @@ -61,7 +63,7 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside ", ].join("\n") : "Create a new anchored summary from the conversation history above." - return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") + return [anchor, SUMMARY_TEMPLATE, recentTailAudit(input.tail), ...input.context].filter(Boolean).join("\n\n") +} + +function recentTailAudit(messages: MessageV2.WithParts[]) { + const text = messages + .flatMap((msg) => { + const parts = msg.parts + .flatMap(recentTailPartText) + .map((part) => truncate(part.trim(), RECENT_TAIL_AUDIT_PART_MAX_CHARS)) + .filter(Boolean) + if (!parts.length) return [] + return [`### ${msg.info.role}\n${parts.join("\n")}`] + }) + .join("\n\n") + if (!text) return undefined + return [ + "", + "Use this text-only view only to reconcile Done, In Progress, Blocked, and Next Steps. Do not copy it wholesale into the summary.", + truncate(text, RECENT_TAIL_AUDIT_MAX_CHARS), + "", + ].join("\n") +} + +function recentTailPartText(part: MessageV2.Part) { + if (part.type === "text") return [part.text] + if (part.type !== "tool") return [] + if (part.state.status === "completed") return [`tool:${part.tool}\n${part.state.title}\n${part.state.output}`] + if (part.state.status === "error") return [`tool:${part.tool} error\n${part.state.error}`] + return [] +} + +function truncate(text: string, max: number) { + if (text.length <= max) return text + return `${text.slice(0, max).trimEnd()}\n[truncated]` } function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { @@ -248,10 +285,10 @@ export const layer = Layer.effect( model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS - if (limit <= 0) return { head: input.messages, tail_start_id: undefined } + if (limit <= 0) return { head: input.messages, tail: [], tail_start_id: undefined } const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) - if (!all.length) return { head: input.messages, tail_start_id: undefined } + if (!all.length) return { head: input.messages, tail: [], tail_start_id: undefined } const recent = all.slice(-limit) const sizes = yield* Effect.forEach( recent, @@ -286,9 +323,10 @@ export const layer = Layer.effect( break } - if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } + if (!keep || keep.start === 0) return { head: input.messages, tail: [], tail_start_id: undefined } return { head: input.messages.slice(0, keep.start), + tail: input.messages.slice(keep.start), tail_start_id: keep.id, } }) @@ -400,7 +438,7 @@ export const layer = Layer.effect( { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail: selected.tail }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2bc9b196216d..d3706a91e2ef 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1052,8 +1052,14 @@ describe("session.compaction.process", () => { "retains a split turn suffix when a later message fits the preserve token budget", () => { const stub = llm() - let captured = "" - stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + let history = "" + let prompt = "" + stub.push( + reply("summary", (input) => { + history = JSON.stringify(input.messages.slice(0, -1)) + prompt = JSON.stringify(input.messages.at(-1)) + }), + ) return Effect.gen(function* () { const test = yield* TestInstance const ssn = yield* SessionNs.Service @@ -1086,8 +1092,9 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) - expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + expect(history).toContain("zzzz") + expect(history).not.toContain("keep tail") + expect(prompt).toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) @@ -1363,10 +1370,12 @@ describe("session.compaction.process", () => { "summarizes only the head while keeping recent tail out of summary input", () => { const stub = llm() - let captured = "" + let history = "" + let prompt = "" stub.push( reply("summary", (input) => { - captured = JSON.stringify(input.messages) + history = JSON.stringify(input.messages.slice(0, -1)) + prompt = JSON.stringify(input.messages.at(-1)) }), ) return Effect.gen(function* () { @@ -1387,15 +1396,89 @@ describe("session.compaction.process", () => { auto: false, }) - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") + expect(history).toContain("older context") + expect(history).not.toContain("keep this turn") + expect(history).not.toContain("and this one too") + expect(prompt).toContain("") + expect(prompt).toContain("keep this turn") + expect(prompt).toContain("and this one too") + expect(`${history}${prompt}`).not.toContain("What did we do so far?") }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, ) + itCompaction.instance( + "reconciles stale Next Steps with the preserved recent tail audit", + () => { + const stub = llm() + const stale = `## Goal +- Fix compaction summaries + +## Progress +### Done +- Implemented the fix + +## Next Steps +- run tests` + const reconciled = `## Goal +- Fix compaction summaries + +## Progress +### Done +- Implemented the fix +- tests were run and passed + +## Next Steps +- (none)` + stub.push((input) => + reply(JSON.stringify(input.messages.at(-1)).includes("tests were run and passed") ? reconciled : stale)(input), + ) + + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "implement a compaction fix") + yield* createCompactionMarker(session.id) + const previousParent = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id + expect(previousParent).toBeTruthy() + yield* createSummaryAssistantMessage(session.id, previousParent!, test.directory, stale) + + const runTests = yield* createUserMessage(session.id, "run tests") + const passed = yield* createAssistantMessage(session.id, runTests.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: passed.id, + sessionID: session.id, + type: "text", + text: "tests were run and passed", + }) + yield* createUserMessage(session.id, "prepare final response") + yield* createCompactionMarker(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.parentID === parent!, + ) + const text = + summary?.parts + .filter((part): part is MessageV2.TextPart => part.type === "text") + .map((part) => part.text) + .join("\n") ?? "" + expect(text).toContain("## Next Steps") + expect(text.match(/## Next Steps\n([\s\S]*?)(?:\n## |$)/)?.[1]?.toLowerCase() ?? "").not.toContain( + "run tests", + ) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ preserve_recent_tokens: 10_000 }) })) + }, + { git: true }, + ) + itCompaction.instance( "anchors repeated compactions with the previous summary", () => {