@@ -6,6 +6,8 @@ import { Instance } from "../../src/project/instance"
66import { Log } from "../../src/util/log"
77import { tmpdir } from "../fixture/fixture"
88import { Session } from "../../src/session"
9+ import { MessageV2 } from "../../src/session/message-v2"
10+ import { Identifier } from "../../src/id/id"
911import type { Provider } from "../../src/provider/provider"
1012
1113Log . 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