@@ -12,6 +12,7 @@ import type {
1212 InboundMessage ,
1313} from "@intx/types/runtime" ;
1414
15+ import { APPROVAL_TIMEOUT_RESULT_TEXT } from "../permission/decline-markers.js" ;
1516import type { PermissionGate } from "../permission/gate.js" ;
1617import { createExtraDeniedPathMatcher } from "../plugins/secret-guard-plugin.js" ;
1718import {
@@ -23,6 +24,7 @@ import {
2324 resolveSuspendedApprovalFromStore ,
2425} from "./approval-resume.js" ;
2526import { createSessionOperationQueue } from "../tui/delivery-queue.js" ;
27+ import { createParkedOverlayAbortBinding } from "../tui/runner/parked-overlay-abort.js" ;
2628import { defined } from "../../testkit/defined.js" ;
2729import { redactedCorrelationId } from "./redacted-correlation.js" ;
2830import {
@@ -936,6 +938,78 @@ describe("approval identity ordering", () => {
936938 expect ( decisionBody ( message ) . outcome ) . toBe ( "rejected" ) ;
937939 } ) ;
938940
941+ test ( "generation change during catch history drops the parked call once" , async ( ) => {
942+ const ctx = deferredResume ( ) ;
943+ const failure = new Error ( "gate exploded" ) ;
944+ ctx . resolveSuspended . mockImplementation ( async ( ) => {
945+ throw failure ;
946+ } ) ;
947+ ctx . history . mockResolvedValueOnce ( [ ] ) . mockImplementationOnce ( async ( ) => {
948+ ctx . invalidate ( ) ;
949+ return [ ] ;
950+ } ) ;
951+ const handling = ctx . resume . handle ( suspension ( "corr-A" , "echo same" ) ) ;
952+ ctx . lookup . resolve ( "call-A" ) ;
953+ await expect ( handling ) . rejects . toBe ( failure ) ;
954+ expect ( ctx . onDropped ) . toHaveBeenCalledTimes ( 1 ) ;
955+ expect ( ctx . onDropped ) . toHaveBeenCalledWith ( APPROVAL_DROPPED_NOTICE ) ;
956+ expect ( ctx . deliver ) . toHaveBeenCalledTimes ( 1 ) ;
957+ const message = ctx . deliver . mock . calls [ 0 ] ?. [ 0 ] ;
958+ if ( message === undefined )
959+ throw new Error ( "expected cancellation rejection" ) ;
960+ expect ( deliveredCorrelationId ( message ) ) . toBe ( "corr-A" ) ;
961+ expect ( decisionBody ( message ) ) . toEqual ( {
962+ outcome : "rejected" ,
963+ message : APPROVAL_DROPPED_NOTICE ,
964+ } ) ;
965+ } ) ;
966+
967+ test ( "a gate throw with no generation change rejects once as approval surface failed" , async ( ) => {
968+ const ctx = deferredResume ( ) ;
969+ const failure = new Error ( "gate exploded" ) ;
970+ ctx . resolveSuspended . mockImplementation ( async ( ) => {
971+ throw failure ;
972+ } ) ;
973+ const handling = ctx . resume . handle ( suspension ( "corr-A" , "echo same" ) ) ;
974+ ctx . lookup . resolve ( "call-A" ) ;
975+ await expect ( handling ) . rejects . toBe ( failure ) ;
976+ expect ( ctx . onDropped ) . not . toHaveBeenCalled ( ) ;
977+ expect ( ctx . deliver ) . toHaveBeenCalledTimes ( 1 ) ;
978+ const message = ctx . deliver . mock . calls [ 0 ] ?. [ 0 ] ;
979+ if ( message === undefined ) throw new Error ( "expected surface rejection" ) ;
980+ expect ( deliveredCorrelationId ( message ) ) . toBe ( "corr-A" ) ;
981+ expect ( decisionBody ( message ) ) . toEqual ( {
982+ outcome : "rejected" ,
983+ message : "approval surface failed" ,
984+ } ) ;
985+ } ) ;
986+
987+ test ( "generation change during catch history after a timeout does not reject again" , async ( ) => {
988+ const ctx = deferredResume ( ) ;
989+ const failure = new Error ( "gate exploded" ) ;
990+ ctx . resolveSuspended . mockImplementation ( async ( ) => {
991+ throw failure ;
992+ } ) ;
993+ const reading = Promise . withResolvers < undefined > ( ) ;
994+ const history = Promise . withResolvers < ConversationTurn [ ] > ( ) ;
995+ ctx . history . mockResolvedValueOnce ( [ ] ) . mockImplementationOnce ( ( ) => {
996+ reading . resolve ( undefined ) ;
997+ return history . promise ;
998+ } ) ;
999+ const handling = ctx . resume . handle ( suspension ( "corr-A" , "echo same" ) ) ;
1000+ ctx . lookup . resolve ( "call-A" ) ;
1001+ await reading . promise ;
1002+ expect ( ctx . resolveSuspended ) . toHaveBeenCalledTimes ( 1 ) ;
1003+ expect ( ctx . registered ( ) ) . toBeUndefined ( ) ;
1004+ ctx . invalidate ( ) ;
1005+ history . resolve ( [ timeoutTurn ( "call-A" ) ] ) ;
1006+ await expect ( handling ) . rejects . toBe ( failure ) ;
1007+ expect ( ctx . onDropped ) . toHaveBeenCalledTimes ( 1 ) ;
1008+ expect ( ctx . onDropped ) . toHaveBeenCalledWith ( APPROVAL_DROPPED_NOTICE ) ;
1009+ expect ( ctx . deliver ) . not . toHaveBeenCalled ( ) ;
1010+ expect ( ctx . registered ( ) ) . toBeUndefined ( ) ;
1011+ } ) ;
1012+
9391013 for ( const timedOutCallId of [ "call-A" , "call-B" ] ) {
9401014 test ( `generation change during post-gate history with ${ timedOutCallId } timeout preserves exact-call cancellation` , async ( ) => {
9411015 const ctx = deferredResume ( ) ;
@@ -1192,10 +1266,9 @@ describe("approval resume retry re-await", () => {
11921266 expect ( delivered ) . toHaveLength ( 1 ) ;
11931267 } ) ;
11941268
1195- // Concurrent duplicates share one in-flight rejection (one gate, zero
1196- // deliveries), then a retry re-gates and delivers exactly once, and later
1197- // retries memoize.
1198- test ( "a shared rejection re-gates once, delivers once, then memoizes" , async ( ) => {
1269+ // Concurrent duplicates share one in-flight gate failure (one gate, one
1270+ // rejection delivered), and later retries memoize.
1271+ test ( "a shared gate failure rejects the parked call once, then memoizes" , async ( ) => {
11991272 const gate = Promise . withResolvers < { allow : boolean } > ( ) ;
12001273 const { resume, delivered, resolveSuspended } = retryHarness ( {
12011274 allow : true ,
@@ -1210,15 +1283,79 @@ describe("approval resume retry re-await", () => {
12101283 await expect ( first ) . rejects . toBe ( failure ) ;
12111284 await expect ( second ) . rejects . toBe ( failure ) ;
12121285 expect ( resolveSuspended ) . toHaveBeenCalledTimes ( 1 ) ;
1213- expect ( delivered ) . toHaveLength ( 0 ) ;
1214-
1215- expect ( await resume . handle ( suspension ( "corr-A" , "echo alpha" ) ) ) . toBe ( true ) ;
1216- expect ( resolveSuspended ) . toHaveBeenCalledTimes ( 2 ) ;
1286+ // Nothing retries a parked call, so the failure answers it.
12171287 expect ( delivered ) . toHaveLength ( 1 ) ;
1218- expect ( decisionBody ( onlyDecision ( delivered ) ) . outcome ) . toBe ( "approved " ) ;
1288+ expect ( decisionBody ( onlyDecision ( delivered ) ) . outcome ) . toBe ( "rejected " ) ;
12191289
12201290 expect ( await resume . handle ( suspension ( "corr-A" , "echo alpha" ) ) ) . toBe ( true ) ;
1221- expect ( resolveSuspended ) . toHaveBeenCalledTimes ( 2 ) ;
1291+ expect ( resolveSuspended ) . toHaveBeenCalledTimes ( 1 ) ;
12221292 expect ( delivered ) . toHaveLength ( 1 ) ;
12231293 } ) ;
1294+ test ( "a gate failure after the reactor timed the call out delivers nothing" , async ( ) => {
1295+ const delivered : InboundMessage [ ] = [ ] ;
1296+ let timedOut = false ;
1297+ const failure = new Error ( "gate exploded" ) ;
1298+ const resume = createApprovalResume ( {
1299+ getAgent : ( ) => ( {
1300+ deliver : ( message : InboundMessage ) => {
1301+ delivered . push ( message ) ;
1302+ } ,
1303+ history : async ( ) =>
1304+ timedOut
1305+ ? ( [
1306+ {
1307+ role : "user" ,
1308+ content : [
1309+ {
1310+ type : "tool_result" ,
1311+ callId : "call-A" ,
1312+ content : [
1313+ { type : "text" , text : APPROVAL_TIMEOUT_RESULT_TEXT } ,
1314+ ] ,
1315+ } ,
1316+ ] ,
1317+ } ,
1318+ ] as unknown as Awaited < ReturnType < Agent [ "history" ] > > )
1319+ : [ ] ,
1320+ } ) ,
1321+ resolveParkedCallId : ( ) => "call-A" ,
1322+ gate : {
1323+ resolveSuspended : async ( ) => {
1324+ timedOut = true ;
1325+ throw failure ;
1326+ } ,
1327+ } as unknown as PermissionGate ,
1328+ } ) ;
1329+ await expect (
1330+ resume . handle ( suspension ( "corr-A" , "echo alpha" ) ) ,
1331+ ) . rejects . toBe ( failure ) ;
1332+ expect ( delivered ) . toEqual ( [ ] ) ;
1333+ } ) ;
1334+ } ) ;
1335+
1336+ describe ( "parked overlay registration after the operator answers" , ( ) => {
1337+ // approve_worker_call raises a nested permission prompt as soon as its
1338+ // redispatched call starts, while the decision that resumed it is still being
1339+ // delivered. That prompt's abort signal merges the parked overlay controller.
1340+ test ( "a permission prompt raised while the decision is delivered is not pre-aborted" , async ( ) => {
1341+ const binding = createParkedOverlayAbortBinding ( ) ;
1342+ const identity = new AbortController ( ) ;
1343+ const { agent, gate } = createApprovalResumeHarness ( ) ;
1344+ let promptSignal : AbortSignal | undefined ;
1345+ const resume = createApprovalResume ( {
1346+ getAgent : ( ) => agent ,
1347+ gate,
1348+ resolveParkedCallId : ( ) => "call-A" ,
1349+ registerOverlayAbort : binding . registerOverlayAbort ,
1350+ deliver : async ( message ) => {
1351+ promptSignal = binding . identitySignal ( identity . signal ) ;
1352+ agent . deliver ( message ) ;
1353+ } ,
1354+ } ) ;
1355+ expect (
1356+ await resume . handle ( suspension ( "corr-A" , "git status --short" ) ) ,
1357+ ) . toBe ( true ) ;
1358+ expect ( promptSignal ) . toBeDefined ( ) ;
1359+ expect ( promptSignal ?. aborted ) . toBe ( false ) ;
1360+ } ) ;
12241361} ) ;
0 commit comments