@@ -253,19 +253,14 @@ interface DecideThenReloadOpts {
253253}
254254
255255/**
256- * Parks `call` on an ask, settles it per `opts.decision` (approve by
257- * default), and once `reloadWhen` holds for the settled run, rebuilds the
258- * agent on the same store. `reloadWhen` sees only events from after the
259- * decision.
256+ * Opens a session and parks `call` on an ask. `live.session` is the agent's
257+ * current session; a rebuild replaces it, and cleanup closes whichever is
258+ * current.
260259 */
261- async function decideThenReload (
260+ async function parkOnAsk (
262261 call : { name : string ; args : Record < string , unknown > } ,
263- reloadWhen : (
264- session : IntegrationSession ,
265- events : ReactorEmittedEvent [ ] ,
266- ) => boolean ,
267- opts : DecideThenReloadOpts = { } ,
268- ) : Promise < Reloaded > {
262+ opts : Pick < DecideThenReloadOpts , "replyAfterDecision" | "prepare" > ,
263+ ) {
269264 // Held from before the park so the expire case can fire the original gate
270265 // timer; the approve and reject cases clear it unfired.
271266 const clock = holdLongTimers ( ) ;
@@ -276,61 +271,97 @@ async function decideThenReload(
276271 authorize : createReactorAuthorize ( ctx . gate ) ,
277272 pinToolSetAtBuild : true ,
278273 } ;
279- let session = await openIntegrationSession ( sessionOpts ) ;
280- cleanups . push ( ( ) => closeIntegrationSession ( session ) ) ;
281- await opts . prepare ?.( session ) ;
274+ const live = { session : await openIntegrationSession ( sessionOpts ) } ;
275+ cleanups . push ( ( ) => closeIntegrationSession ( live . session ) ) ;
276+ await opts . prepare ?.( live . session ) ;
282277
283- session . harness . scenario . replyOnce ( "anthropic" , {
278+ live . session . harness . scenario . replyOnce ( "anthropic" , {
284279 toolCalls : [ call ] ,
285280 } ) ;
286- if ( opts . replyAfterDecision === undefined ) session . harness . scenario . stall ( ) ;
281+ if ( opts . replyAfterDecision === undefined )
282+ live . session . harness . scenario . stall ( ) ;
287283 else
288- session . harness . scenario . replyOnce ( "anthropic" , {
284+ live . session . harness . scenario . replyOnce ( "anthropic" , {
289285 text : opts . replyAfterDecision ,
290286 } ) ;
291287
292- const turn = await runUntilSuspended ( session , "Probe the local port." ) ;
293- if ( turn . result . type !== "suspended" ) throw new Error ( "did not park" ) ;
294- const { correlationId } = turn . result ;
295- const parked = ( await session . storage . load ( ) ) . pendingOperations . find (
296- ( op ) => op . correlationId === correlationId ,
288+ const turn = await runUntilSuspended ( live . session , "Probe the local port." ) ;
289+ const suspended = turn . result ;
290+ if ( suspended . type !== "suspended" ) throw new Error ( "did not park" ) ;
291+ const parked = ( await live . session . storage . load ( ) ) . pendingOperations . find (
292+ ( op ) => op . correlationId === suspended . correlationId ,
297293 ) ;
298294 const callId = parked ?. suspendedCall ?. id ;
299295 const deadline = parked ?. timeoutAt ;
300296 if ( callId === undefined || deadline === undefined ) {
301297 throw new Error ( "parked call has no persisted call id or deadline" ) ;
302298 }
299+ return {
300+ live,
301+ sessionOpts,
302+ ctx,
303+ clock,
304+ suspended,
305+ events : turn . events ,
306+ callId,
307+ deadline,
308+ } ;
309+ }
310+
311+ /**
312+ * Parks `call` on an ask, settles it per `opts.decision` (approve by
313+ * default), and once `reloadWhen` holds for the settled run, rebuilds the
314+ * agent on the same store. `reloadWhen` sees only events from after the
315+ * decision.
316+ */
317+ async function decideThenReload (
318+ call : { name : string ; args : Record < string , unknown > } ,
319+ reloadWhen : (
320+ session : IntegrationSession ,
321+ events : ReactorEmittedEvent [ ] ,
322+ ) => boolean ,
323+ opts : DecideThenReloadOpts = { } ,
324+ ) : Promise < Reloaded > {
325+ const { live, sessionOpts, ctx, clock, suspended, events, callId, deadline } =
326+ await parkOnAsk ( call , opts ) ;
303327
304328 const decision = opts . decision ?? "approve" ;
305- const decidedAt = turn . events . length ;
329+ const decidedAt = events . length ;
306330 if ( decision === "expire" ) {
307331 clock . advanceTo ( deadline + 1_000 ) ;
308332 } else {
309333 const resume = createApprovalResume ( {
310- getAgent : ( ) => session . agent ,
334+ getAgent : ( ) => live . session . agent ,
311335 gate : ctx . gate ,
312336 resolveParkedCallId : ( id ) =>
313- resolveParkedCallIdFromStore ( session . storage , id ) ,
337+ resolveParkedCallIdFromStore ( live . session . storage , id ) ,
314338 } ) ;
315- const handling = resume . handle ( turn . result ) ;
339+ const handling = resume . handle ( suspended ) ;
316340 await waitFor ( ctx . asked , "approval prompt" ) ;
317- opts . afterApprovalOpened ?.( session ) ;
341+ opts . afterApprovalOpened ?.( live . session ) ;
318342 if ( decision === "approve" ) ctx . approve ( ) ;
319343 else ctx . reject ( ) ;
320344 expect ( await handling ) . toBe ( true ) ;
321345 }
322- void session . harness
346+ void live . session . harness
323347 . run ( { wallClockBudgetMs : 30_000 } )
324348 . catch ( ( ) => undefined ) ;
325- const before = session ;
349+ const before = live . session ;
326350 await waitFor (
327- ( ) => reloadWhen ( before , turn . events . slice ( decidedAt ) ) ,
351+ ( ) => reloadWhen ( before , events . slice ( decidedAt ) ) ,
328352 "reload point" ,
329353 ) ;
330354
331- if ( opts . rebuildInPlace === true ) await session . rebuild ( ) ;
332- else session = await rebuildIntegrationSession ( session , sessionOpts ) ;
333- return { session, events : collect ( session ) , callId, deadline, clock } ;
355+ if ( opts . rebuildInPlace === true ) await live . session . rebuild ( ) ;
356+ else
357+ live . session = await rebuildIntegrationSession ( live . session , sessionOpts ) ;
358+ return {
359+ session : live . session ,
360+ events : collect ( live . session ) ,
361+ callId,
362+ deadline,
363+ clock,
364+ } ;
334365}
335366
336367// Past both the original deadline and a full approval window freshly armed
@@ -601,37 +632,19 @@ describe("integration: a rejection the reactor accepted but never ran, then reop
601632 test . serial (
602633 "the reopened agent loads the denial once and no parked call, and the old deadline adds no result" ,
603634 async ( ) => {
604- const clock = holdLongTimers ( ) ;
605- cleanups . push ( ( ) => clock . restore ( ) ) ;
606- const ctx = deferredApprovalGate ( ) ;
607- const sessionOpts = {
608- permissionGate : ctx . gate ,
609- authorize : createReactorAuthorize ( ctx . gate ) ,
610- pinToolSetAtBuild : true ,
611- } ;
612- const live = { session : await openIntegrationSession ( sessionOpts ) } ;
613- cleanups . push ( ( ) => closeIntegrationSession ( live . session ) ) ;
614- live . session . harness . scenario . replyOnce ( "anthropic" , {
615- toolCalls : [
616- { name : "run_shell" , args : { command : CLOSED_PORT_PROBE } } ,
617- ] ,
618- } ) ;
619- live . session . harness . scenario . stall ( ) ;
620-
621- const turn = await runUntilSuspended (
622- live . session ,
623- "Probe the local port." ,
624- ) ;
625- const suspended = turn . result ;
626- if ( suspended . type !== "suspended" ) throw new Error ( "did not park" ) ;
627- const parked = ( await live . session . storage . load ( ) ) . pendingOperations . find (
628- ( op ) => op . correlationId === suspended . correlationId ,
635+ const {
636+ live,
637+ sessionOpts,
638+ ctx,
639+ clock,
640+ suspended,
641+ events,
642+ callId,
643+ deadline,
644+ } = await parkOnAsk (
645+ { name : "run_shell" , args : { command : CLOSED_PORT_PROBE } } ,
646+ { } ,
629647 ) ;
630- const callId = parked ?. suspendedCall ?. id ;
631- const deadline = parked ?. timeoutAt ;
632- if ( callId === undefined || deadline === undefined ) {
633- throw new Error ( "parked call has no persisted call id or deadline" ) ;
634- }
635648
636649 void live . session . agent . send ( "Still there?" ) . catch ( ( ) => undefined ) ;
637650 await waitFor (
@@ -649,7 +662,7 @@ describe("integration: a rejection the reactor accepted but never ran, then reop
649662 ctx . reject ( ) ;
650663 expect ( await handling ) . toBe ( true ) ;
651664 await waitFor (
652- ( ) => turn . events . some ( ( e ) => e . type === "message.correlated" ) ,
665+ ( ) => events . some ( ( e ) => e . type === "message.correlated" ) ,
653666 "rejection accepted" ,
654667 ) ;
655668
0 commit comments