Skip to content

Commit 0c3fb27

Browse files
committed
test(tui): cover duplicate and marked-ask settlement routes in gate-wire
Two distinct events sharing an id must both resolve across accept, timeout, and dispose teardown, and every marked primary ask route (free text, Esc, timeout) must admit and settle exactly once. MCP-trust-shaped events keep modal behavior but never fire input-required hooks.
1 parent 9372c45 commit 0c3fb27

1 file changed

Lines changed: 254 additions & 3 deletions

File tree

‎src/tui/gate-wire.test.ts‎

Lines changed: 254 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ describe("wireGates", () => {
512512
);
513513
});
514514

515-
test("re-emitted marked primary ask keeps one live gate and resolves once", async () => {
515+
test("re-emitting a marked primary ask queues a second gate; both resolvers settle", async () => {
516516
const admitted: string[] = [];
517517
const settled: string[] = [];
518518
const resolved: unknown[] = [];
@@ -521,18 +521,71 @@ describe("wireGates", () => {
521521
const event = {
522522
id: "same-ask",
523523
source: PRIMARY_ASK_OPERATOR_SOURCE,
524+
question: "Proceed?",
525+
options: ["Cancel", "Continue"],
524526
resolve: (result: unknown) => resolved.push(result),
525527
};
526528
emitOperator(emitter, event);
527529
emitOperator(emitter, event);
528530

529-
expect(admitted).toEqual(["same-ask"]);
531+
// The gate-wire layer no longer drops same-id events: dedup lives in
532+
// the input-required reducer (operator-input-required.ts), which keeps
533+
// the display single while gate-wire preserves HEAD's queue behavior.
534+
expect(admitted).toEqual(["same-ask", "same-ask"]);
530535
expect(shell.overlayKind).toBe("operator");
531536

532537
acceptOverlaySelection(shell);
538+
expect(resolved).toEqual([{ kind: "option", index: 0 }]);
539+
// The re-emitted gate was queued, not dropped: it takes the host next.
540+
expect(shell.overlayKind).toBe("operator");
541+
acceptOverlaySelection(shell);
542+
expect(resolved).toEqual([
543+
{ kind: "option", index: 0 },
544+
{ kind: "option", index: 0 },
545+
]);
546+
expect(settled).toEqual(["same-ask", "same-ask"]);
547+
expect(shell.overlayList).toBeNull();
548+
},
549+
{
550+
hooks: {
551+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
552+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
553+
},
554+
},
555+
);
556+
});
557+
558+
test("two distinct events sharing an id both resolve on accept", async () => {
559+
const admitted: string[] = [];
560+
const settled: string[] = [];
561+
const resolved: unknown[] = [];
562+
await withGates(
563+
async ({ shell, emitter }) => {
564+
emitOperator(emitter, {
565+
id: "dup",
566+
source: PRIMARY_ASK_OPERATOR_SOURCE,
567+
options: ["Cancel", "Continue"],
568+
resolve: (result: unknown) => resolved.push(result),
569+
});
570+
emitOperator(emitter, {
571+
id: "dup",
572+
source: PRIMARY_ASK_OPERATOR_SOURCE,
573+
options: ["Cancel", "Continue"],
574+
resolve: (result: unknown) => resolved.push(result),
575+
});
533576

534-
expect(settled).toEqual(["same-ask"]);
577+
expect(admitted).toEqual(["dup", "dup"]);
578+
expect(shell.overlayKind).toBe("operator");
579+
580+
acceptOverlaySelection(shell);
535581
expect(resolved).toEqual([{ kind: "option", index: 0 }]);
582+
expect(shell.overlayKind).toBe("operator");
583+
acceptOverlaySelection(shell);
584+
expect(resolved).toEqual([
585+
{ kind: "option", index: 0 },
586+
{ kind: "option", index: 0 },
587+
]);
588+
expect(settled).toEqual(["dup", "dup"]);
536589
expect(shell.overlayList).toBeNull();
537590
},
538591
{
@@ -544,6 +597,87 @@ describe("wireGates", () => {
544597
);
545598
});
546599

600+
test("two distinct events sharing an id both settle on timeout", async () => {
601+
const settled: string[] = [];
602+
const resolved: unknown[] = [];
603+
await withGates(
604+
async ({ shell, emitter }) => {
605+
emitOperator(emitter, {
606+
id: "dup",
607+
source: PRIMARY_ASK_OPERATOR_SOURCE,
608+
options: ["Yes", "No"],
609+
timeoutMs: 5,
610+
resolve: (result: unknown) => resolved.push(result),
611+
});
612+
emitOperator(emitter, {
613+
id: "dup",
614+
source: PRIMARY_ASK_OPERATOR_SOURCE,
615+
options: ["Yes", "No"],
616+
// Arms only once the queued duplicate opens, so the two settles are
617+
// observable separately.
618+
timeoutMs: 80,
619+
resolve: (result: unknown) => resolved.push(result),
620+
});
621+
expect(shell.overlayKind).toBe("operator");
622+
623+
await new Promise((r) => setTimeout(r, 20));
624+
expect(resolved).toEqual([operatorCancelResult()]);
625+
// The queued duplicate opened once the first timed out.
626+
expect(shell.overlayKind).toBe("operator");
627+
await new Promise((r) => setTimeout(r, 120));
628+
expect(resolved).toEqual([
629+
operatorCancelResult(),
630+
operatorCancelResult(),
631+
]);
632+
expect(settled).toEqual(["dup", "dup"]);
633+
expect(shell.overlayList).toBeNull();
634+
},
635+
{
636+
hooks: {
637+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
638+
},
639+
},
640+
);
641+
});
642+
643+
test("a duplicate id still queued at dispose settles via the teardown sweep", async () => {
644+
const settled: string[] = [];
645+
const resolved: unknown[] = [];
646+
await withGates(
647+
async ({ shell, emitter, disposeGates }) => {
648+
emitOperator(emitter, {
649+
id: "dup",
650+
source: PRIMARY_ASK_OPERATOR_SOURCE,
651+
options: ["Yes", "No"],
652+
resolve: (result: unknown) => resolved.push(result),
653+
});
654+
emitOperator(emitter, {
655+
id: "dup",
656+
source: PRIMARY_ASK_OPERATOR_SOURCE,
657+
options: ["Yes", "No"],
658+
resolve: (result: unknown) => resolved.push(result),
659+
});
660+
expect(shell.overlayKind).toBe("operator");
661+
662+
acceptOverlaySelection(shell);
663+
expect(resolved).toHaveLength(1);
664+
expect(shell.overlayKind).toBe("operator");
665+
666+
disposeGates();
667+
expect(resolved).toEqual([
668+
{ kind: "option", index: 0 },
669+
operatorCancelResult(),
670+
]);
671+
expect(settled).toEqual(["dup", "dup"]);
672+
},
673+
{
674+
hooks: {
675+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
676+
},
677+
},
678+
);
679+
});
680+
547681
test("unmarked operator gates retain modal behavior without input-required hooks", async () => {
548682
let admitted = 0;
549683
let settled = 0;
@@ -564,6 +698,123 @@ describe("wireGates", () => {
564698
);
565699
});
566700

701+
test("an MCP-trust-shaped operator gate opens the modal but never fires input-required hooks", async () => {
702+
let admitted = 0;
703+
let settled = 0;
704+
await withGates(
705+
async ({ shell, emitter }) => {
706+
emitOperator(emitter, {
707+
question: "Trust this local MCP server?",
708+
options: ["Trust and connect", "Cancel"],
709+
});
710+
expect(shell.overlayKind).toBe("operator");
711+
expect(admitted).toBe(0);
712+
acceptOverlaySelection(shell);
713+
expect(settled).toBe(0);
714+
},
715+
{
716+
hooks: {
717+
onPrimaryOperatorAdmitted: () => admitted++,
718+
onPrimaryOperatorSettled: () => settled++,
719+
},
720+
},
721+
);
722+
});
723+
724+
test("free-text answer settles a marked ask with one admission and one settlement", async () => {
725+
const admitted: string[] = [];
726+
const settled: string[] = [];
727+
const resolved: unknown[] = [];
728+
await withGates(
729+
async ({ shell, emitter }) => {
730+
emitOperator(emitter, {
731+
source: PRIMARY_ASK_OPERATOR_SOURCE,
732+
options: ["Continue"],
733+
resolve: (result: unknown) => resolved.push(result),
734+
});
735+
expect(admitted).toEqual(["ask-1"]);
736+
setOverlayAnswerActive(shell, true);
737+
for (const ch of "yes") {
738+
handleOverlayAnswerKey(shell, {
739+
name: ch,
740+
sequence: ch,
741+
ctrl: false,
742+
meta: false,
743+
option: false,
744+
} as unknown as KeyEvent);
745+
}
746+
handleOverlayAnswerKey(shell, {
747+
name: "return",
748+
sequence: "",
749+
ctrl: false,
750+
meta: false,
751+
option: false,
752+
} as unknown as KeyEvent);
753+
expect(resolved).toEqual([{ kind: "custom", text: "yes" }]);
754+
expect(settled).toEqual(["ask-1"]);
755+
expect(shell.overlayList).toBeNull();
756+
},
757+
{
758+
hooks: {
759+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
760+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
761+
},
762+
},
763+
);
764+
});
765+
766+
test("Esc settles a marked ask with one admission and one settlement", async () => {
767+
const admitted: string[] = [];
768+
const settled: string[] = [];
769+
const resolved: unknown[] = [];
770+
await withGates(
771+
async ({ shell, emitter }) => {
772+
emitOperator(emitter, {
773+
source: PRIMARY_ASK_OPERATOR_SOURCE,
774+
resolve: (result: unknown) => resolved.push(result),
775+
});
776+
expect(admitted).toEqual(["ask-1"]);
777+
closeInsetOverlay(shell);
778+
expect(resolved).toEqual([{ kind: "cancel" }]);
779+
expect(settled).toEqual(["ask-1"]);
780+
expect(shell.overlayList).toBeNull();
781+
},
782+
{
783+
hooks: {
784+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
785+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
786+
},
787+
},
788+
);
789+
});
790+
791+
test("timeout settles a marked ask with one admission and one settlement", async () => {
792+
const admitted: string[] = [];
793+
const settled: string[] = [];
794+
const resolved: unknown[] = [];
795+
await withGates(
796+
async ({ shell, emitter }) => {
797+
emitOperator(emitter, {
798+
source: PRIMARY_ASK_OPERATOR_SOURCE,
799+
timeoutMs: 5,
800+
resolve: (result: unknown) => resolved.push(result),
801+
});
802+
expect(admitted).toEqual(["ask-1"]);
803+
expect(shell.overlayKind).toBe("operator");
804+
await new Promise((r) => setTimeout(r, 20));
805+
expect(resolved).toEqual([operatorCancelResult()]);
806+
expect(settled).toEqual(["ask-1"]);
807+
expect(shell.overlayList).toBeNull();
808+
},
809+
{
810+
hooks: {
811+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
812+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
813+
},
814+
},
815+
);
816+
});
817+
567818
test("a marked queued ask settles its own indicator on abort and disposal", async () => {
568819
const settled: string[] = [];
569820
await withGates(

0 commit comments

Comments
 (0)