Skip to content

Commit 6a1bb54

Browse files
committed
Expire error recovery when the operator interrupts
1 parent d557b26 commit 6a1bb54

2 files changed

Lines changed: 49 additions & 2 deletions

File tree

src/tui/runtime-bridge.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -905,7 +905,8 @@ describe("same-turn failover after inference.error", () => {
905905
wireKeys: false,
906906
run: "idle",
907907
});
908-
const bridge = attachSessionBridge(shell, createRecordingPort());
908+
const port = createRecordingPort();
909+
const bridge = attachSessionBridge(shell, port);
909910
try {
910911
bridge.handle({ type: "inference.start", data: {} });
911912
bridge.handle({
@@ -927,6 +928,47 @@ describe("same-turn failover after inference.error", () => {
927928

928929
expect(errorRows(shell)).toEqual([]);
929930
expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered");
931+
// Same-turn failover, not an operator stop — recovery must not borrow interrupt.
932+
expect(port.calls.some((c) => c.op === "interrupt")).toBe(false);
933+
expect(shell.streamLog.some((r) => r.meta === "stop")).toBe(false);
934+
} finally {
935+
bridge.dispose();
936+
shell.dispose();
937+
}
938+
},
939+
{ width: 80, height: 24 },
940+
);
941+
});
942+
943+
test("interrupt then a new prompt keeps the prompt and the classified error", async () => {
944+
await withTestRenderer(
945+
async (h) => {
946+
const shell = createAppShell(h.renderer, {
947+
terminal: { columns: 80, rows: 24 },
948+
wireKeys: false,
949+
run: "idle",
950+
});
951+
const port = createRecordingPort();
952+
const bridge = attachSessionBridge(shell, port);
953+
try {
954+
bridge.handle({ type: "inference.start", data: {} });
955+
bridge.handle({
956+
type: "inference.error",
957+
data: {
958+
error: { category: "credential_failure", message: "Forbidden", statusCode: 403 },
959+
},
960+
});
961+
bridge.interrupt();
962+
bridge.submit("next prompt", "immediate");
963+
bridge.handle({
964+
type: "message.received",
965+
data: { message: { content: "next prompt" } },
966+
});
967+
bridge.handle({ type: "inference.start", data: {} });
968+
969+
const text = shell.streamLog.map((r) => r.text).join("\n");
970+
expect(text).toContain("next prompt");
971+
expect(errorRows(shell)).toContain("Session expired — re-authenticating…");
930972
} finally {
931973
bridge.dispose();
932974
shell.dispose();
@@ -995,7 +1037,7 @@ describe("same-turn failover after inference.error", () => {
9951037
bridge.handle(event);
9961038
}
9971039

998-
expect(errorRows(shell).length).toBeGreaterThan(0);
1040+
expect(errorRows(shell)).toContain("Session expired — re-authenticating…");
9991041
} finally {
10001042
bridge.dispose();
10011043
shell.dispose();

src/tui/runtime-bridge.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,11 @@ export function attachSessionBridge(
11951195
if (bag.disposed) return;
11961196
closeOpenRow(shell, bag);
11971197
bag.pendingEchoes.length = 0;
1198+
// The stopped attempt is no longer in flight. Expire the error-recovery
1199+
// handoff so a later new-turn inference.start cannot roll back the
1200+
// classified error, the stop row, or the operator's next prompt.
1201+
bag.mapCtx.errorRollbackArmed = false;
1202+
bag.attemptRow = null;
11981203
applyShellInterrupt(shell);
11991204
bag.port.interrupt();
12001205
// The stop settles the turn without necessarily producing an idle event to

0 commit comments

Comments
 (0)