@@ -6,6 +6,7 @@ import * as path from "node:path";
66import { createHash } from "node:crypto" ;
77import { pathToFileURL } from "node:url" ;
88import { SessionManager , type SessionMessage } from "../session" ;
9+ import { OpenAIMessageConverter } from "../common/openai-message-converter" ;
910import { getCompactPrompt , frameCompactSummary } from "../prompt" ;
1011import {
1112 compactBudget ,
@@ -126,7 +127,11 @@ test("prefixes preserve complete parallel tool groups, include old summaries and
126127 [ [ "summary" , "older" ] , [ "summary" ] ]
127128 ) ;
128129 assert . deepEqual ( compactPrefixes ( [ message ( "huge" ) ] , [ 1000000 ] , 0 ) , [ ] ) ;
129- assert . deepEqual ( compactPrefixes ( [ call , results [ 0 ] , message ( "tail" ) ] , [ 10 , 10 , 10 ] , 0 ) , [ ] ) ;
130+ assert . deepEqual (
131+ compactPrefixes ( [ call , results [ 0 ] , message ( "tail" ) ] , [ 10 , 10 , 10 ] , 0 ) . map ( ( prefix ) => prefix . map ( ( m ) => m . id ) ) ,
132+ [ [ "call" , "a" ] ]
133+ ) ;
134+ assert . deepEqual ( compactPrefixes ( [ call , results [ 0 ] ] , [ 10 , 10 ] , 0 ) , [ ] ) ;
130135 const tail = [ ...history , message ( "tail" ) ] ;
131136 assert . equal (
132137 compactPrefixes (
@@ -138,6 +143,81 @@ test("prefixes preserve complete parallel tool groups, include old summaries and
138143 ) ;
139144} ) ;
140145
146+ test ( "historical interrupted calls can compact, but unfinished trailing tool batches stay intact" , ( ) => {
147+ const call = {
148+ ...message ( "call" , "assistant" ) ,
149+ messageParams : { tool_calls : [ { id : "missing" , type : "function" , function : { name : "Bash" , arguments : "{}" } } ] } ,
150+ } ;
151+ const resumed = [ call , message ( "resume" , "user" , "Continue" ) , message ( "work" , "assistant" ) , message ( "tail" ) ] ;
152+ const selected = compactPrefixes ( resumed , [ 10 , 10 , 10 , 10 ] , 0 ) [ 0 ] ;
153+ assert . deepEqual (
154+ selected ?. map ( ( m ) => m . id ) ,
155+ [ "call" , "resume" , "work" ]
156+ ) ;
157+ const replay = new OpenAIMessageConverter ( ) . buildMessages ( selected , false , "test-model" ) ;
158+ assert . equal ( replay [ 1 ] . role , "tool" ) ;
159+ assert . match ( String ( replay [ 1 ] . content ) , / P r e v i o u s t o o l c a l l d i d n o t c o m p l e t e / ) ;
160+ assert . deepEqual ( compactPrefixes ( [ call ] , [ 10 ] , 0 ) , [ ] ) ;
161+
162+ const parallel = { ...call , messageParams : { tool_calls : [ { id : "missing" } , { id : "done" } ] } } ;
163+ const result = { ...message ( "result" , "tool" ) , messageParams : { tool_call_id : "done" } } ;
164+ assert . deepEqual ( compactPrefixes ( [ parallel , result , message ( "system" , "system" ) ] , [ 10 , 10 , 10 ] , 0 ) , [ ] ) ;
165+ } ) ;
166+
167+ test ( "compaction follows the converter's preferred result when a real result replaces an interrupted placeholder" , ( ) => {
168+ const call = { ...message ( "call" , "assistant" ) , messageParams : { tool_calls : [ { id : "a" } ] } } ;
169+ const placeholder = {
170+ ...message ( "placeholder" , "tool" , JSON . stringify ( { metadata : { interrupted : true } } ) ) ,
171+ messageParams : { tool_call_id : "a" } ,
172+ } ;
173+ const result = { ...message ( "result" , "tool" , "real result" ) , messageParams : { tool_call_id : "a" } } ;
174+ const history = [ call , placeholder , message ( "resume" ) , result , message ( "tail" ) ] ;
175+ assert . deepEqual ( compactPrefixes ( history , [ 10 , 10 , 10 , 10 , 10 ] , 30 ) , [ ] ) ;
176+ const selected = compactPrefixes ( history , [ 10 , 10 , 10 , 10 , 10 ] , 0 ) [ 0 ] ;
177+ assert . deepEqual (
178+ selected . map ( ( m ) => m . id ) ,
179+ [ "call" , "placeholder" , "resume" , "result" ]
180+ ) ;
181+ const replay = new OpenAIMessageConverter ( ) . buildMessages ( selected , false , "test-model" ) ;
182+ assert . equal ( replay . find ( ( m ) => m . role === "tool" ) ?. content , "real result" ) ;
183+ } ) ;
184+
185+ test ( "automatic compaction resumes after an interrupted parallel tool batch" , async ( ) => {
186+ const call = {
187+ ...message ( "interrupted" , "assistant" , "old work " . repeat ( 7000 ) ) ,
188+ messageParams : {
189+ tool_calls : [ "done" , "missing" ] . map ( ( id ) => ( {
190+ id,
191+ type : "function" ,
192+ function : { name : "Bash" , arguments : "{}" } ,
193+ } ) ) ,
194+ } ,
195+ } ;
196+ const result = { ...message ( "result" , "tool" , "finished result" ) , messageParams : { tool_call_id : "done" } } ;
197+ const f = fixture ( [ call , result , message ( "resume" , "user" , "continue " . repeat ( 6000 ) ) ] ) ;
198+ f . settings . contextWindow = 100000 ;
199+ const context = await f . internal . prepareContext ( f . read ( ) , f . settings . model , true ) ;
200+ f . settings . autoCompactWindow =
201+ f . internal . measureRequest ( "compact-test" , context . messages , f . settings . model ) . tokens - 10000 ;
202+ await f . run ( ) ;
203+
204+ assert . equal ( f . requests . length , 2 ) ;
205+ assert . equal ( f . requests [ 0 ] . tool_choice , "none" ) ;
206+ const toolResults = f . requests [ 0 ] . messages . filter ( ( m : any ) => m . role === "tool" ) ;
207+ assert . equal ( toolResults . find ( ( m : any ) => m . tool_call_id === "done" ) ?. content , "finished result" ) ;
208+ assert . match (
209+ toolResults . find ( ( m : any ) => m . tool_call_id === "missing" ) ?. content ,
210+ / P r e v i o u s t o o l c a l l d i d n o t c o m p l e t e /
211+ ) ;
212+ assert . equal ( f . read ( ) . find ( ( m ) => m . id === "interrupted" ) ?. compacted , true ) ;
213+ assert . equal ( f . read ( ) . find ( ( m ) => m . id === "result" ) ?. compacted , true ) ;
214+ assert . equal ( f . read ( ) . find ( ( m ) => m . id === "resume" ) ?. compacted , false ) ;
215+ assert . equal (
216+ f . requests [ 1 ] . messages . some ( ( m : any ) => m . role === "tool" ) ,
217+ false
218+ ) ;
219+ } ) ;
220+
141221test ( "only explicit context and image capacity errors enable recovery" , ( ) => {
142222 assert . equal ( isContextOverflow ( { status : 400 , error : { code : "context_length_exceeded" } } ) , true ) ;
143223 assert . equal ( isContextOverflow ( { status : 400 , message : "maximum context length is 10000 tokens" } ) , true ) ;
0 commit comments