@@ -30,13 +30,24 @@ import {
3030} from "../../session/approval-resume.js" ;
3131import type { PermissionGate } from "../../permission/gate.js" ;
3232import { ASK_DIRECTOR_WAKE_PREFIX } from "../../subagent/fleet-report.js" ;
33+ import { withTestRenderer } from "../harness.js" ;
34+ import { createAppShell } from "../shell/index.js" ;
35+ import { shellInternals , type AppShell } from "../shell/internals.js" ;
3336import { createDeliverRouting , createSubmitPath } from "./submit.js" ;
3437import type { RunnerServices , RunnerState } from "./state.js" ;
3538
3639const CORRELATION = "00000000-0000-4000-8000-0000000000a1" ;
3740const PARKED_CALL = "call_parked_approve_worker" ;
3841
39- function harness ( ) {
42+ interface HarnessOptions {
43+ // The operator's answer; defaults to allow-once.
44+ resolveSuspended ?: ( ) => Promise < { allow : boolean } > ;
45+ // Whether the stub reactor accepts a delivered decision.
46+ acceptsDecisions ?: boolean ;
47+ shell ?: AppShell ;
48+ }
49+
50+ function harness ( opts : HarnessOptions = { } ) {
4051 const delivered : InboundMessage [ ] = [ ] ;
4152 const waiters = new Map < string , ( ) => void > ( ) ;
4253 const acceptance = {
@@ -70,17 +81,17 @@ function harness() {
7081 deliver : ( message : InboundMessage ) => {
7182 delivered . push ( message ) ;
7283 const id = message . headers . interchangeCorrelationId ;
73- if ( id !== undefined ) acceptance . settle ( id ) ;
84+ if ( id !== undefined && opts . acceptsDecisions !== false )
85+ acceptance . settle ( id ) ;
7486 } ,
7587 history : async ( ) => [ ] ,
7688 } as unknown as Agent ;
7789
7890 let gateAsked = 0 ;
79- // Operator answers allow-once immediately.
8091 const gate = {
8192 resolveSuspended : async ( ) => {
8293 gateAsked ++ ;
83- return { allow : true } ;
94+ return opts . resolveSuspended ?. ( ) ?? { allow : true } ;
8495 } ,
8596 } as unknown as PermissionGate ;
8697
@@ -94,6 +105,7 @@ function harness() {
94105 fatalBuildError : null ,
95106 inFlight : 0 ,
96107 sendAborted : false ,
108+ liveSource : { credentialId : undefined } ,
97109 approvalPersistNotice : { } ,
98110 credentialRecovery : {
99111 begin : ( ) => ( { failedProvider : "p" } ) ,
@@ -107,14 +119,14 @@ function harness() {
107119 setSuspendedApprovalRecovery : ( ) => undefined ,
108120 parentCycleLive : false ,
109121 } ,
110- shell : { } ,
122+ shell : opts . shell ?? { } ,
111123 } ,
112124 } as unknown as RunnerState ;
113125
114126 const approvalDeliverer = createApprovalDeliverer ( {
115127 deliverToAgent : ( message ) => agent . deliver ( message ) ,
116128 acceptance,
117- timeoutMs : 1_000 ,
129+ timeoutMs : 50 ,
118130 } ) ;
119131 const approvalResume = createApprovalResume ( {
120132 getAgent : ( ) => agent ,
@@ -208,3 +220,63 @@ test("wake-drained turn: allow-once decision reaches the reactor", async () => {
208220 // And the queue must not be wedged: a later operator send awaits the tail.
209221 expect ( await settleWithin ( h . sessionOps . awaitTail ( ) , 200 ) ) . toBe ( "settled" ) ;
210222} ) ;
223+
224+ // A fresh shell is on the landing, which defers system rows until it closes.
225+ const systemRows = ( shell : AppShell ) : string [ ] =>
226+ [
227+ ...( shellInternals ( shell ) ?. landingDeferredRows ?? [ ] ) ,
228+ ...shell . streamLog ,
229+ ] . flatMap ( ( row ) => ( row . role === "system" ? [ row . text ] : [ ] ) ) ;
230+
231+ const drainWake = ( h : ReturnType < typeof harness > ) : ( ( ) => unknown ) => {
232+ let settled : unknown ;
233+ h . route (
234+ `${ ASK_DIRECTOR_WAKE_PREFIX } — worker qa-lead parked question ask-1` ,
235+ "queue" ,
236+ [ ] ,
237+ ( result ) => {
238+ settled = result ;
239+ } ,
240+ false ,
241+ ) ;
242+ return ( ) => settled ;
243+ } ;
244+
245+ // A decision that fails after the drained send returned cannot change the
246+ // drain's result: the wake already reached the agent, and restoring it would
247+ // resend it. It must be surfaced the way a failed send is instead.
248+ for ( const [ name , opts ] of [
249+ [
250+ "the approval surface throws" ,
251+ {
252+ resolveSuspended : ( ) =>
253+ Promise . reject ( new Error ( "approval overlay failed" ) ) ,
254+ } ,
255+ ] ,
256+ [ "the reactor never accepts the decision" , { acceptsDecisions : false } ] ,
257+ ] as const ) {
258+ test ( `wake-drained turn: surfaces a lost decision when ${ name } ` , async ( ) => {
259+ await withTestRenderer ( async ( r ) => {
260+ const shell = createAppShell ( r . renderer , {
261+ title : "test" ,
262+ cwd : "/tmp" ,
263+ terminal : { columns : 80 , rows : 24 } ,
264+ wireKeys : false ,
265+ } ) ;
266+ try {
267+ const h = harness ( { ...opts , shell } ) ;
268+ const settled = drainWake ( h ) ;
269+ await new Promise ( ( resolve ) => setTimeout ( resolve , 200 ) ) ;
270+ expect ( h . gateAsked ( ) ) . toBe ( 1 ) ;
271+ expect ( settled ( ) ) . toEqual ( { status : "accepted" } ) ;
272+ expect ( h . state . runError ) . toBeDefined ( ) ;
273+ expect ( systemRows ( shell ) . length ) . toBe ( 1 ) ;
274+ expect ( await settleWithin ( h . sessionOps . awaitTail ( ) , 200 ) ) . toBe (
275+ "settled" ,
276+ ) ;
277+ } finally {
278+ shell . dispose ( ) ;
279+ }
280+ } ) ;
281+ } ) ;
282+ }
0 commit comments