Skip to content

Commit 1b92c8f

Browse files
Mark Hendersonfwang
authored andcommitted
fix: clear tool output and attachments when pruning to prevent memory leak
When compaction prunes old tool outputs, only the compacted timestamp was being set - the actual output string and attachments array remained in storage indefinitely. This caused storage files to grow unbounded with large tool outputs (file contents, base64 images/PDFs, etc.). Now prune() clears both output and attachments when marking parts as compacted. The toModelMessage function already replaces compacted outputs with placeholder text, so this is safe. Fixes part of #4315
1 parent b41fbda commit 1b92c8f

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

packages/opencode/src/session/compaction.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export namespace SessionCompaction {
8282
for (const part of toPrune) {
8383
if (part.state.status === "completed") {
8484
part.state.time.compacted = Date.now()
85+
// Clear output and attachments to free memory - these are replaced with
86+
// placeholder text in toModelMessage when compacted flag is set
87+
part.state.output = ""
88+
part.state.attachments = undefined
8589
await Session.updatePart(part)
8690
}
8791
}

packages/opencode/test/session/compaction.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Instance } from "../../src/project/instance"
66
import { Log } from "../../src/util/log"
77
import { tmpdir } from "../fixture/fixture"
88
import { Session } from "../../src/session"
9+
import { MessageV2 } from "../../src/session/message-v2"
10+
import { Identifier } from "../../src/id/id"
911
import type { Provider } from "../../src/provider/provider"
1012

1113
Log.init({ print: false })
@@ -249,3 +251,148 @@ describe("session.getUsage", () => {
249251
expect(result.cost).toBe(3 + 1.5)
250252
})
251253
})
254+
255+
describe("session.compaction.prune", () => {
256+
test("clears output and attachments when pruning tool parts", async () => {
257+
await using tmp = await tmpdir({ git: true })
258+
await Instance.provide({
259+
directory: tmp.path,
260+
fn: async () => {
261+
// Create a session
262+
const session = await Session.create({})
263+
264+
// Create user messages with turns to get past the initial protection
265+
const userMsg1 = await Session.updateMessage({
266+
id: Identifier.ascending("message"),
267+
role: "user",
268+
sessionID: session.id,
269+
time: { created: Date.now() - 10000 },
270+
agent: "coder",
271+
model: { providerID: "test", modelID: "test-model" },
272+
})
273+
274+
// Create an assistant message with a completed tool part containing large output
275+
const assistantMsg1 = await Session.updateMessage({
276+
id: Identifier.ascending("message"),
277+
role: "assistant",
278+
parentID: userMsg1.id,
279+
sessionID: session.id,
280+
mode: "normal",
281+
agent: "coder",
282+
path: { cwd: tmp.path, root: tmp.path },
283+
cost: 0,
284+
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
285+
modelID: "test-model",
286+
providerID: "test",
287+
time: { created: Date.now() - 9000 },
288+
})
289+
290+
// Create large output to exceed PRUNE_PROTECT (40,000 tokens = 160,000 chars)
291+
const largeOutput = "x".repeat(200_000)
292+
const toolPart = await Session.updatePart({
293+
id: Identifier.ascending("part"),
294+
messageID: assistantMsg1.id,
295+
sessionID: session.id,
296+
type: "tool",
297+
callID: "call-1",
298+
tool: "read",
299+
state: {
300+
status: "completed",
301+
input: { path: "/test/file.ts" },
302+
output: largeOutput,
303+
title: "Read file",
304+
metadata: {},
305+
time: { start: Date.now() - 8000, end: Date.now() - 7000 },
306+
attachments: [
307+
{
308+
id: Identifier.ascending("part"),
309+
messageID: assistantMsg1.id,
310+
sessionID: session.id,
311+
type: "file",
312+
mime: "image/png",
313+
filename: "screenshot.png",
314+
url: "data:image/png;base64," + "A".repeat(50000),
315+
},
316+
],
317+
},
318+
} as MessageV2.ToolPart)
319+
320+
// Create a second user message (turn 2)
321+
const userMsg2 = await Session.updateMessage({
322+
id: Identifier.ascending("message"),
323+
role: "user",
324+
sessionID: session.id,
325+
time: { created: Date.now() - 5000 },
326+
agent: "coder",
327+
model: { providerID: "test", modelID: "test-model" },
328+
})
329+
330+
// Create a third user message (turn 3) to get past the turn protection
331+
const userMsg3 = await Session.updateMessage({
332+
id: Identifier.ascending("message"),
333+
role: "user",
334+
sessionID: session.id,
335+
time: { created: Date.now() },
336+
agent: "coder",
337+
model: { providerID: "test", modelID: "test-model" },
338+
})
339+
340+
// Verify initial state - output and attachments exist
341+
const initialParts = await MessageV2.parts(assistantMsg1.id)
342+
const initialToolPart = initialParts.find((p) => p.type === "tool") as MessageV2.ToolPart
343+
expect(initialToolPart.state.status).toBe("completed")
344+
if (initialToolPart.state.status === "completed") {
345+
expect(initialToolPart.state.output.length).toBe(200_000)
346+
expect(initialToolPart.state.attachments?.length).toBe(1)
347+
}
348+
349+
// Run prune
350+
await SessionCompaction.prune({ sessionID: session.id })
351+
352+
// Verify output and attachments are cleared
353+
const prunedParts = await MessageV2.parts(assistantMsg1.id)
354+
const prunedToolPart = prunedParts.find((p) => p.type === "tool") as MessageV2.ToolPart
355+
expect(prunedToolPart.state.status).toBe("completed")
356+
if (prunedToolPart.state.status === "completed") {
357+
expect(prunedToolPart.state.output).toBe("")
358+
expect(prunedToolPart.state.attachments).toBeUndefined()
359+
expect(prunedToolPart.state.time.compacted).toBeDefined()
360+
}
361+
362+
// Cleanup
363+
await Session.remove(session.id)
364+
},
365+
})
366+
})
367+
368+
test("does not prune when prune config is disabled", async () => {
369+
await using tmp = await tmpdir({
370+
git: true,
371+
init: async (dir) => {
372+
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ compaction: { prune: false } }))
373+
},
374+
})
375+
await Instance.provide({
376+
directory: tmp.path,
377+
fn: async () => {
378+
const session = await Session.create({})
379+
380+
// Create minimal messages to run prune
381+
const userMsg = await Session.updateMessage({
382+
id: Identifier.ascending("message"),
383+
role: "user",
384+
sessionID: session.id,
385+
time: { created: Date.now() },
386+
agent: "coder",
387+
model: { providerID: "test", modelID: "test-model" },
388+
})
389+
390+
// Run prune - should return early due to config
391+
await SessionCompaction.prune({ sessionID: session.id })
392+
393+
// Cleanup
394+
await Session.remove(session.id)
395+
},
396+
})
397+
})
398+
})

0 commit comments

Comments
 (0)