Skip to content

Commit 8ab537e

Browse files
bwachmanclaude
andcommitted
fix(tui): settle the turn when a parked call ends without running
A call parked on an approval gate is registered as active when the model streams it. Approved, the reactor re-dispatches it and it reports through its own tool.start and tool.done. Rejected, timed out or dropped, the reactor answers it with a synthetic error result and emits nothing naming it, so the call stayed active. The model's follow-up reply arrived, but a turn with an active call never settles, so the footer stayed at "working" until the operator interrupted. The bridge now records which active calls had not started executing when an approval gate blocked. A call that later starts leaves the set. The reactor cannot re-infer while a call is parked, so an inference.start that finds a recorded call still unstarted means it was answered without running. The bridge closes it as not run through the same path a tool.done takes, and the turn settles when the reply ends. Siblings that were already executing, and gates other than approvals, are left alone. Bridge tests cover the rejected, approved, executing-sibling and non-approval cases. An end-to-end test captures the real reactor's stream for a rejected and an approved parked call and replays it into the bridge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent abe5aa8 commit 8ab537e

3 files changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* A parked call that ends without running is answered by the reactor with a
3+
* synthetic error result and no `tool.done`. These tests capture the real
4+
* reactor's event stream for a rejected and an approved parked call and replay
5+
* it into the TUI bridge, so the bridge is judged against what the reactor
6+
* actually emits rather than a hand-written sequence.
7+
*/
8+
import { describe, expect, test } from "bun:test";
9+
10+
import type { ReactorEmittedEvent } from "@intx/inference";
11+
12+
import { createPermissionGate } from "../src/permission/gate.js";
13+
import { createReactorAuthorize } from "../src/permission/reactor-authorize.js";
14+
import {
15+
createApprovalResume,
16+
resolveParkedCallIdFromStore,
17+
} from "../src/session/approval-resume.js";
18+
import {
19+
attachSessionBridge,
20+
createRecordingPort,
21+
PARKED_CALL_NOT_RUN,
22+
} from "../src/tui/runtime-bridge.js";
23+
import { withAppShell } from "../src/tui/test-helpers.js";
24+
import {
25+
closeIntegrationSession,
26+
openIntegrationSession,
27+
runUntilSuspended,
28+
toolDoneEvents,
29+
} from "./integration-harness.js";
30+
31+
/** Drives one parked call to the operator's decision; returns every event. */
32+
async function parkedCallEvents(
33+
allow: boolean,
34+
): Promise<ReactorEmittedEvent[]> {
35+
let release: ((outcome: { allow: boolean }) => void) | undefined;
36+
const gate = createPermissionGate({
37+
approvals: [],
38+
interactive: true,
39+
skipPermissions: false,
40+
auto: false,
41+
reactorGated: true,
42+
requestApproval: () =>
43+
new Promise((resolve) => {
44+
release = resolve;
45+
}),
46+
});
47+
const session = await openIntegrationSession({
48+
permissionGate: gate,
49+
authorize: createReactorAuthorize(gate),
50+
});
51+
try {
52+
session.harness.scenario.replyOnce("anthropic", {
53+
toolCalls: [
54+
{
55+
name: "run_shell",
56+
args: { command: "curl -sS https://example.com" },
57+
},
58+
],
59+
});
60+
session.harness.scenario.replyOnce("anthropic", { text: "Understood." });
61+
62+
const turn = await runUntilSuspended(session, "Fetch example.com.");
63+
if (turn.result.type !== "suspended") throw new Error("did not park");
64+
const resume = createApprovalResume({
65+
getAgent: () => session.agent,
66+
gate,
67+
resolveParkedCallId: (correlationId) =>
68+
resolveParkedCallIdFromStore(session.storage, correlationId),
69+
});
70+
const handling = resume.handle(turn.result);
71+
for (let i = 0; i < 400 && release === undefined; i++) {
72+
await new Promise((resolve) => setTimeout(resolve, 5));
73+
}
74+
release?.({ allow });
75+
await handling;
76+
await turn.reply();
77+
// Let the run-ended event that follows the reply land.
78+
await new Promise((resolve) => setTimeout(resolve, 50));
79+
return [...turn.events];
80+
} finally {
81+
await closeIntegrationSession(session);
82+
}
83+
}
84+
85+
async function replayIntoBridge(events: readonly ReactorEmittedEvent[]) {
86+
let outcome:
87+
| {
88+
run: string;
89+
active: readonly string[];
90+
rows: { text: string; failed?: boolean; pending?: boolean }[];
91+
}
92+
| undefined;
93+
await withAppShell(
94+
(shell) => {
95+
const bridge = attachSessionBridge(shell, createRecordingPort());
96+
try {
97+
for (const event of events) {
98+
bridge.handle(event as { type: string; data?: unknown });
99+
}
100+
outcome = {
101+
run: shell.session.run,
102+
active: bridge.turn.activeToolCalls,
103+
rows: shell.streamLog.filter(
104+
(row) => row.role === "tool",
105+
) as unknown as {
106+
text: string;
107+
failed?: boolean;
108+
pending?: boolean;
109+
}[],
110+
};
111+
} finally {
112+
bridge.dispose();
113+
}
114+
},
115+
{ shell: { run: "busy" } },
116+
);
117+
if (outcome === undefined) throw new Error("bridge did not run");
118+
return outcome;
119+
}
120+
121+
describe("integration: a parked call's end settles the TUI turn", () => {
122+
test.serial(
123+
"a rejected call emits no tool.done, and the bridge still closes it and settles",
124+
async () => {
125+
const events = await parkedCallEvents(false);
126+
// The premise of the fix: nothing on the stream names the parked call.
127+
expect(toolDoneEvents(events)).toHaveLength(0);
128+
129+
const after = await replayIntoBridge(events);
130+
expect(after.active).toEqual([]);
131+
expect(after.run).toBe("idle");
132+
expect(after.rows).toHaveLength(1);
133+
expect(after.rows[0]?.failed).toBe(true);
134+
expect(after.rows[0]?.text).toBe(PARKED_CALL_NOT_RUN);
135+
},
136+
);
137+
138+
test.serial(
139+
"an approved call reports its own result and is not closed as not run",
140+
async () => {
141+
const events = await parkedCallEvents(true);
142+
expect(toolDoneEvents(events)).toHaveLength(1);
143+
144+
const after = await replayIntoBridge(events);
145+
expect(after.active).toEqual([]);
146+
expect(after.run).toBe("idle");
147+
expect(after.rows).toHaveLength(1);
148+
expect(after.rows[0]?.text).not.toBe(PARKED_CALL_NOT_RUN);
149+
},
150+
);
151+
});

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
mapReactorLike,
1212
type TaskProgressSession,
1313
type TurnMonitorOptions,
14+
PARKED_CALL_NOT_RUN,
1415
} from "./runtime-bridge";
1516
import { DEFAULT_STALL_MS } from "./agent-progress";
1617
import { appendStreamRow, paintChrome } from "./shell/chrome";
@@ -1045,6 +1046,126 @@ describe("parallel sub-agent dispatch on the live session bridge", () => {
10451046
});
10461047
});
10471048

