@@ -5,9 +5,16 @@ import type {
55 ReactorInboundEvent ,
66 ReactorState ,
77} from "@intx/types/runtime" ;
8+ import { COMPACTOR_KEEP_RECENT_TURNS , compactorNoOpFloor } from "../session/compactor.js" ;
89import { SubAgentDirector } from "./nudge-director.js" ;
910
1011const state = { turns : [ ] } as unknown as ReactorState ;
12+ const longState = {
13+ turns : Array . from (
14+ { length : compactorNoOpFloor ( COMPACTOR_KEEP_RECENT_TURNS ) + 1 } ,
15+ ( ) => ( { role : "user" , content : [ ] , timestamp : 0 } ) ,
16+ ) ,
17+ } as unknown as ReactorState ;
1118
1219function capabilities ( ) : ReactorCapabilities {
1320 return {
@@ -26,7 +33,11 @@ function capabilities(): ReactorCapabilities {
2633 } ;
2734}
2835
29- function inferenceDone ( callIds : string [ ] ) : ReactorInboundEvent {
36+ function inferenceDone (
37+ callIds : string [ ] ,
38+ inputTokens = 0 ,
39+ pathForId : ( id : string ) => string = ( id ) => `${ id } .ts` ,
40+ ) : ReactorInboundEvent {
3041 return {
3142 type : "inference.done" ,
3243 turn : {
@@ -37,11 +48,11 @@ function inferenceDone(callIds: string[]): ReactorInboundEvent {
3748 type : "tool_call" ,
3849 id,
3950 name : "read_file" ,
40- arguments : { path : ` ${ id } .ts` } ,
51+ arguments : { path : pathForId ( id ) } ,
4152 } ) ) ,
4253 } ,
43- usage : { input : 0 , output : 0 } ,
44- source : "test" ,
54+ usage : { input : inputTokens , output : 1 , cacheRead : 0 , cacheWrite : 0 , thinking : 0 } ,
55+ source : { model : "test-model" } ,
4556 } as unknown as ReactorInboundEvent ;
4657}
4758
@@ -52,6 +63,13 @@ function toolDone(callId: string, isError = false): ReactorInboundEvent {
5263 } as unknown as ReactorInboundEvent ;
5364}
5465
66+ function messageReceived ( content : string ) : ReactorInboundEvent {
67+ return {
68+ type : "message.received" ,
69+ message : { role : "user" , content } ,
70+ } as unknown as ReactorInboundEvent ;
71+ }
72+
5573function actions ( result : ReactorAction | ReactorAction [ ] ) : ReactorAction [ ] {
5674 return Array . isArray ( result ) ? result : [ result ] ;
5775}
@@ -130,4 +148,87 @@ describe("SubAgentDirector tool failure recovery", () => {
130148 ) ;
131149 expect ( ephemeralTexts ( infer ) ) . toBeUndefined ( ) ;
132150 } ) ;
151+
152+ test ( "retains recovery through compaction and consumes it once on continuation infer" , async ( ) => {
153+ let continuations = 0 ;
154+ const director = new SubAgentDirector (
155+ "system" ,
156+ [ ] ,
157+ ( ) => {
158+ continuations ++ ;
159+ } ,
160+ 30 ,
161+ ) ;
162+ const caps = capabilities ( ) ;
163+
164+ await director . decide ( inferenceDone ( [ "failed-at-threshold" ] , 999_999 ) , longState , caps ) ;
165+ const compact = actions (
166+ await director . decide ( toolDone ( "failed-at-threshold" , true ) , longState , caps ) ,
167+ ) ;
168+ expect ( compact . some ( ( action ) => action . type === "infer" ) ) . toBe ( false ) ;
169+ expect ( compact ) . toEqual ( [
170+ { type : "checkpoint" , message : "tool-done" } ,
171+ { type : "compact" , compactor : "pruning-compactor" , reason : "context-threshold" } ,
172+ ] ) ;
173+ expect ( continuations ) . toBe ( 1 ) ;
174+
175+ const resumed = inferAction (
176+ await director . decide ( messageReceived ( "" ) , longState , caps ) ,
177+ ) ;
178+ const resumedTexts = ephemeralTexts ( resumed ) ;
179+ expect ( resumedTexts ) . toHaveLength ( 1 ) ;
180+ expect ( resumedTexts ?. [ 0 ] ) . toContain ( "A tool call failed" ) ;
181+
182+ const later = inferAction (
183+ await director . decide ( messageReceived ( "" ) , longState , caps ) ,
184+ ) ;
185+ expect ( ephemeralTexts ( later ) ) . toBeUndefined ( ) ;
186+ } ) ;
187+
188+ test ( "near-budget wrap-up nudge wins over failed-tool recovery" , async ( ) => {
189+ const director = new SubAgentDirector ( "system" , [ ] , undefined , 3 ) ;
190+ const caps = capabilities ( ) ;
191+
192+ await director . decide ( inferenceDone ( [ "near-budget-failure" ] ) , state , caps ) ;
193+ const texts = ephemeralTexts (
194+ inferAction ( await director . decide ( toolDone ( "near-budget-failure" , true ) , state , caps ) ) ,
195+ ) ;
196+
197+ expect ( texts ) . toHaveLength ( 1 ) ;
198+ expect ( texts ?. [ 0 ] ) . toContain ( "close to your turn budget" ) ;
199+ expect ( texts ?. [ 0 ] ) . toContain ( "write your final report now" ) ;
200+ expect ( texts ?. [ 0 ] ) . not . toContain ( "A tool call failed" ) ;
201+ expect ( texts ?. [ 0 ] ) . not . toContain ( "change the arguments or approach" ) ;
202+ } ) ;
203+
204+ test ( "failed-tool recovery supersedes soft re-read guidance" , async ( ) => {
205+ const director = new SubAgentDirector ( "system" , [ ] , undefined , 30 ) ;
206+ const caps = capabilities ( ) ;
207+ const callIds = [
208+ "shared-1" ,
209+ "shared-2" ,
210+ "shared-3" ,
211+ "unique-1" ,
212+ "unique-2" ,
213+ "unique-3" ,
214+ "unique-4" ,
215+ "failed-last" ,
216+ ] ;
217+
218+ await director . decide (
219+ inferenceDone ( callIds , 0 , ( id ) => id . startsWith ( "shared-" ) ? "shared.ts" : `${ id } .ts` ) ,
220+ state ,
221+ caps ,
222+ ) ;
223+ for ( const callId of callIds . slice ( 0 , - 1 ) ) {
224+ await director . decide ( toolDone ( callId ) , state , caps ) ;
225+ }
226+ const texts = ephemeralTexts (
227+ inferAction ( await director . decide ( toolDone ( "failed-last" , true ) , state , caps ) ) ,
228+ ) ;
229+
230+ expect ( texts ) . toHaveLength ( 1 ) ;
231+ expect ( texts ?. [ 0 ] ) . toContain ( "A tool call failed" ) ;
232+ expect ( texts ?. [ 0 ] ) . not . toContain ( "re-reading the same paths" ) ;
233+ } ) ;
133234} ) ;
0 commit comments