Skip to content

Commit ff577d9

Browse files
committed
Route leftover steers through send not live deliver
Live parent tool.boundary still injects via Agent.deliver. Steers left at idle, idle-with-fleet, or after interrupt must send so they share sendQueue, inFlight, and token refresh with follow-ups.
1 parent 7a2bebc commit ff577d9

10 files changed

Lines changed: 331 additions & 44 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ In TUI chat mode there is no completion gate — the session stays open across t
8383
- Bridges reactor events to the OpenTUI host via a plain `EventEmitter`
8484
- **Mid-run injection** — Shell `session-queue` items drain at the parent
8585
`tool.boundary` through `SessionPort.deliver`. Production `routeQueuedDelivery`
86-
sends steers via `agentProxy.deliver` (`Agent.deliver`) into the live reactor
87-
and idle follow-ups via the existing send path. `/clear` and `/new` bump a
86+
live-injects in-flight parent-boundary steers via `agentProxy.deliver`
87+
(`Agent.deliver`) into the live reactor. Idle leftover, idle-with-fleet, and
88+
post-interrupt steers, plus follow-ups (`kind === "queue"`), use the existing
89+
send path. `/clear` and `/new` bump a
8890
delivery generation and call `SessionBridge.clearQueuedDelivery()` so queued
8991
input from the previous session cannot enter the new one.
9092
- **Session rotation** — Uses a serial session-operation queue (`createSessionOperationQueue`, not a boolean flag) so rotation, compaction continuation, and `agentProxy.deliver` never race a concurrent rebuild. Each operation chains onto the tail, ensuring in-flight work completes before the agent is torn down.

‎docs/TUI.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ events carrying the live-lane count and the bridge holds the run busy on it.
481481
During the hold, Enter upgrades to a new primary turn sent immediately —
482482
there is no parent tool left to steer — while Alt+Enter follow-ups keep
483483
waiting for true session-idle. A steer still pending when the hold engages
484-
delivers at once (the parent it was steering has stopped), and the last lane
484+
sends at once (the parent it was steering has stopped), and the last lane
485485
terminalizing releases the hold, drains follow-ups, and returns the session
486486
to idle.
487487

‎src/tui/live-session-port.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface LiveSessionPortDeps {
2323
) => SubmitClassification;
2424
/** Hard interrupt current run (runner close/rebuild). */
2525
interrupt: () => void;
26-
/** Drained queue/steer item at tool boundary (or idle). Always forwarded. */
26+
/** Drained queue/steer item. Kind routing (live inject vs send) is the host's. */
2727
deliver: (text: string, kind: QueueKind, attachments?: readonly PendingImageAttachment[]) => void;
2828
}
2929

‎src/tui/prompt-attachments.test.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, test } from "bun:test";
22

33
import type { AttachImageResult, PendingImageAttachment } from "./image-attachments.js";
4-
import { ingestPathMentions, spliceMentionCompletion } from "./prompt-attachments.js";
4+
import {
5+
ingestOperatorPrompt,
6+
ingestPathMentions,
7+
spliceMentionCompletion,
8+
} from "./prompt-attachments.js";
59

610
function attachment(name: string): PendingImageAttachment {
711
return {
@@ -40,6 +44,30 @@ describe("ingestPathMentions", () => {
4044
});
4145
});
4246

