Skip to content

Commit 7333c1e

Browse files
committed
Drop overlay approval after interrupt rebuilds the agent
Interrupt rebuilt the agent while the permission overlay stayed open, so a later accept or decline still saw a current generation and delivered into the new agent. Bump delivery generation on interrupt the same way session rotation does.
1 parent 3ac2806 commit 7333c1e

3 files changed

Lines changed: 47 additions & 2 deletions

File tree

src/session/approval-resume.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ export function createApprovalResume(args: {
115115
// TUI session queue. When present, each decision is awaited through this
116116
// seam; exec omits it and uses getAgent().deliver.
117117
deliver?: (message: InboundMessage, stillCurrent: () => boolean) => void | Promise<void>;
118-
// TUI: capture at handle() start so /clear during the overlay drops the
119-
// decision instead of delivering into the new session. Exec omits this.
118+
// TUI: capture at handle() start so /clear or interrupt during the overlay
119+
// drops the decision instead of delivering into the rebuilt agent. Exec omits this.
120120
captureGeneration?: () => () => boolean;
121121
gate: PermissionGate;
122122
}): ApprovalResume {

src/tui/runner/exit.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,9 @@ export async function createRunLifecycle(
401401
// Close it, drain the old stream, and rebuild a fresh agent so the next send
402402
// works.
403403
const interrupt = (): void => {
404+
// Overlay stays open across interrupt; bump so a later accept/decline
405+
// cannot late-bind into the rebuilt agent.
406+
services.deliveryGeneration.bump();
404407
state.sendAborted = true;
405408
void enqueueOp(async () => {
406409
try {

tests/unit/approval-resume.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,46 @@ describe("approval resume generation capture", () => {
236236
outcome: "approved",
237237
});
238238
});
239+
240+
async function interruptDuringOverlayThenDecide(outcome: { allow: boolean; message?: string }) {
241+
const generation = createDeliveryGeneration();
242+
const deliveredA: unknown[] = [];
243+
const deliveredB: unknown[] = [];
244+
const agentA = {
245+
deliver: (message: unknown) => deliveredA.push(message),
246+
history: async () => [userTurn()],
247+
};
248+
const agentB = {
249+
deliver: (message: unknown) => deliveredB.push(message),
250+
history: async () => [userTurn()],
251+
};
252+
let current: typeof agentA | typeof agentB = agentA;
253+
const resume = createApprovalResume({
254+
getAgent: () => current,
255+
captureGeneration: generation.capture,
256+
deliver: (message, stillCurrent) => {
257+
if (!stillCurrent()) return;
258+
current.deliver(message);
259+
},
260+
gate: {
261+
resolveSuspended: async () => {
262+
generation.bump();
263+
current = agentB;
264+
return outcome;
265+
},
266+
} as unknown as PermissionGate,
267+
});
268+
269+
expect(await resume.handle(SUSPENDED)).toBe(true);
270+
expect(deliveredA).toEqual([]);
271+
expect(deliveredB).toEqual([]);
272+
}
273+
274+
test("interrupt during overlay then accept does not deliver to the rebuilt agent", async () => {
275+
await interruptDuringOverlayThenDecide({ allow: true });
276+
});
277+
278+
test("interrupt during overlay then decline does not deliver to the rebuilt agent", async () => {
279+
await interruptDuringOverlayThenDecide({ allow: false, message: "not today" });
280+
});
239281
});

0 commit comments

Comments
 (0)