Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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
Expand All @@ -63,7 +65,7 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
- [decision and why, or "(none)"]

## Next Steps
- [ordered next actions or "(none)"]
- [ordered unfinished future actions or "(none)"]

## Critical Context
- [important technical facts, errors, open questions, or "(none)"]
Expand All @@ -76,6 +78,8 @@ Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Next Steps must contain only unfinished future actions.
- If recent preserved-tail audit says a task was completed, move it out of Next Steps.
- Do not mention the summary process or that context was compacted.`
type Turn = {
start: number
Expand Down Expand Up @@ -122,7 +126,7 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
})
}

function buildPrompt(input: { previousSummary?: string; context: string[] }) {
function buildPrompt(input: { previousSummary?: string; context: string[]; tail: MessageV2.WithParts[] }) {
const anchor = input.previousSummary
? [
"Update the anchored summary below using the conversation history above.",
Expand All @@ -132,7 +136,40 @@ function buildPrompt(input: { previousSummary?: string; context: string[] }) {
"</previous-summary>",
].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 [
"<recent-preserved-tail-audit>",
"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),
"</recent-preserved-tail-audit>",
].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 }) {
Expand Down Expand Up @@ -250,10 +287,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,
Expand Down Expand Up @@ -288,9 +325,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,
}
})
Expand Down Expand Up @@ -402,7 +440,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, {
Expand Down
103 changes: 93 additions & 10 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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* () {
Expand All @@ -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("<recent-preserved-tail-audit>")
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",
() => {
Expand Down
Loading