Skip to content

Commit 20a0c25

Browse files
committed
test(tui): pin worker waiting strip to snapshots and director routing
1 parent 720a5f0 commit 20a0c25

1 file changed

Lines changed: 287 additions & 2 deletions

File tree

‎src/tui/runner/wiring.ask-wake.test.ts‎

Lines changed: 287 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ import {
1313
} from "../runtime-bridge.js";
1414
import { createLiveSessionPort } from "../live-session-port.js";
1515
import { createAppShell } from "../shell/index.js";
16-
import { withTestRenderer } from "../harness.js";
16+
import { withTestRenderer, type Harness } from "../harness.js";
1717
import { resetSessionForRotation } from "./exit.js";
18-
import { clearTranscript } from "../shell/chrome.js";
18+
import { clearTranscript, setMcpNeedsAuth } from "../shell/chrome.js";
19+
import type { AppShell } from "../shell/internals.js";
20+
import { closeInsetOverlay } from "../shell/overlay-host.js";
21+
import { submitPrompt } from "../shell/prompt.js";
22+
import { wireGates } from "../gate-wire.js";
23+
import { ASK_DIRECTOR_WAKE_PREFIX } from "../../subagent/fleet-report.js";
1924
import {
2025
createDeliveryGeneration,
2126
createLeftoverSend,
@@ -223,6 +228,8 @@ for (const phase of ["settled", "prequeued", "deferred"] as const) {
223228
{ type: "agent-ask", asks: [] },
224229
{ type: "fleet", running: 0 },
225230
]);
231+
expect(shell.workerWait.items).toEqual([]);
232+
expect(shell.workerWaitRow.visible).toBe(false);
226233
expect(
227234
store.list().every((worker) => worker.status === "cancelled"),
228235
).toBe(true);
@@ -301,6 +308,10 @@ for (const removal of [
301308
resolve: () => undefined,
302309
reject: () => undefined,
303310
});
311+
expect(shell.workerWait.items.map((item) => item.sessionId)).toEqual([
312+
worker.id,
313+
]);
314+
expect(shell.workerWaitRow.visible).toBe(true);
304315
if (removal === "answer") {
305316
const mailbox = createFleetMailbox(store);
306317
mailbox.register(worker.id);
@@ -338,6 +349,8 @@ for (const removal of [
338349
type: "agent-ask",
339350
asks: [],
340351
});
352+
expect(shell.workerWait.items).toEqual([]);
353+
expect(shell.workerWaitRow.visible).toBe(false);
341354
bridge.handle({ type: "inference.done", data: {} });
342355
expect(sends).toEqual([]);
343356
} finally {
@@ -528,3 +541,275 @@ test("yield wait does not stamp; sending pendingAskWakeText gates list_agents",
528541
{ width: 80, height: 24 },
529542
);
530543
});
544+
545+
interface StripRig {
546+
readonly shell: AppShell;
547+
readonly h: Harness;
548+
readonly bridge: ReturnType<typeof attachSessionBridge>;
549+
readonly store: ReturnType<typeof createSubAgentSessionStore>;
550+
readonly emitter: EventEmitter;
551+
/** Everything the primary session port was handed, in order. */
552+
readonly toPrimary: string[];
553+
readonly answers: string[];
554+
readonly park: (id: string, questionId: string, question: string) => void;
555+
}
556+
557+
async function withStripRig(fn: (rig: StripRig) => Promise<void>) {
558+
await withTestRenderer(
559+
async (h) => {
560+
const shell = createAppShell(h.renderer, {
561+
terminal: { columns: 120, rows: 30 },
562+
wireKeys: false,
563+
});
564+
const toPrimary: string[] = [];
565+
const answers: string[] = [];
566+
const bridge = attachSessionBridge(
567+
shell,
568+
createLiveSessionPort({
569+
send: (text) => {
570+
toPrimary.push(text);
571+
},
572+
deliver: (text) => {
573+
toPrimary.push(text);
574+
},
575+
interrupt: () => undefined,
576+
}),
577+
);
578+
const store = createSubAgentSessionStore();
579+
const emitter = new EventEmitter();
580+
emitter.on("event", (event: BridgeInboundEvent) => bridge.handle(event));
581+
const publisher = createFleetWakePublisher(store, emitter);
582+
const unsubscribe = store.subscribe(publisher.publish);
583+
const disposeGates = wireGates(emitter, shell, {
584+
onGateOpened: () => bridge.gateOpened(),
585+
onGateClosed: () => bridge.gateClosed(),
586+
});
587+
const park = (id: string, questionId: string, question: string) => {
588+
if (store.list().every((lane) => lane.id !== id)) {
589+
store.start({ id, agentId: "builder", description: id, brief: "b" });
590+
store.markRunning(id);
591+
}
592+
store.registerAsk(id, {
593+
question,
594+
questionId,
595+
resolve: (answer) => {
596+
answers.push(`${id}:${answer}`);
597+
},
598+
reject: () => undefined,
599+
});
600+
};
601+
try {
602+
await fn({
603+
shell,
604+
h,
605+
bridge,
606+
store,
607+
emitter,
608+
toPrimary,
609+
answers,
610+
park,
611+
});
612+
} finally {
613+
disposeGates();
614+
unsubscribe();
615+
bridge.dispose();
616+
shell.dispose();
617+
}
618+
},
619+
{ width: 120, height: 30 },
620+
);
621+
}
622+
623+
async function stripText(h: Harness): Promise<string | undefined> {
624+
await h.renderOnce();
625+
await h.renderOnce();
626+
return h
627+
.captureCharFrame()
628+
.split("\n")
629+
.find((row) => row.includes("WAITING"));
630+
}
631+
632+
test("the strip follows agent-ask snapshots while the wake reaches the director and send_input resolves it", async () => {
633+
await withStripRig(
634+
async ({ shell, h, bridge, store, toPrimary, answers, park }) => {
635+
bridge.handle({ type: "inference.start", data: {} });
636+
park("worker-session", "q1", "Which destination path should I use?");
637+
const parked = await stripText(h);
638+
expect(parked).toContain("WORKER WAITING");
639+
expect(parked).toContain("Which destination path should I use?");
640+
expect(toPrimary).toEqual([]);
641+
642+
// The primary settles: the wake goes to the director, the strip stays.
643+
bridge.handle({ type: "inference.done", data: {} });
644+
expect(toPrimary).toHaveLength(1);
645+
expect(toPrimary[0]).toContain("Which destination path should I use?");
646+
expect(toPrimary[0]).toContain("using target worker-session");
647+
expect(await stripText(h)).toBe(parked);
648+
649+
// Director inference on the wake and its settlement are not resolution.
650+
bridge.handle({ type: "inference.start", data: {} });
651+
expect(await stripText(h)).toBe(parked);
652+
bridge.handle({ type: "inference.done", data: {} });
653+
expect(await stripText(h)).toBe(parked);
654+
655+
// A republished snapshot of the same identity does not duplicate it.
656+
store.wake();
657+
expect(shell.workerWait.items).toHaveLength(1);
658+
expect(toPrimary).toHaveLength(1);
659+
660+
// Only the director's send_input to the worker session resolves it.
661+
expect(store.sendInputOne("worker-session", "/srv/out").ok).toBe(true);
662+
expect(answers).toEqual(["worker-session:/srv/out"]);
663+
expect(await stripText(h)).toBeUndefined();
664+
expect(shell.workerWaitRow.visible).toBe(false);
665+
},
666+
);
667+
});
668+
669+
test("multiple parked workers keep the displayed identity and an accurate count", async () => {
670+
await withStripRig(async ({ shell, h, bridge, store, park }) => {
671+
bridge.handle({ type: "inference.start", data: {} });
672+
park("first", "q1", "First question?");
673+
park("second", "q2", "Second question?");
674+
park("third", "q3", "Third question?");
675+
let strip = await stripText(h);
676+
expect(strip).toContain("First question?");
677+
expect(strip).toContain("(+2 more)");
678+
679+
// A later worker's replacement question leaves the displayed one alone.
680+
store.cancelAsk("third");
681+
park("third", "q4", "Third replacement?");
682+
strip = await stripText(h);
683+
expect(strip).toContain("First question?");
684+
expect(strip).toContain("(+2 more)");
685+
expect(shell.workerWait.items.map((item) => item.questionId)).toEqual([
686+
"q1",
687+
"q2",
688+
"q4",
689+
]);
690+
691+
// The displayed identity resolves: fall back to snapshot order.
692+
expect(store.sendInputOne("first", "ok").ok).toBe(true);
693+
strip = await stripText(h);
694+
expect(strip).toContain("Second question?");
695+
expect(strip).toContain("(+1 more)");
696+
});
697+
});
698+
699+
test("composer submission still goes to the director and never answers or clears the worker", async () => {
700+
await withStripRig(
701+
async ({ shell, h, bridge, store, toPrimary, answers, park }) => {
702+
bridge.handle({ type: "inference.start", data: {} });
703+
park("worker-session", "q1", "Which destination path should I use?");
704+
bridge.handle({ type: "inference.done", data: {} });
705+
expect(toPrimary).toHaveLength(1);
706+
const parked = await stripText(h);
707+
708+
// Mid wake turn: Enter queues a steer for the primary session.
709+
bridge.handle({ type: "inference.start", data: {} });
710+
shell.prompt.value = "try /srv/out";
711+
submitPrompt(shell, "steer");
712+
expect(shell.session.items.map((item) => item.text)).toEqual([
713+
"try /srv/out",
714+
]);
715+
expect(answers).toEqual([]);
716+
expect(store.hasPendingAsk("worker-session")).toBe(true);
717+
expect(await stripText(h)).toBe(parked);
718+
bridge.handle({ type: "inference.done", data: {} });
719+
720+
// Idle with the worker still live: Enter is a new primary turn and the
721+
// operator's words reach the director verbatim.
722+
shell.prompt.value = "/srv/out";
723+
submitPrompt(shell, "steer");
724+
expect(toPrimary.at(-1)).toBe("/srv/out");
725+
expect(answers).toEqual([]);
726+
expect(store.hasPendingAsk("worker-session")).toBe(true);
727+
expect(await stripText(h)).toBe(parked);
728+
729+
// Typing alone changes nothing either.
730+
shell.prompt.value = "still thinking";
731+
expect(await stripText(h)).toBe(parked);
732+
expect(store.hasPendingAsk("worker-session")).toBe(true);
733+
},
734+
);
735+
});
736+
737+
test("ask_operator and permission gates never activate the strip", async () => {
738+
await withStripRig(async ({ shell, h, bridge, emitter }) => {
739+
bridge.handle({ type: "inference.start", data: {} });
740+
let operatorAnswer: unknown;
741+
emitter.emit("operator.gate", {
742+
id: "ask-1",
743+
question: "Which destination path should I use?",
744+
options: ["Cancel", "Continue"],
745+
resolve: (value: unknown) => {
746+
operatorAnswer = value;
747+
},
748+
});
749+
expect(shell.overlayList).not.toBeNull();
750+
expect(shell.workerWait.items).toEqual([]);
751+
expect(shell.layout.heights.worker_wait).toBe(0);
752+
expect(await stripText(h)).toBeUndefined();
753+
closeInsetOverlay(shell);
754+
expect(operatorAnswer).toBeDefined();
755+
756+
emitter.emit("permission.gate", {
757+
id: "req-1",
758+
request: {
759+
tool: "run_shell",
760+
action: "Run shell command",
761+
subject: "rm -rf build",
762+
scopes: [],
763+
},
764+
resolve: () => undefined,
765+
});
766+
expect(shell.overlayKind).toBe("permissions");
767+
expect(shell.workerWait.items).toEqual([]);
768+
expect(await stripText(h)).toBeUndefined();
769+
});
770+
});
771+
772+
test("transcript wake text, status events and MCP attention never activate the strip", async () => {
773+
await withStripRig(async ({ shell, h, bridge }) => {
774+
bridge.handle({
775+
type: "assistant",
776+
text: `${ASK_DIRECTOR_WAKE_PREFIX} worker builder parked question q1`,
777+
});
778+
bridge.handle({ type: "system", text: "worker builder is waiting" });
779+
bridge.handle({ type: "fleet", running: 2 });
780+
setMcpNeedsAuth(shell, ["granola"]);
781+
expect(shell.workerWait.items).toEqual([]);
782+
expect(shell.layout.heights.worker_wait).toBe(0);
783+
expect(await stripText(h)).toBeUndefined();
784+
});
785+
});
786+
787+
test("a live strip and an ask_operator overlay stay separate", async () => {
788+
await withStripRig(async ({ shell, h, bridge, emitter, store, park }) => {
789+
bridge.handle({ type: "inference.start", data: {} });
790+
park("worker-session", "q1", "Which destination path should I use?");
791+
const parked = await stripText(h);
792+
emitter.emit("operator.gate", {
793+
id: "ask-2",
794+
question: "Ship it?",
795+
options: ["No", "Yes"],
796+
resolve: () => undefined,
797+
});
798+
expect(shell.overlayList).not.toBeNull();
799+
expect(await stripText(h)).toBe(parked);
800+
closeInsetOverlay(shell);
801+
expect(await stripText(h)).toBe(parked);
802+
expect(store.hasPendingAsk("worker-session")).toBe(true);
803+
});
804+
});
805+
806+
test("bridge teardown clears the strip", async () => {
807+
await withStripRig(async ({ shell, h, bridge, park }) => {
808+
bridge.handle({ type: "inference.start", data: {} });
809+
park("worker-session", "q1", "Which destination path should I use?");
810+
expect(await stripText(h)).toContain("WORKER WAITING");
811+
bridge.dispose();
812+
expect(shell.workerWait.items).toEqual([]);
813+
expect(await stripText(h)).toBeUndefined();
814+
});
815+
});

0 commit comments

Comments
 (0)