@raidiant/notifai 0.2.0 → 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
@@ -510,7 +510,7 @@ describe('nagging guards', () => {
510
510
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
511
511
 
512
512
  expect(h.recorder.submitted).toEqual([])
513
- expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
513
+ expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
514
514
  expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
515
515
  })
516
516
  })
@@ -523,7 +523,7 @@ describe('late answer collection', () => {
523
523
  registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
524
524
 
525
525
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
526
- 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')
527
527
  answers.push(reply({ text: 'Ship it' }))
528
528
  h.io.outLines = []
529
529
 
@@ -585,7 +585,7 @@ describe('OpenCode answer preservation', () => {
585
585
  expect(
586
586
  h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
587
587
  ).toEqual([])
588
- expect(readSessionState('open1', h.env).pending).toMatchObject({
588
+ expect(readSessionState('open1', h.env).pending?.[0]).toMatchObject({
589
589
  question: 'Deploy?',
590
590
  request_id: 'req_hook_1',
591
591
  device_ids: ['dev_iphone', 'dev_mac'],
@@ -609,7 +609,7 @@ describe('reply-wait presence monitoring', () => {
609
609
 
610
610
  expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
611
611
  expect(h.recorder.closed).toEqual([])
612
- 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')
613
613
  expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
614
614
  expect(h.io.errLines.join('\n')).toContain('came back')
615
615
  expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
@@ -643,11 +643,14 @@ describe('Cursor stop output', () => {
643
643
  })
644
644
 
645
645
  /**
646
- * Stale questions piling up on a phone are worse than no questions at all:
647
- * they teach the user to ignore the surface. Every one of them was a delivered
648
- * 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.
649
652
  */
650
- describe('superseding a live question', () => {
653
+ describe('question queueing and retirement debt', () => {
651
654
  /** The lifecycle state of each retirement push the recorder captured. */
652
655
  function retirements(h: Harness): { state: unknown; retires: unknown }[] {
653
656
  return h.recorder.submitted
@@ -658,7 +661,7 @@ describe('superseding a live question', () => {
658
661
  }))
659
662
  }
660
663
 
661
- it('retires the first question instead of orphaning it', async () => {
664
+ it('keeps the first question live and asks the second alongside it', async () => {
662
665
  const h = harness([])
663
666
  writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
664
667
  registerQuestion('sup1', h.env, { question: 'Ship it?' })
@@ -667,15 +670,19 @@ describe('superseding a live question', () => {
667
670
  expect(first).toBeDefined()
668
671
 
669
672
  // The agent carried on and asked something else. The first question is
670
- // 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.
671
675
  registerQuestion('sup1', h.env, { question: 'Deploy it?' })
672
676
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
673
677
 
674
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
675
- // And the replacement was still asked; superseding is not suppressing.
678
+ expect(retirements(h)).toEqual([])
676
679
  expect(
677
680
  h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
678
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)
679
686
  })
680
687
 
681
688
  it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
@@ -685,17 +692,15 @@ describe('superseding a live question', () => {
685
692
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
686
693
  const first = h.recorder.receipts[0]
687
694
 
688
- registerQuestion('sup2', h.env, { question: 'Deploy it?' })
689
- expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
690
-
691
- // 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.
692
697
  h.recorder.failSubmits = true
693
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
698
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup2' }))
694
699
  expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
695
700
 
696
701
  h.recorder.failSubmits = false
697
702
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
698
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
703
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
699
704
  expect(readSessionState('sup2', h.env).retiring).toEqual([])
700
705
  })
701
706
 
@@ -705,20 +710,19 @@ describe('superseding a live question', () => {
705
710
  registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
706
711
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
707
712
 
708
- const live = readSessionState('sup-targets', h.env).pending
713
+ const live = readSessionState('sup-targets', h.env).pending?.[0]
709
714
  expect(live).toMatchObject({
710
715
  request_id: 'req_hook_1',
711
716
  device_ids: ['dev_iphone', 'dev_mac'],
712
717
  })
713
718
 
714
- // The user changes their default routing before the agent supersedes the
715
- // question. Retirement belongs to the Deliveries that actually carried the
716
- // 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.
717
722
  const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
718
723
  mkdirSync(configDir, { recursive: true })
719
724
  writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
720
- registerQuestion('sup-targets', h.env, { question: 'Deploy it?' })
721
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
725
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
722
726
 
723
727
  const retirement = h.recorder.submitted.find(
724
728
  (submission) => submission.draft.event === 'question_retired',
@@ -731,18 +735,24 @@ describe('superseding a live question', () => {
731
735
  })
732
736
 
733
737
  it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
734
- // stop_hook_active short-circuits the escalation path, and a superseding
735
- // 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.
736
741
  const h = harness([])
737
742
  writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
738
743
  registerQuestion('sup3', h.env, { question: 'Ship it?' })
739
744
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
740
745
  const first = h.recorder.receipts[0]
741
746
 
742
- 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
+
743
752
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
744
753
 
745
- 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([])
746
756
  })
747
757
 
748
758
  it('does not lose a queued retirement when the user comes back to the terminal', async () => {
@@ -754,25 +764,123 @@ describe('superseding a live question', () => {
754
764
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
755
765
  const first = h.recorder.receipts[0]
756
766
 
757
- 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.
758
769
  h.recorder.failSubmits = true
759
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
770
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
760
771
  h.recorder.failSubmits = false
761
772
 
762
773
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
763
774
 
764
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
775
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
765
776
  expect(readSessionState('sup4', h.env).retiring).toEqual([])
766
777
  })
767
778
 
768
- it('parks nothing for a question that never reached a device', async () => {
779
+ it('parks nothing for questions that never reached a device', async () => {
769
780
  // No request_id means there is no notification anywhere to retire, and a
770
- // retirement push for one would be pure noise.
781
+ // retirement push for one would be pure noise. Both questions simply
782
+ // queue.
771
783
  const h = harness([])
772
784
  registerQuestion('sup5', h.env, { question: 'Ship it?' })
773
785
  registerQuestion('sup5', h.env, { question: 'Deploy it?' })
774
786
 
775
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)
776
884
  })
777
885
  })
778
886
 
@@ -815,15 +923,15 @@ describe('a question that outlives its session', () => {
815
923
  const h = harness([])
816
924
  const first = await pushUnanswered(h, 'dead2')
817
925
 
818
- // Superseded while offline: the retirement is parked, not sent.
819
- 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.
820
928
  h.recorder.failSubmits = true
821
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'dead2' }))
929
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
822
930
  h.recorder.failSubmits = false
823
931
 
824
932
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
825
933
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
826
- expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
934
+ expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
827
935
  })
828
936
 
829
937
  it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
@@ -928,7 +1036,7 @@ describe('ask registration', () => {
928
1036
  session: 'a2',
929
1037
  }),
930
1038
  ).toBe(EXIT.ok)
931
- expect(readSessionState('a2', h.env).pending?.questions).toEqual([
1039
+ expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
932
1040
  {
933
1041
  id: 'ship-it',
934
1042
  text: 'Ship it?',
@@ -955,7 +1063,7 @@ describe('ask registration', () => {
955
1063
  }),
956
1064
  }),
957
1065
  ).toBe(EXIT.ok)
958
- const pending = readSessionState('form1', h.env).pending
1066
+ const pending = readSessionState('form1', h.env).pending?.[0]
959
1067
  expect(pending?.questions).toHaveLength(2)
960
1068
  expect(pending?.detail).toContain('long story')
961
1069
 
@@ -1040,7 +1148,7 @@ describe('ask registration', () => {
1040
1148
  'claude-code',
1041
1149
  )
1042
1150
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
1043
- expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
1151
+ expect(readSessionState('real1', h.env).pending?.[0]?.question).toBe('Ship it?')
1044
1152
  expect(readSessionState('my-label', h.env).pending).toBeUndefined()
1045
1153
  })
1046
1154
 
@@ -1048,24 +1156,42 @@ describe('ask registration', () => {
1048
1156
  const h = harness()
1049
1157
  h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
1050
1158
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
1051
- expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
1159
+ expect(readSessionState('solo-session', h.env).pending?.[0]?.question).toBe('Ship it?')
1052
1160
  })
1053
1161
 
1054
- 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.
1055
1166
  const h = harness()
1056
1167
  writeSessionState('incomplete', h.env, {
1057
- pending: {
1058
- question: 'Original?',
1059
- request_id: 'req_original',
1060
- collapse_key: 'collapse-original',
1061
- },
1168
+ pending: [
1169
+ {
1170
+ question: 'Original?',
1171
+ request_id: 'req_original',
1172
+ collapse_key: 'collapse-original',
1173
+ },
1174
+ ],
1062
1175
  })
1063
1176
 
1064
- expect(askCommand(h.deps, 'Replacement?', { session: 'incomplete' })).toBe(EXIT.failed)
1065
- expect(h.io.errLines.join('\n')).toContain(
1066
- 'refusing to retire it without request, collapse, and device identifiers',
1067
- )
1068
- 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)
1069
1195
  })
1070
1196
  })
1071
1197
 
@@ -1097,7 +1223,7 @@ describe('user-prompt-submit hook', () => {
1097
1223
  expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
1098
1224
  })
1099
1225
 
1100
- 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 () => {
1101
1227
  const h = harness([], 900)
1102
1228
  await hookRunCommand(
1103
1229
  h.deps,
@@ -1108,7 +1234,7 @@ describe('user-prompt-submit hook', () => {
1108
1234
  expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
1109
1235
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
1110
1236
 
1111
- const live = readSessionState('transition', h.env).pending
1237
+ const live = readSessionState('transition', h.env).pending?.[0]
1112
1238
  expect(live).toMatchObject({
1113
1239
  question: 'Ship it?',
1114
1240
  request_id: 'req_hook_1',
@@ -1116,19 +1242,14 @@ describe('user-prompt-submit hook', () => {
1116
1242
  })
1117
1243
  expect(live?.collapse_key).toMatch(/^notifai-hook-/)
1118
1244
 
1119
- // A second plain `ask` resolves through the project pointer and supersedes
1120
- // the live question. The next prompt then resets presence before another
1121
- // 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.
1122
1249
  expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
1123
- expect(readSessionState('transition', h.env).retiring).toEqual([
1124
- {
1125
- request_id: live?.request_id,
1126
- collapse_key: live?.collapse_key,
1127
- device_ids: ['dev_iphone', 'dev_mac'],
1128
- question: 'Ship it?',
1129
- state: 'superseded',
1130
- },
1131
- ])
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?'])
1132
1253
 
1133
1254
  await hookRunCommand(
1134
1255
  h.deps,
@@ -1137,19 +1258,24 @@ describe('user-prompt-submit hook', () => {
1137
1258
  'claude-code',
1138
1259
  )
1139
1260
 
1261
+ // The delivered question retires truthfully; the never-delivered second
1262
+ // one has nothing on any device and leaves no trace.
1140
1263
  const retirement = h.recorder.submitted.find(
1141
1264
  (submission) => submission.draft.event === 'question_retired',
1142
1265
  )?.draft
1143
1266
  expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
1144
1267
  expect(retirement?.lifecycle).toEqual({
1145
1268
  tier: 'done',
1146
- state: 'superseded',
1269
+ state: 'answered_elsewhere',
1147
1270
  retires_request_id: live?.request_id,
1148
1271
  })
1149
1272
  expect(retirement?.targets).toEqual({
1150
1273
  mode: 'selected',
1151
1274
  device_ids: ['dev_iphone', 'dev_mac'],
1152
1275
  })
1276
+ expect(
1277
+ h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
1278
+ ).toHaveLength(1)
1153
1279
  })
1154
1280
 
1155
1281
  it('retires a question a real timed-out Stop left live on the devices', async () => {
@@ -1161,7 +1287,7 @@ describe('user-prompt-submit hook', () => {
1161
1287
  registerQuestion('s11', h.env, { question: 'Which environment?' })
1162
1288
 
1163
1289
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
1164
- const live = readSessionState('s11', h.env).pending
1290
+ const live = readSessionState('s11', h.env).pending?.[0]
1165
1291
  expect(live?.request_id).toBe('req_hook_1')
1166
1292
  expect(live?.collapse_key).toBeDefined()
1167
1293
 
@@ -1272,16 +1398,18 @@ describe('session-end hook', () => {
1272
1398
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
1273
1399
  const h = harness()
1274
1400
  writeSessionState('s-incomplete', h.env, {
1275
- pending: {
1276
- question: 'Still live?',
1277
- request_id: 'req_incomplete',
1278
- collapse_key: 'collapse-incomplete',
1279
- },
1401
+ pending: [
1402
+ {
1403
+ question: 'Still live?',
1404
+ request_id: 'req_incomplete',
1405
+ collapse_key: 'collapse-incomplete',
1406
+ },
1407
+ ],
1280
1408
  })
1281
1409
 
1282
1410
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
1283
1411
 
1284
- 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')
1285
1413
  expect(h.io.errLines.join('\n')).toContain(
1286
1414
  'refusing to retire it without request, collapse, and device identifiers',
1287
1415
  )
@@ -1431,7 +1559,7 @@ describe('pruning abandoned session state', () => {
1431
1559
  it('bounds what one runaway question can write to disk', () => {
1432
1560
  const h = harness()
1433
1561
  registerQuestion('big', h.env, { question: 'x'.repeat(50_000) })
1434
- expect(readSessionState('big', h.env).pending?.question.length).toBeLessThanOrEqual(2000)
1562
+ expect(readSessionState('big', h.env).pending?.[0]?.question.length).toBeLessThanOrEqual(2000)
1435
1563
  })
1436
1564
  })
1437
1565
 
@@ -1604,7 +1732,7 @@ describe('two hooks racing one question', () => {
1604
1732
  })
1605
1733
 
1606
1734
  describe('question registration racing a Stop submission', () => {
1607
- 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 () => {
1608
1736
  const h = harness([], 900)
1609
1737
  writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
1610
1738
  registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
@@ -1615,11 +1743,16 @@ describe('question registration racing a Stop submission', () => {
1615
1743
 
1616
1744
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
1617
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.
1618
1748
  const state = readSessionState('submit-race', h.env)
1619
- expect(state.pending?.question).toBe('New question?')
1620
- expect(state.retiring).toContainEqual(
1621
- expect.objectContaining({ request_id: 'req_hook_1', state: 'superseded' }),
1622
- )
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([])
1623
1756
  })
1624
1757
 
1625
1758
  it('keeps the newer question when the older in-flight question receives an answer', async () => {
@@ -1634,7 +1767,7 @@ describe('question registration racing a Stop submission', () => {
1634
1767
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
1635
1768
 
1636
1769
  const state = readSessionState('answer-race', h.env)
1637
- expect(state.pending?.question).toBe('New question?')
1770
+ expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
1638
1771
  expect(
1639
1772
  h.recorder.submitted.find(
1640
1773
  (entry) =>