1049+
describe("calls parked on an approval gate", () => {
1050+
type Event = { type: string; data?: unknown };
1051+
1052+
const toolCall = (id: string): Event[] => [
1053+
{
1054+
type: "inference.tool_call.start",
1055+
data: { callId: id, name: "run_shell" },
1056+
},
1057+
{
1058+
type: "inference.tool_call.end",
1059+
data: {
1060+
name: "run_shell",
1061+
callId: id,
1062+
arguments: JSON.stringify({ command: `touch ${id}` }),
1063+
},
1064+
},
1065+
];
1066+
const gateBlocked = (reason = "approval"): Event => ({
1067+
type: "reactor.gate.blocked",
1068+
data: { reason, gateId: "gate-1", correlationId: "corr-1" },
1069+
});
1070+
// The reactor re-infers once the parked call is answered.
1071+
const followUpReply: Event[] = [
1072+
{ type: "inference.start", data: {} },
1073+
{ type: "inference.text.delta", data: { token: "It was not run." } },
1074+
{ type: "inference.done", data: {} },
1075+
{ type: "connector.reply", data: { content: "It was not run." } },
1076+
];
1077+
const toolRow = (shell: { streamLog: readonly { role: string }[] }) =>
1078+
shell.streamLog.filter((r) => r.role === "tool") as unknown as {
1079+
text: string;
1080+
pending?: boolean;
1081+
failed?: boolean;
1082+
}[];
1083+
1084+
test("a call answered without running is closed as not run and the turn settles", async () => {
1085+
await withBridge({ run: "busy" }, async ({ shell, bridge }) => {
1086+
const events: Event[] = [
1087+
{ type: "inference.start", data: {} },
1088+
...toolCall("c1"),
1089+
{ type: "inference.done", data: {} },
1090+
gateBlocked(),
1091+
];
1092+
for (const event of events) bridge.handle(event);
1093+
expect(shell.session.run).toBe("busy");
1094+
1095+
// Rejected, timed out or dropped: no tool.done, just the re-inference.
1096+
for (const event of followUpReply) bridge.handle(event);
1097+
1098+
expect(bridge.turn.activeToolCalls).toEqual([]);
1099+
expect(shell.session.run).toBe("idle");
1100+
expect(shell.lockupPhase).toBeNull();
1101+
const rows = toolRow(shell);
1102+
expect(rows).toHaveLength(1);
1103+
expect(rows[0]?.pending).not.toBe(true);
1104+
expect(rows[0]?.failed).toBe(true);
1105+
expect(rows[0]?.text).toBe(PARKED_CALL_NOT_RUN);
1106+
});
1107+
});
1108+
1109+
test("an approved call reports through its own events and is never closed early", async () => {
1110+
await withBridge({ run: "busy" }, async ({ shell, bridge }) => {
1111+
const events: Event[] = [
1112+
{ type: "inference.start", data: {} },
1113+
...toolCall("c1"),
1114+
{ type: "inference.done", data: {} },
1115+
gateBlocked(),
1116+
// Approved: the reactor re-dispatches the exact parked call.
1117+
{ type: "tool.start", data: { call: { id: "c1", name: "run_shell" } } },
1118+
{
1119+
type: "tool.done",
1120+
data: { result: { callId: "c1", name: "run_shell", content: "ok" } },
1121+
},
1122+
...followUpReply,
1123+
];
1124+
for (const event of events) bridge.handle(event);
1125+
1126+
expect(shell.session.run).toBe("idle");
1127+
const rows = toolRow(shell);
1128+
expect(rows).toHaveLength(1);
1129+
expect(rows[0]?.failed).not.toBe(true);
1130+
expect(rows[0]?.text).toBe("ok");
1131+
});
1132+
});
1133+
1134+
test("a sibling already executing when the gate blocked stays open", async () => {
1135+
await withBridge({ run: "busy" }, async ({ shell, bridge }) => {
1136+
const events: Event[] = [
1137+
{ type: "inference.start", data: {} },
1138+
...toolCall("c1"),
1139+
...toolCall("c2"),
1140+
{ type: "inference.done", data: {} },
1141+
// c1 was auto-allowed and is running; c2 parked on the gate.
1142+
{ type: "tool.start", data: { call: { id: "c1", name: "run_shell" } } },
1143+
gateBlocked(),
1144+
{ type: "inference.start", data: {} },
1145+
];
1146+
for (const event of events) bridge.handle(event);
1147+
1148+
expect(bridge.turn.activeToolCalls).toEqual(["c1"]);
1149+
expect(shell.session.run).toBe("busy");
1150+
});
1151+
});
1152+
1153+
test("a gate that is not an approval does not close anything", async () => {
1154+
await withBridge({ run: "busy" }, async ({ bridge }) => {
1155+
const events: Event[] = [
1156+
{ type: "inference.start", data: {} },
1157+
...toolCall("c1"),
1158+
{ type: "inference.done", data: {} },
1159+
gateBlocked("input"),
1160+
{ type: "inference.start", data: {} },
1161+
];
1162+
for (const event of events) bridge.handle(event);
1163+
1164+
expect(bridge.turn.activeToolCalls).toEqual(["c1"]);
1165+
});
1166+
});
1167+
});
1168+
10481169
describe("idle-with-fleet (CL-7057)", () => {
10491170
test("parent settles while fleet is live: run holds busy, follow-up keeps waiting", async () => {
10501171
await withBridge({ run: "idle" }, async ({ h, shell, port, bridge }) => {

0 commit comments

Comments
 (0)