47+
describe("ingestOperatorPrompt", () => {
48+
test("merges pending attachments and expands a missing @mention", async () => {
49+
const pending = attachment("clip.png");
50+
const result = await ingestOperatorPrompt(
51+
"use @missing.ts",
52+
"/repo",
53+
async () => {
54+
throw new Error("must not load");
55+
},
56+
[pending],
57+
);
58+
expect(result.text).toContain("@missing.ts (not found)");
59+
expect(result.attachments).toEqual([pending]);
60+
});
61+
62+
test("does not send — only returns ingested text and attachments", async () => {
63+
const result = await ingestOperatorPrompt("just words", "/repo", async () => {
64+
throw new Error("must not load");
65+
});
66+
expect(result.text).toBe("just words");
67+
expect(result.attachments).toEqual([]);
68+
});
69+
});
70+
4371
describe("spliceMentionCompletion", () => {
4472
test("replaces the typed token and keeps the trailing text", () => {
4573
const value = "read @src/tu rest";

‎src/tui/prompt-attachments.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type AttachImageResult,
1010
type PendingImageAttachment,
1111
} from "./image-attachments.js";
12+
import { resolveAtMentions } from "./mention-resolution.js";
1213

1314
export type { PendingImageAttachment };
1415

@@ -42,6 +43,21 @@ export async function ingestPathMentions(
4243
return { text: out, attachments };
4344
}
4445

46+
/**
47+
* Shared operator-prompt ingest for send and live-steer deliver: inline image
48+
* paths become attachments and @mentions are expanded. Does not send.
49+
*/
50+
export async function ingestOperatorPrompt(
51+
text: string,
52+
cwd: string,
53+
load: (path: string) => Promise<AttachImageResult>,
54+
pending: readonly PendingImageAttachment[] = [],
55+
): Promise<PathMentionIngestion> {
56+
const ingested = await ingestPathMentions(text, cwd, load);
57+
const resolved = await resolveAtMentions(ingested.text, cwd);
58+
return { text: resolved, attachments: [...pending, ...ingested.attachments] };
59+
}
60+
4561
export interface MentionSplice {
4662
readonly value: string;
4763
readonly cursor: number;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Last-hop pins for drained queue items: live parent-boundary steers
3+
* Agent.deliver (deliverSteer); leftover / fleet-hold / interrupt use send.
4+
*/
5+
import { describe, expect, test } from "bun:test";
6+
import { attachSessionBridge, type SessionBridge } from "./runtime-bridge";
7+
import { createLiveSessionPort } from "./live-session-port";
8+
import { createAppShell } from "./shell";
9+
import { withTestRenderer } from "./harness";
10+
import { routeQueuedDelivery } from "./queued-delivery.js";
11+
import { badgeCount } from "./session-queue";
12+
13+
function lastHopPort(bridgeRef: { current: SessionBridge | undefined }) {
14+
const sends: string[] = [];
15+
const steers: string[] = [];
16+
const port = createLiveSessionPort({
17+
send: (text) => {
18+
sends.push(text);
19+
},
20+
interrupt: () => {},
21+
deliver: routeQueuedDelivery({
22+
send: (text) => {
23+
sends.push(text);
24+
},
25+
deliverSteer: (text) => {
26+
steers.push(text);
27+
},
28+
parentCycleLive: () => bridgeRef.current?.parentCycleLive === true,
29+
}),
30+
});
31+
return { port, sends, steers };
32+
}
33+
34+
describe("queued delivery last hop", () => {
35+
test("busy parent tool.boundary steer last-hops to deliverSteer, not send", async () => {
36+
await withTestRenderer(
37+
async (h) => {
38+
const shell = createAppShell(h.renderer, {
39+
terminal: { columns: 80, rows: 24 },
40+
wireKeys: false,
41+
run: "busy",
42+
});
43+
const bridgeRef: { current: SessionBridge | undefined } = { current: undefined };
44+
const { port, sends, steers } = lastHopPort(bridgeRef);
45+
const bridge = attachSessionBridge(shell, port);
46+
bridgeRef.current = bridge;
47+
try {
48+
bridge.submit("asap", "steer");
49+
expect(badgeCount(shell.session)).toBe(1);
50+
bridge.handle({ type: "tool.boundary" });
51+
expect(steers).toEqual(["asap"]);
52+
expect(sends).toEqual([]);
53+
} finally {
54+
bridge.dispose();
55+
shell.dispose();
56+
}
57+
},
58+
{ width: 80, height: 24 },
59+
);
60+
});
61+
62+
test("text-only settle leftover steer last-hops to send", async () => {
63+
await withTestRenderer(
64+
async (h) => {
65+
const shell = createAppShell(h.renderer, {
66+
terminal: { columns: 80, rows: 24 },
67+
wireKeys: false,
68+
run: "busy",
69+
});
70+
const bridgeRef: { current: SessionBridge | undefined } = { current: undefined };
71+
const { port, sends, steers } = lastHopPort(bridgeRef);
72+
const bridge = attachSessionBridge(shell, port);
73+
bridgeRef.current = bridge;
74+
try {
75+
bridge.submit("leftover", "steer");
76+
bridge.handle({ type: "inference.start", data: {} });
77+
bridge.handle({ type: "inference.text.delta", data: { token: "hi" } });
78+
bridge.handle({ type: "inference.done", data: {} });
79+
expect(sends).toEqual(["leftover"]);
80+
expect(steers).toEqual([]);
81+
} finally {
82+
bridge.dispose();
83+
shell.dispose();
84+
}
85+
},
86+
{ width: 80, height: 24 },
87+
);
88+
});
89+
90+
test("interrupt leftover steer last-hops to send", async () => {
91+
await withTestRenderer(
92+
async (h) => {
93+
const shell = createAppShell(h.renderer, {
94+
terminal: { columns: 80, rows: 24 },
95+
wireKeys: false,
96+
run: "busy",
97+
});
98+
const bridgeRef: { current: SessionBridge | undefined } = { current: undefined };
99+
const { port, sends, steers } = lastHopPort(bridgeRef);
100+
const bridge = attachSessionBridge(shell, port);
101+
bridgeRef.current = bridge;
102+
try {
103+
bridge.submit("after stop", "steer");
104+
bridge.interrupt();
105+
expect(sends).toEqual(["after stop"]);
106+
expect(steers).toEqual([]);
107+
} finally {
108+
bridge.dispose();
109+
shell.dispose();
110+
}
111+
},
112+
{ width: 80, height: 24 },
113+
);
114+
});
115+
116+
test("idle-with-fleet leftover steer last-hops to send", async () => {
117+
await withTestRenderer(
118+
async (h) => {
119+
const shell = createAppShell(h.renderer, {
120+
terminal: { columns: 80, rows: 24 },
121+
wireKeys: false,
122+
run: "idle",
123+
});
124+
const bridgeRef: { current: SessionBridge | undefined } = { current: undefined };
125+
const { port, sends, steers } = lastHopPort(bridgeRef);
126+
const bridge = attachSessionBridge(shell, port);
127+
bridgeRef.current = bridge;
128+
try {
129+
bridge.submit("dispatch", "immediate");
130+
bridge.submit("one more worker", "steer");
131+
bridge.handle({ type: "fleet", running: 1 });
132+
bridge.handle({ type: "inference.start", data: {} });
133+
bridge.handle({ type: "inference.done", data: {} });
134+
expect(sends).toEqual(["dispatch", "one more worker"]);
135+
expect(steers).toEqual([]);
136+
} finally {
137+
bridge.dispose();
138+
shell.dispose();
139+
}
140+
},
141+
{ width: 80, height: 24 },
142+
);
143+
});
144+
145+
test("/clear drops queued steers so a later boundary does not deliver or send", async () => {
146+
await withTestRenderer(
147+
async (h) => {
148+
const shell = createAppShell(h.renderer, {
149+
terminal: { columns: 80, rows: 24 },
150+
wireKeys: false,
151+
run: "busy",
152+
});
153+
const bridgeRef: { current: SessionBridge | undefined } = { current: undefined };
154+
const { port, sends, steers } = lastHopPort(bridgeRef);
155+
const bridge = attachSessionBridge(shell, port);
156+
bridgeRef.current = bridge;
157+
try {
158+
bridge.submit("old steer", "steer");
159+
expect(badgeCount(shell.session)).toBe(1);
160+
bridge.clearQueuedDelivery();
161+
bridge.handle({ type: "tool.boundary" });
162+
expect(sends).toEqual([]);
163+
expect(steers).toEqual([]);
164+
} finally {
165+
bridge.dispose();
166+
shell.dispose();
167+
}
168+
},
169+
{ width: 80, height: 24 },
170+
);
171+
});
172+
});

‎src/tui/queued-delivery.test.ts‎

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,54 +10,69 @@ const image: PendingImageAttachment = {
1010
contentHash: "hash-1",
1111
};
1212

13+
function recordHops(parentCycleLive: () => boolean) {
14+
const sends: string[] = [];
15+
const steers: string[] = [];
16+
const deliver = routeQueuedDelivery({
17+
send: (text) => {
18+
sends.push(text);
19+
},
20+
deliverSteer: (text) => {
21+
steers.push(text);
22+
},
23+
parentCycleLive,
24+
});
25+
return { deliver, sends, steers };
26+
}
27+
1328
describe("routeQueuedDelivery", () => {
14-
test("steer calls deliverSteer only", () => {
15-
const sends: string[] = [];
16-
const steers: string[] = [];
17-
const deliver = routeQueuedDelivery({
18-
send: (text) => {
19-
sends.push(text);
20-
},
21-
deliverSteer: (text) => {
22-
steers.push(text);
23-
},
24-
});
29+
test("live parent-boundary steer calls deliverSteer only", () => {
30+
const { deliver, sends, steers } = recordHops(() => true);
2531
deliver("asap", "steer");
2632
expect(steers).toEqual(["asap"]);
2733
expect(sends).toEqual([]);
2834
});
2935

30-
test("queue calls send only", () => {
31-
const sends: string[] = [];
32-
const steers: string[] = [];
33-
const deliver = routeQueuedDelivery({
34-
send: (text) => {
35-
sends.push(text);
36-
},
37-
deliverSteer: (text) => {
38-
steers.push(text);
39-
},
40-
});
36+
test("leftover steer (idle / fleet-hold / post-interrupt) calls send only", () => {
37+
const { deliver, sends, steers } = recordHops(() => false);
38+
deliver("leftover", "steer");
39+
expect(sends).toEqual(["leftover"]);
40+
expect(steers).toEqual([]);
41+
});
42+
43+
test("queue calls send only, even while the parent cycle is live", () => {
44+
const { deliver, sends, steers } = recordHops(() => true);
4145
deliver("later", "queue");
4246
expect(sends).toEqual(["later"]);
4347
expect(steers).toEqual([]);
4448
});
4549

46-
test("forwards attachments on both kinds", () => {
50+
test("forwards attachments on both hops", () => {
4751
const sent: (readonly PendingImageAttachment[] | undefined)[] = [];
4852
const steered: (readonly PendingImageAttachment[] | undefined)[] = [];
49-
const deliver = routeQueuedDelivery({
53+
const live = routeQueuedDelivery({
54+
send: (_text, attachments) => {
55+
sent.push(attachments);
56+
},
57+
deliverSteer: (_text, attachments) => {
58+
steered.push(attachments);
59+
},
60+
parentCycleLive: () => true,
61+
});
62+
const leftover = routeQueuedDelivery({
5063
send: (_text, attachments) => {
5164
sent.push(attachments);
5265
},
5366
deliverSteer: (_text, attachments) => {
5467
steered.push(attachments);
5568
},
69+
parentCycleLive: () => false,
5670
});
57-
deliver("asap", "steer", [image]);
58-
deliver("later", "queue", [image]);
71+
live("asap", "steer", [image]);
72+
leftover("later", "steer", [image]);
73+
leftover("follow", "queue", [image]);
5974
expect(steered).toEqual([[image]]);
60-
expect(sent).toEqual([[image]]);
75+
expect(sent).toEqual([[image], [image]]);
6176
});
6277
});
6378

0 commit comments

Comments
 (0)