@raidiant/notifai 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hooks.test.ts CHANGED
@@ -127,7 +127,8 @@ function reply(overrides: Partial<ReplyView> = {}): ReplyView {
127
127
  device_id: 'dev_test',
128
128
  device_name: 'Furankuphone',
129
129
  text: 'Allow',
130
- choice_id: 'allow',
130
+ answers: [{ question_id: 'q1', choice_ids: ['allow'], text: null }],
131
+ source: null,
131
132
  created_at: new Date().toISOString(),
132
133
  ...overrides,
133
134
  }
@@ -509,7 +510,7 @@ describe('nagging guards', () => {
509
510
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
510
511
 
511
512
  expect(h.recorder.submitted).toEqual([])
512
- expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
513
+ expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
513
514
  expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
514
515
  })
515
516
  })
@@ -522,7 +523,7 @@ describe('late answer collection', () => {
522
523
  registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
523
524
 
524
525
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
525
- expect(readSessionState('late-stop', h.env).pending?.request_id).toBe('req_hook_1')
526
+ expect(readSessionState('late-stop', h.env).pending?.[0]?.request_id).toBe('req_hook_1')
526
527
  answers.push(reply({ text: 'Ship it' }))
527
528
  h.io.outLines = []
528
529
 
@@ -584,7 +585,7 @@ describe('OpenCode answer preservation', () => {
584
585
  expect(
585
586
  h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
586
587
  ).toEqual([])
587
- expect(readSessionState('open1', h.env).pending).toMatchObject({
588
+ expect(readSessionState('open1', h.env).pending?.[0]).toMatchObject({
588
589
  question: 'Deploy?',
589
590
  request_id: 'req_hook_1',
590
591
  device_ids: ['dev_iphone', 'dev_mac'],
@@ -608,7 +609,7 @@ describe('reply-wait presence monitoring', () => {
608
609
 
609
610
  expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
610
611
  expect(h.recorder.closed).toEqual([])
611
- expect(readSessionState('returned', h.env).pending?.request_id).toBe('req_hook_1')
612
+ expect(readSessionState('returned', h.env).pending?.[0]?.request_id).toBe('req_hook_1')
612
613
  expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
613
614
  expect(h.io.errLines.join('\n')).toContain('came back')
614
615
  expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
@@ -642,11 +643,14 @@ describe('Cursor stop output', () => {
642
643
  })
643
644
 
644
645
  /**
645
- * Stale questions piling up on a phone are worse than no questions at all:
646
- * they teach the user to ignore the surface. Every one of them was a delivered
647
- * question whose ids had been thrown away, so nothing could reach it again.
646
+ * Questions never supersede questions — a second ask joins the first, and
647
+ * both reach the user (superseding is reply semantics). What remains from the
648
+ * old supersede model is the retirement debt machinery: a delivered question
649
+ * that dies (the user came back to the terminal) must be truthfully retired
650
+ * even when the network fails at the moment we learn it, because stale
651
+ * questions piling up on a phone teach the user to ignore the surface.
648
652
  */
649
- describe('superseding a live question', () => {
653
+ describe('question queueing and retirement debt', () => {
650
654
  /** The lifecycle state of each retirement push the recorder captured. */
651
655
  function retirements(h: Harness): { state: unknown; retires: unknown }[] {
652
656
  return h.recorder.submitted
@@ -657,7 +661,7 @@ describe('superseding a live question', () => {
657
661
  }))
658
662
  }
659
663
 
660
- it('retires the first question instead of orphaning it', async () => {
664
+ it('keeps the first question live and asks the second alongside it', async () => {
661
665
  const h = harness([])
662
666
  writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
663
667
  registerQuestion('sup1', h.env, { question: 'Ship it?' })
@@ -666,15 +670,19 @@ describe('superseding a live question', () => {
666
670
  expect(first).toBeDefined()
667
671
 
668
672
  // The agent carried on and asked something else. The first question is
669
- // dead the moment this returns, whether or not anyone answers the second.
673
+ // still the user's to answer — a second question never ends the first;
674
+ // superseding is reply semantics, not question semantics.
670
675
  registerQuestion('sup1', h.env, { question: 'Deploy it?' })
671
676
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
672
677
 
673
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
674
- // And the replacement was still asked; superseding is not suppressing.
678
+ expect(retirements(h)).toEqual([])
675
679
  expect(
676
680
  h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
677
681
  ).toHaveLength(1)
682
+ const live = readSessionState('sup1', h.env).pending
683
+ expect(live).toHaveLength(2)
684
+ expect(live?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
685
+ expect(live?.every((entry) => entry.request_id !== undefined)).toBe(true)
678
686
  })
679
687
 
680
688
  it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
@@ -684,17 +692,15 @@ describe('superseding a live question', () => {
684
692
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
685
693
  const first = h.recorder.receipts[0]
686
694
 
687
- registerQuestion('sup2', h.env, { question: 'Deploy it?' })
688
- expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
689
-
690
- // Offline: the drain runs, fails, and must not forget what it was for.
695
+ // Offline when the user returns: the wipe parks the retirement, the
696
+ // drain fails, and it must not forget what it was for.
691
697
  h.recorder.failSubmits = true
692
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
698
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup2' }))
693
699
  expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
694
700
 
695
701
  h.recorder.failSubmits = false
696
702
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
697
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
703
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
698
704
  expect(readSessionState('sup2', h.env).retiring).toEqual([])
699
705
  })
700
706
 
@@ -704,20 +710,19 @@ describe('superseding a live question', () => {
704
710
  registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
705
711
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
706
712
 
707
- const live = readSessionState('sup-targets', h.env).pending
713
+ const live = readSessionState('sup-targets', h.env).pending?.[0]
708
714
  expect(live).toMatchObject({
709
715
  request_id: 'req_hook_1',
710
716
  device_ids: ['dev_iphone', 'dev_mac'],
711
717
  })
712
718
 
713
- // The user changes their default routing before the agent supersedes the
714
- // question. Retirement belongs to the Deliveries that actually carried the
715
- // first question, not to whichever Device Installation is selected now.
719
+ // The user changes their default routing before the question dies.
720
+ // Retirement belongs to the Deliveries that actually carried the
721
+ // question, not to whichever Device Installation is selected now.
716
722
  const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
717
723
  mkdirSync(configDir, { recursive: true })
718
724
  writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
719
- registerQuestion('sup-targets', h.env, { question: 'Deploy it?' })
720
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
725
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
721
726
 
722
727
  const retirement = h.recorder.submitted.find(
723
728
  (submission) => submission.draft.event === 'question_retired',
@@ -730,18 +735,24 @@ describe('superseding a live question', () => {
730
735
  })
731
736
 
732
737
  it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
733
- // stop_hook_active short-circuits the escalation path, and a superseding
734
- // turn is very often exactly this turn.
738
+ // stop_hook_active short-circuits the escalation path, but the drain runs
739
+ // before every guard — retirement debt has nothing to do with whether
740
+ // this turn may ask.
735
741
  const h = harness([])
736
742
  writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
737
743
  registerQuestion('sup3', h.env, { question: 'Ship it?' })
738
744
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
739
745
  const first = h.recorder.receipts[0]
740
746
 
741
- registerQuestion('sup3', h.env, { question: 'Deploy it?' })
747
+ h.recorder.failSubmits = true
748
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
749
+ expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
750
+ h.recorder.failSubmits = false
751
+
742
752
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
743
753
 
744
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
754
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
755
+ expect(readSessionState('sup3', h.env).retiring).toEqual([])
745
756
  })
746
757
 
747
758
  it('does not lose a queued retirement when the user comes back to the terminal', async () => {
@@ -753,25 +764,123 @@ describe('superseding a live question', () => {
753
764
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
754
765
  const first = h.recorder.receipts[0]
755
766
 
756
- registerQuestion('sup4', h.env, { question: 'Deploy it?' })
767
+ // The first return is offline: the wipe parks, the drain fails, and the
768
+ // next prompt must still find the debt.
757
769
  h.recorder.failSubmits = true
758
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
770
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
759
771
  h.recorder.failSubmits = false
760
772
 
761
773
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
762
774
 
763
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
775
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
764
776
  expect(readSessionState('sup4', h.env).retiring).toEqual([])
765
777
  })
766
778
 
767
- it('parks nothing for a question that never reached a device', async () => {
779
+ it('parks nothing for questions that never reached a device', async () => {
768
780
  // No request_id means there is no notification anywhere to retire, and a
769
- // retirement push for one would be pure noise.
781
+ // retirement push for one would be pure noise. Both questions simply
782
+ // queue.
770
783
  const h = harness([])
771
784
  registerQuestion('sup5', h.env, { question: 'Ship it?' })
772
785
  registerQuestion('sup5', h.env, { question: 'Deploy it?' })
773
786
 
774
787
  expect(readSessionState('sup5', h.env).retiring ?? []).toEqual([])
788
+ expect(readSessionState('sup5', h.env).pending?.map((entry) => entry.question)).toEqual([
789
+ 'Ship it?',
790
+ 'Deploy it?',
791
+ ])
792
+ })
793
+ })
794
+
795
+ describe('several questions in flight', () => {
796
+ /** Route each live request its own replies; the shared list cannot say who answered what. */
797
+ function repliesByRequest(h: Harness, byRequest: Map<string, ReplyView[]>): void {
798
+ const factory = h.deps.clientFactory
799
+ h.deps.clientFactory = () => {
800
+ const client = factory!()
801
+ return {
802
+ ...client,
803
+ replies: async (requestId: string) => ({
804
+ request_id: requestId,
805
+ reply_expires_at: null,
806
+ replies: byRequest.get(requestId) ?? [],
807
+ }),
808
+ } as ApiClient
809
+ }
810
+ }
811
+
812
+ it('escalates every registered question in one pass, each as its own notification', async () => {
813
+ const h = harness([], 900)
814
+ writeSessionState('multi1', h.env, { last_prompt_at: AWAY })
815
+ registerQuestion('multi1', h.env, { question: 'Ship it?' }, NOW)
816
+ registerQuestion('multi1', h.env, { question: 'Deploy where?' }, NOW + 1)
817
+
818
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
819
+
820
+ const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
821
+ expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
822
+ // Each is its own notification with its own collapse key — one ask never
823
+ // stands in for, or replaces, another.
824
+ expect(new Set(questions.map((s) => s.draft.delivery.collapse_key)).size).toBe(2)
825
+ const live = readSessionState('multi1', h.env).pending
826
+ expect(live?.map((entry) => entry.request_id)).toEqual(['req_hook_1', 'req_hook_2'])
827
+ })
828
+
829
+ it('resumes with every answer that arrived, each tied to its question', async () => {
830
+ const h = harness([], 900)
831
+ repliesByRequest(
832
+ h,
833
+ new Map([
834
+ ['req_hook_1', [reply({ text: 'Ship it' })]],
835
+ ['req_hook_2', [reply({ text: 'Staging' })]],
836
+ ]),
837
+ )
838
+ writeSessionState('multi2', h.env, { last_prompt_at: AWAY })
839
+ registerQuestion('multi2', h.env, { question: 'Ship it?' }, NOW)
840
+ registerQuestion('multi2', h.env, { question: 'Deploy where?' }, NOW + 1)
841
+
842
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi2' }))
843
+
844
+ // Both reply windows close immediately (first answer claims each
845
+ // question); the retirement drain closes again, which is idempotent.
846
+ expect(new Set(h.recorder.closed)).toEqual(new Set(['req_hook_1', 'req_hook_2']))
847
+ expect(readSessionState('multi2', h.env).pending).toBeUndefined()
848
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
849
+ expect(output.decision).toBe('block')
850
+ expect(output.reason).toContain('answered 2 questions')
851
+ expect(output.reason).toContain('"Ship it?" → "Ship it"')
852
+ expect(output.reason).toContain('"Deploy where?" → "Staging"')
853
+ })
854
+
855
+ it('resumes with a partial answer and keeps the rest registered', async () => {
856
+ const h = harness([], 900)
857
+ const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
858
+ repliesByRequest(h, byRequest)
859
+ writeSessionState('multi3', h.env, { last_prompt_at: AWAY })
860
+ registerQuestion('multi3', h.env, { question: 'Ship it?' }, NOW)
861
+ registerQuestion('multi3', h.env, { question: 'Deploy where?' }, NOW + 1)
862
+
863
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3' }))
864
+
865
+ const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
866
+ expect(output.reason).toContain('Ship it')
867
+ expect(output.reason).toContain('1 more registered question is still waiting')
868
+ const live = readSessionState('multi3', h.env).pending
869
+ expect(live?.map((entry) => entry.question)).toEqual(['Deploy where?'])
870
+ expect(live?.[0]?.request_id).toBe('req_hook_2')
871
+
872
+ // The remaining question's answer arrives before the next turn ends; the
873
+ // late-answer path hands it over without asking anything twice.
874
+ byRequest.set('req_hook_2', [reply({ text: 'Staging' })])
875
+ h.io.outLines = []
876
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3', stop_hook_active: true }))
877
+
878
+ const followup = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
879
+ expect(followup.reason).toContain('Staging')
880
+ expect(readSessionState('multi3', h.env).pending).toBeUndefined()
881
+ expect(
882
+ h.recorder.submitted.filter((s) => s.draft.event === 'agent_question'),
883
+ ).toHaveLength(2)
775
884
  })
776
885
  })
777
886
 
@@ -814,15 +923,15 @@ describe('a question that outlives its session', () => {
814
923
  const h = harness([])
815
924
  const first = await pushUnanswered(h, 'dead2')
816
925
 
817
- // Superseded while offline: the retirement is parked, not sent.
818
- registerQuestion('dead2', h.env, { question: 'Deploy it?' })
926
+ // Died while offline: the user came back, the wipe parked the
927
+ // retirement, and the drain could not send it.
819
928
  h.recorder.failSubmits = true
820
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'dead2' }))
929
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
821
930
  h.recorder.failSubmits = false
822
931
 
823
932
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
824
933
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
825
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
934
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
826
935
  })
827
936
 
828
937
  it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
@@ -915,11 +1024,11 @@ describe('ask registration', () => {
915
1024
  const h = harness()
916
1025
  // Inside a hook, a rejection is only a stderr note the agent never reads —
917
1026
  // so it would look registered and then silently never ask.
918
- expect(askCommand(h.deps, 'Ship it?', { choice: 'Yes|No', session: 'a1' })).toBe(EXIT.usage)
1027
+ expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
919
1028
  expect(readSessionState('a1', h.env).pending).toBeUndefined()
920
1029
  })
921
1030
 
922
- it('stores validated labels verbatim, commas and all', () => {
1031
+ it('stores the validated question set, comma-bearing labels verbatim', () => {
923
1032
  const h = harness()
924
1033
  expect(
925
1034
  askCommand(h.deps, 'Ship it?', {
@@ -927,7 +1036,104 @@ describe('ask registration', () => {
927
1036
  session: 'a2',
928
1037
  }),
929
1038
  ).toBe(EXIT.ok)
930
- expect(readSessionState('a2', h.env).pending?.choices).toEqual(['Yes, ship it', 'No, hold'])
1039
+ expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
1040
+ {
1041
+ id: 'ship-it',
1042
+ text: 'Ship it?',
1043
+ choices: [
1044
+ { id: 'yes-ship-it', label: 'Yes, ship it' },
1045
+ { id: 'no-hold', label: 'No, hold' },
1046
+ ],
1047
+ },
1048
+ ])
1049
+ })
1050
+
1051
+ it('registers a multi-question form and pushes it as one set', async () => {
1052
+ const h = harness([reply({ text: 'Yes' })], 900)
1053
+ writeSessionState('form1', h.env, { last_prompt_at: AWAY })
1054
+ expect(
1055
+ askCommand(h.deps, undefined, {
1056
+ session: 'form1',
1057
+ form: JSON.stringify({
1058
+ questions: [
1059
+ { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
1060
+ { text: 'Anything to watch?' },
1061
+ ],
1062
+ detail: '## Context\nThe long story.',
1063
+ }),
1064
+ }),
1065
+ ).toBe(EXIT.ok)
1066
+ const pending = readSessionState('form1', h.env).pending?.[0]
1067
+ expect(pending?.questions).toHaveLength(2)
1068
+ expect(pending?.detail).toContain('long story')
1069
+
1070
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
1071
+ const draft = h.recorder.submitted[0]?.draft
1072
+ // The banner leads with the first question and admits the rest exists;
1073
+ // the full set rides the payload for the answering card.
1074
+ expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
1075
+ expect(draft?.presentation.detail).toContain('long story')
1076
+ expect(draft?.reply?.questions).toEqual([
1077
+ {
1078
+ id: 'deploy-where',
1079
+ text: 'Deploy where?',
1080
+ choices: [
1081
+ { id: 'staging', label: 'Staging' },
1082
+ { id: 'production', label: 'Production' },
1083
+ ],
1084
+ multi: true,
1085
+ },
1086
+ { id: 'anything-to-watch', text: 'Anything to watch?' },
1087
+ ])
1088
+ })
1089
+
1090
+ it('acts on the latest reply when answers conflict, because it is a correction', async () => {
1091
+ const h = harness(
1092
+ [
1093
+ reply({ seq: 1, text: 'Yes', answers: [{ question_id: 'ship-it', choice_ids: ['yes'], text: null }] }),
1094
+ reply({
1095
+ seq: 2,
1096
+ reply_id: 'rpl_2',
1097
+ text: 'No',
1098
+ answers: [{ question_id: 'ship-it', choice_ids: ['no'], text: null }],
1099
+ }),
1100
+ ],
1101
+ 900,
1102
+ )
1103
+ writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
1104
+ expect(
1105
+ askCommand(h.deps, 'Ship it?', { session: 'latest1', choice: ['Yes', 'No'] }),
1106
+ ).toBe(EXIT.ok)
1107
+
1108
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
1109
+
1110
+ const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
1111
+ expect(decision.decision).toBe('block')
1112
+ expect(decision.reason).toContain('"No"')
1113
+ expect(decision.reason).not.toContain('"Yes"')
1114
+ })
1115
+
1116
+ it('hands a free-text answer written in parts to the agent in order', async () => {
1117
+ const h = harness(
1118
+ [
1119
+ reply({ seq: 1, text: 'Use blue', answers: [{ question_id: 'which-color', choice_ids: [], text: 'Use blue' }] }),
1120
+ reply({
1121
+ seq: 2,
1122
+ reply_id: 'rpl_2',
1123
+ text: 'actually teal',
1124
+ answers: [{ question_id: 'which-color', choice_ids: [], text: 'actually teal' }],
1125
+ }),
1126
+ ],
1127
+ 900,
1128
+ )
1129
+ writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
1130
+ expect(askCommand(h.deps, 'Which color?', { session: 'parts1' })).toBe(EXIT.ok)
1131
+
1132
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
1133
+
1134
+ const decision = JSON.parse(h.io.outLines.at(-1)!) as { reason: string }
1135
+ expect(decision.reason).toContain('in the order written')
1136
+ expect(decision.reason).toContain('"Use blue", then "actually teal"')
931
1137
  })
932
1138
 
933
1139
  it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
@@ -942,7 +1148,7 @@ describe('ask registration', () => {
942
1148
  'claude-code',
943
1149
  )
944
1150
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
945
- expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
1151
+ expect(readSessionState('real1', h.env).pending?.[0]?.question).toBe('Ship it?')
946
1152
  expect(readSessionState('my-label', h.env).pending).toBeUndefined()
947
1153
  })
948
1154
 
@@ -950,24 +1156,42 @@ describe('ask registration', () => {
950
1156
  const h = harness()
951
1157
  h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
952
1158
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
953
- expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
1159
+ expect(readSessionState('solo-session', h.env).pending?.[0]?.question).toBe('Ship it?')
954
1160
  })
955
1161
 
956
- it('fails explicitly instead of rerouting incomplete live state', () => {
1162
+ it('queues alongside incomplete live state without touching it', () => {
1163
+ // Registering never retires, so even a live entry too incomplete to
1164
+ // retire is no obstacle — it stays exactly as found, and the new
1165
+ // question joins the queue behind it.
957
1166
  const h = harness()
958
1167
  writeSessionState('incomplete', h.env, {
959
- pending: {
960
- question: 'Original?',
961
- request_id: 'req_original',
962
- collapse_key: 'collapse-original',
963
- },
1168
+ pending: [
1169
+ {
1170
+ question: 'Original?',
1171
+ request_id: 'req_original',
1172
+ collapse_key: 'collapse-original',
1173
+ },
1174
+ ],
964
1175
  })
965
1176
 
966
- expect(askCommand(h.deps, 'Replacement?', { session: 'incomplete' })).toBe(EXIT.failed)
967
- expect(h.io.errLines.join('\n')).toContain(
968
- 'refusing to retire it without request, collapse, and device identifiers',
969
- )
970
- expect(readSessionState('incomplete', h.env).pending?.question).toBe('Original?')
1177
+ expect(askCommand(h.deps, 'Another?', { session: 'incomplete' })).toBe(EXIT.ok)
1178
+ const pending = readSessionState('incomplete', h.env).pending
1179
+ expect(pending?.map((entry) => entry.question)).toEqual(['Original?', 'Another?'])
1180
+ expect(pending?.[0]).toMatchObject({
1181
+ request_id: 'req_original',
1182
+ collapse_key: 'collapse-original',
1183
+ })
1184
+ })
1185
+
1186
+ it('refuses a fifth question and names the form alternative', () => {
1187
+ const h = harness()
1188
+ for (const question of ['One?', 'Two?', 'Three?', 'Four?']) {
1189
+ expect(askCommand(h.deps, question, { session: 'crowded' })).toBe(EXIT.ok)
1190
+ }
1191
+
1192
+ expect(askCommand(h.deps, 'Five?', { session: 'crowded' })).toBe(EXIT.failed)
1193
+ expect(h.io.errLines.join('\n')).toContain('ask --form')
1194
+ expect(readSessionState('crowded', h.env).pending).toHaveLength(4)
971
1195
  })
972
1196
  })
973
1197
 
@@ -999,7 +1223,7 @@ describe('user-prompt-submit hook', () => {
999
1223
  expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
1000
1224
  })
1001
1225
 
1002
- it('preserves a delivered question through ask replacement and prompt transition', async () => {
1226
+ it('preserves a delivered question through a second ask and the prompt transition', async () => {
1003
1227
  const h = harness([], 900)
1004
1228
  await hookRunCommand(
1005
1229
  h.deps,
@@ -1010,7 +1234,7 @@ describe('user-prompt-submit hook', () => {
1010
1234
  expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
1011
1235
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
1012
1236
 
1013
- const live = readSessionState('transition', h.env).pending
1237
+ const live = readSessionState('transition', h.env).pending?.[0]
1014
1238
  expect(live).toMatchObject({
1015
1239
  question: 'Ship it?',
1016
1240
  request_id: 'req_hook_1',
@@ -1018,19 +1242,14 @@ describe('user-prompt-submit hook', () => {
1018
1242
  })
1019
1243
  expect(live?.collapse_key).toMatch(/^notifai-hook-/)
1020
1244
 
1021
- // A second plain `ask` resolves through the project pointer and supersedes
1022
- // the live question. The next prompt then resets presence before another
1023
- // Stop can run — the transition that used to erase the retirement debt.
1245
+ // A second plain `ask` resolves through the project pointer and joins the
1246
+ // queue behind the live question — it must not touch it. The next prompt
1247
+ // then resets presence before another Stop can run — the transition that
1248
+ // used to erase the retirement debt.
1024
1249
  expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
1025
- expect(readSessionState('transition', h.env).retiring).toEqual([
1026
- {
1027
- request_id: live?.request_id,
1028
- collapse_key: live?.collapse_key,
1029
- device_ids: ['dev_iphone', 'dev_mac'],
1030
- question: 'Ship it?',
1031
- state: 'superseded',
1032
- },
1033
- ])
1250
+ const queued = readSessionState('transition', h.env)
1251
+ expect(queued.retiring ?? []).toEqual([])
1252
+ expect(queued.pending?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
1034
1253
 
1035
1254
  await hookRunCommand(
1036
1255
  h.deps,
@@ -1039,19 +1258,24 @@ describe('user-prompt-submit hook', () => {
1039
1258
  'claude-code',
1040
1259
  )
1041
1260
 
1261
+ // The delivered question retires truthfully; the never-delivered second
1262
+ // one has nothing on any device and leaves no trace.
1042
1263
  const retirement = h.recorder.submitted.find(
1043
1264
  (submission) => submission.draft.event === 'question_retired',
1044
1265
  )?.draft
1045
1266
  expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
1046
1267
  expect(retirement?.lifecycle).toEqual({
1047
1268
  tier: 'done',
1048
- state: 'superseded',
1269
+ state: 'answered_elsewhere',
1049
1270
  retires_request_id: live?.request_id,
1050
1271
  })
1051
1272
  expect(retirement?.targets).toEqual({
1052
1273
  mode: 'selected',
1053
1274
  device_ids: ['dev_iphone', 'dev_mac'],
1054
1275
  })
1276
+ expect(
1277
+ h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
1278
+ ).toHaveLength(1)
1055
1279
  })
1056
1280
 
1057
1281
  it('retires a question a real timed-out Stop left live on the devices', async () => {
@@ -1063,7 +1287,7 @@ describe('user-prompt-submit hook', () => {
1063
1287
  registerQuestion('s11', h.env, { question: 'Which environment?' })
1064
1288
 
1065
1289
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
1066
- const live = readSessionState('s11', h.env).pending
1290
+ const live = readSessionState('s11', h.env).pending?.[0]
1067
1291
  expect(live?.request_id).toBe('req_hook_1')
1068
1292
  expect(live?.collapse_key).toBeDefined()
1069
1293
 
@@ -1174,16 +1398,18 @@ describe('session-end hook', () => {
1174
1398
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
1175
1399
  const h = harness()
1176
1400
  writeSessionState('s-incomplete', h.env, {
1177
- pending: {
1178
- question: 'Still live?',
1179
- request_id: 'req_incomplete',
1180
- collapse_key: 'collapse-incomplete',
1181
- },
1401
+ pending: [
1402
+ {
1403
+ question: 'Still live?',
1404
+ request_id: 'req_incomplete',
1405
+ collapse_key: 'collapse-incomplete',
1406
+ },
1407
+ ],
1182
1408
  })
1183
1409
 
1184
1410
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
1185
1411
 
1186
- expect(readSessionState('s-incomplete', h.env).pending?.request_id).toBe('req_incomplete')
1412
+ expect(readSessionState('s-incomplete', h.env).pending?.[0]?.request_id).toBe('req_incomplete')
1187
1413
  expect(h.io.errLines.join('\n')).toContain(
1188
1414
  'refusing to retire it without request, collapse, and device identifiers',
1189
1415
  )
@@ -1333,7 +1559,7 @@ describe('pruning abandoned session state', () => {
1333
1559
  it('bounds what one runaway question can write to disk', () => {
1334
1560
  const h = harness()
1335
1561
  registerQuestion('big', h.env, { question: 'x'.repeat(50_000) })
1336
- expect(readSessionState('big', h.env).pending?.question.length).toBeLessThanOrEqual(2000)
1562
+ expect(readSessionState('big', h.env).pending?.[0]?.question.length).toBeLessThanOrEqual(2000)
1337
1563
  })
1338
1564
  })
1339
1565
 
@@ -1506,7 +1732,7 @@ describe('two hooks racing one question', () => {
1506
1732
  })
1507
1733
 
1508
1734
  describe('question registration racing a Stop submission', () => {
1509
- it('preserves a newer question when the older submit publishes its request id', async () => {
1735
+ it('keeps both questions when a new ask races the older submit', async () => {
1510
1736
  const h = harness([], 900)
1511
1737
  writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
1512
1738
  registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
@@ -1517,11 +1743,16 @@ describe('question registration racing a Stop submission', () => {
1517
1743
 
1518
1744
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
1519
1745
 
1746
+ // The racing ask appended; the in-flight submit still finds its own entry
1747
+ // and records the delivery ids on it. Nobody retires anybody.
1520
1748
  const state = readSessionState('submit-race', h.env)
1521
- expect(state.pending?.question).toBe('New question?')
1522
- expect(state.retiring).toContainEqual(
1523
- expect.objectContaining({ request_id: 'req_hook_1', state: 'superseded' }),
1524
- )
1749
+ expect(state.pending?.map((entry) => entry.question)).toEqual([
1750
+ 'Old question?',
1751
+ 'New question?',
1752
+ ])
1753
+ expect(state.pending?.[0]?.request_id).toBe('req_hook_1')
1754
+ expect(state.pending?.[1]?.request_id).toBeUndefined()
1755
+ expect(state.retiring ?? []).toEqual([])
1525
1756
  })
1526
1757
 
1527
1758
  it('keeps the newer question when the older in-flight question receives an answer', async () => {
@@ -1536,7 +1767,7 @@ describe('question registration racing a Stop submission', () => {
1536
1767
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
1537
1768
 
1538
1769
  const state = readSessionState('answer-race', h.env)
1539
- expect(state.pending?.question).toBe('New question?')
1770
+ expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
1540
1771
  expect(
1541
1772
  h.recorder.submitted.find(
1542
1773
  (entry) =>