Skip to content

Commit 17645bf

Browse files
esrehmkiESRE-dev
authored andcommitted
compaction: preserve todo list across context compaction
* after compaction the agent lost track of its task list and failed to resume work where it left off ADD * inject current todo state into the summarizer prompt so the post-compaction agent sees the full task list and can continue from the correct point * formatTodos helper renders persisted todos as a markdown section appended to the compaction template TEST * unit tests for formatTodos covering empty input, entry formatting, prompt concatenation, persistence note, and special characters
1 parent a27d3c1 commit 17645bf

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

packages/opencode/src/session/compaction.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
1616
import { Effect, Layer, Context } from "effect"
1717
import { InstanceState } from "@/effect"
1818
import { isOverflow as overflow } from "./overflow"
19+
import { Todo } from "./todo"
1920

2021
const log = Log.create({ service: "session.compaction" })
2122

@@ -28,6 +29,12 @@ export const Event = {
2829
),
2930
}
3031

32+
export function formatTodos(todos: Todo.Info[]): string | undefined {
33+
if (todos.length === 0) return undefined
34+
const items = todos.map((t) => `- [${t.status}] (${t.priority}) ${t.content}`).join("\n")
35+
return `\n\n## Current Task List\nThe agent is tracking the following tasks (persisted in the database — these survive compaction):\n${items}`
36+
}
37+
3138
export const PRUNE_MINIMUM = 20_000
3239
export const PRUNE_PROTECT = 40_000
3340
const PRUNE_PROTECTED_TOOLS = ["skill"]
@@ -66,6 +73,7 @@ export const layer: Layer.Layer<
6673
| Plugin.Service
6774
| SessionProcessor.Service
6875
| Provider.Service
76+
| Todo.Service
6977
> = Layer.effect(
7078
Service,
7179
Effect.gen(function* () {
@@ -76,6 +84,7 @@ export const layer: Layer.Layer<
7684
const plugin = yield* Plugin.Service
7785
const processors = yield* SessionProcessor.Service
7886
const provider = yield* Provider.Service
87+
const todo = yield* Todo.Service
7988

8089
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
8190
tokens: MessageV2.Assistant["tokens"]
@@ -182,7 +191,7 @@ export const layer: Layer.Layer<
182191
{ sessionID: input.sessionID },
183192
{ context: [], prompt: undefined },
184193
)
185-
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
194+
let defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
186195
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
187196
The summary that you construct will be used so that another agent can read it and continue the work.
188197
Do not call any tools. Respond only with the summary text.
@@ -212,6 +221,12 @@ When constructing the summary, try to stick to this template:
212221
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
213222
---`
214223

224+
const todos = yield* todo.get(input.sessionID)
225+
const section = formatTodos(todos)
226+
if (section) {
227+
defaultPrompt += section
228+
}
229+
215230
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
216231
const msgs = structuredClone(messages)
217232
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
@@ -404,6 +419,7 @@ export const defaultLayer = Layer.suspend(() =>
404419
Layer.provide(SessionProcessor.defaultLayer),
405420
Layer.provide(Agent.defaultLayer),
406421
Layer.provide(Plugin.defaultLayer),
422+
Layer.provide(Todo.defaultLayer),
407423
Layer.provide(Bus.layer),
408424
Layer.provide(Config.defaultLayer),
409425
),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { SessionCompaction } from "../../src/session/compaction"
3+
4+
describe("SessionCompaction.formatTodos", () => {
5+
test("returns undefined for empty array", () => {
6+
expect(SessionCompaction.formatTodos([])).toBeUndefined()
7+
})
8+
9+
test("returns section with header for non-empty list", () => {
10+
const result = SessionCompaction.formatTodos([{ content: "Fix bug", status: "pending", priority: "high" }])
11+
expect(result).toContain("## Current Task List")
12+
})
13+
14+
test("includes one entry per item", () => {
15+
const todos = [
16+
{ content: "Fix bug", status: "pending", priority: "high" },
17+
{ content: "Write tests", status: "in_progress", priority: "medium" },
18+
{ content: "Deploy", status: "completed", priority: "low" },
19+
]
20+
const result = SessionCompaction.formatTodos(todos)!
21+
expect(result).toContain("- [pending] (high) Fix bug")
22+
expect(result).toContain("- [in_progress] (medium) Write tests")
23+
expect(result).toContain("- [completed] (low) Deploy")
24+
})
25+
26+
test("formats status, priority, and content in markdown", () => {
27+
const result = SessionCompaction.formatTodos([{ content: "Task A", status: "open", priority: "low" }])!
28+
expect(result).toContain("- [open] (low) Task A")
29+
})
30+
31+
test("entry count matches input length", () => {
32+
const todos = Array.from({ length: 5 }, (_, i) => ({
33+
content: `Task ${i}`,
34+
status: "pending",
35+
priority: "medium",
36+
}))
37+
const result = SessionCompaction.formatTodos(todos)!
38+
const entries = result.split("\n").filter((line) => line.startsWith("- ["))
39+
expect(entries).toHaveLength(5)
40+
})
41+
42+
test("section starts with double newline for prompt concatenation", () => {
43+
const result = SessionCompaction.formatTodos([{ content: "Task", status: "pending", priority: "high" }])!
44+
expect(result.startsWith("\n\n")).toBe(true)
45+
})
46+
47+
test("includes persistence note for agent context", () => {
48+
const result = SessionCompaction.formatTodos([{ content: "Task", status: "pending", priority: "high" }])!
49+
expect(result).toContain("persisted in the database")
50+
expect(result).toContain("survive compaction")
51+
})
52+
53+
test("no section header in empty result", () => {
54+
const result = SessionCompaction.formatTodos([])
55+
expect(result).toBeUndefined()
56+
expect(result ?? "").not.toContain("## Current Task List")
57+
})
58+
59+
test("handles special characters in content", () => {
60+
const result = SessionCompaction.formatTodos([
61+
{ content: "Fix `code` in **bold** & <html>", status: "pending", priority: "high" },
62+
])!
63+
expect(result).toContain("Fix `code` in **bold** & <html>")
64+
})
65+
})

0 commit comments

Comments
 (0)