Skip to content

Commit 9fa3816

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 dbac6b3 commit 9fa3816

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
@@ -491,7 +491,7 @@ describe("wireGates", () => {
491491
);
492492
});
493493

494-
test("re-emitted marked primary ask keeps one live gate and resolves once", async () => {
494+
test("re-emitting a marked primary ask queues a second gate; both resolvers settle", async () => {
495495
const admitted: string[] = [];
496496
const settled: string[] = [];
497497
const resolved: unknown[] = [];
@@ -500,18 +500,71 @@ describe("wireGates", () => {
500500
const event = {
501501
id: "same-ask",
502502
source: PRIMARY_ASK_OPERATOR_SOURCE,
503+
question: "Proceed?",
504+
options: ["Cancel", "Continue"],
503505
resolve: (result: unknown) => resolved.push(result),
504506
};
505507
emitOperator(emitter, event);
506508
emitOperator(emitter, event);
507509

508-
expect(admitted).toEqual(["same-ask"]);
510+
// The gate-wire layer no longer drops same-id events: dedup lives in
511+
// the input-required reducer (operator-input-required.ts), which keeps
512+
// the display single while gate-wire preserves HEAD's queue behavior.
513+
expect(admitted).toEqual(["same-ask", "same-ask"]);
509514
expect(shell.overlayKind).toBe("operator");
510515

511516
acceptOverlaySelection(shell);
517+
expect(resolved).toEqual([{ kind: "option", index: 0 }]);
518+
// The re-emitted gate was queued, not dropped: it takes the host next.
519+
expect(shell.overlayKind).toBe("operator");
520+
acceptOverlaySelection(shell);
521+
expect(resolved).toEqual([
522+
{ kind: "option", index: 0 },
523+
{ kind: "option", index: 0 },
524+
]);
525+
expect(settled).toEqual(["same-ask", "same-ask"]);
526+
expect(shell.overlayList).toBeNull();
527+
},
528+
{
529+
hooks: {
530+
onPrimaryOperatorAdmitted: (event) => admitted.push(event.id),
531+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
532+
},
533+
},
534+
);
535+
});
536+
537+
test("two distinct events sharing an id both resolve on accept", async () => {
538+
const admitted: string[] = [];
539+
const settled: string[] = [];
540+
const resolved: unknown[] = [];
541+
await withGates(
542+
async ({ shell, emitter }) => {
543+
emitOperator(emitter, {
544+
id: "dup",
545+
source: PRIMARY_ASK_OPERATOR_SOURCE,
546+
options: ["Cancel", "Continue"],
547+
resolve: (result: unknown) => resolved.push(result),
548+
});
549+
emitOperator(emitter, {
550+
id: "dup",
551+
source: PRIMARY_ASK_OPERATOR_SOURCE,
552+
options: ["Cancel", "Continue"],
553+
resolve: (result: unknown) => resolved.push(result),
554+
});
512555

513-
expect(settled).toEqual(["same-ask"]);
556+
expect(admitted).toEqual(["dup", "dup"]);
557+
expect(shell.overlayKind).toBe("operator");
558+
559+
acceptOverlaySelection(shell);
514560
expect(resolved).toEqual([{ kind: "option", index: 0 }]);
561+
expect(shell.overlayKind).toBe("operator");
562+
acceptOverlaySelection(shell);
563+
expect(resolved).toEqual([
564+
{ kind: "option", index: 0 },
565+
{ kind: "option", index: 0 },
566+
]);
567+
expect(settled).toEqual(["dup", "dup"]);
515568
expect(shell.overlayList).toBeNull();
516569
},
517570
{
@@ -523,6 +576,87 @@ describe("wireGates", () => {
523576
);
524577
});
525578

579+
test("two distinct events sharing an id both settle on timeout", async () => {
580+
const settled: string[] = [];
581+
const resolved: unknown[] = [];
582+
await withGates(
583+
async ({ shell, emitter }) => {
584+
emitOperator(emitter, {
585+
id: "dup",
586+
source: PRIMARY_ASK_OPERATOR_SOURCE,
587+
options: ["Yes", "No"],
588+
timeoutMs: 5,
589+
resolve: (result: unknown) => resolved.push(result),
590+
});
591+
emitOperator(emitter, {
592+
id: "dup",
593+
source: PRIMARY_ASK_OPERATOR_SOURCE,
594+
options: ["Yes", "No"],
595+
// Arms only once the queued duplicate opens, so the two settles are
596+
// observable separately.
597+
timeoutMs: 80,
598+
resolve: (result: unknown) => resolved.push(result),
599+
});
600+
expect(shell.overlayKind).toBe("operator");
601+
602+
await new Promise((r) => setTimeout(r, 20));
603+
expect(resolved).toEqual([operatorCancelResult()]);
604+
// The queued duplicate opened once the first timed out.
605+
expect(shell.overlayKind).toBe("operator");
606+
await new Promise((r) => setTimeout(r, 120));
607+
expect(resolved).toEqual([
608+
operatorCancelResult(),
609+
operatorCancelResult(),
610+
]);
611+
expect(settled).toEqual(["dup", "dup"]);
612+
expect(shell.overlayList).toBeNull();
613+
},
614+
{
615+
hooks: {
616+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
617+
},
618+
},
619+
);
620+
});
621+
622+
test("a duplicate id still queued at dispose settles via the teardown sweep", async () => {
623+
const settled: string[] = [];
624+
const resolved: unknown[] = [];
625+
await withGates(
626+
async ({ shell, emitter, disposeGates }) => {
627+
emitOperator(emitter, {
628+
id: "dup",
629+
source: PRIMARY_ASK_OPERATOR_SOURCE,
630+
options: ["Yes", "No"],
631+
resolve: (result: unknown) => resolved.push(result),
632+
});
633+
emitOperator(emitter, {
634+
id: "dup",
635+
source: PRIMARY_ASK_OPERATOR_SOURCE,
636+
options: ["Yes", "No"],
637+
resolve: (result: unknown) => resolved.push(result),
638+
});
639+
expect(shell.overlayKind).toBe("operator");
640+
641+
acceptOverlaySelection(shell);
642+
expect(resolved).toHaveLength(1);
643+
expect(shell.overlayKind).toBe("operator");
644+
645+
disposeGates();
646+
expect(resolved).toEqual([
647+
{ kind: "option", index: 0 },
648+
operatorCancelResult(),
649+
]);
650+
expect(settled).toEqual(["dup", "dup"]);
651+
},
652+
{
653+
hooks: {
654+
onPrimaryOperatorSettled: (event) => settled.push(event.id),
655+
},
656+
},
657+
);
658+
});
659+
526660
test("unmarked operator gates retain modal behavior without input-required hooks", async () => {
527661
let admitted = 0;
528662
let settled = 0;
@@ -543,6 +677,123 @@ describe("wireGates", () => {
543677
);
544678
});
545679

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

0 commit comments

Comments
 (0)