@raidiant/notifai 8.0.0 → 9.1.0

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.
Files changed (168) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. package/src/url-policy.ts +399 -0
package/src/hooks.test.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  MAX_CONTINUATION_COUNT,
51
51
  MAX_HELD_DELIVERIES,
52
52
  orphanRetirements,
53
+ pendingAnsweredByPrompt,
53
54
  pruneAbandonedSessions,
54
55
  releaseQuestionPush,
55
56
  readMatchingProjectSessionPointer,
@@ -69,6 +70,7 @@ import {
69
70
  BLOCKING_STOP_TIMEOUT_SECONDS,
70
71
  CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
71
72
  } from './install-hooks.js'
73
+ import { GUIDANCE_CONTEXT_MAX_BYTES } from './guidance-render.js'
72
74
 
73
75
  /** New-format test fixtures always carry the canonical body explicitly. */
74
76
  function registerQuestion(
@@ -127,6 +129,8 @@ interface Recorder {
127
129
  closed: string[]
128
130
  /** Simulates an offline machine; retirement has to survive one. */
129
131
  failSubmits?: boolean
132
+ /** Simulates an unreachable close fence so retirement stays queued. */
133
+ failCloses?: boolean
130
134
  /** Runs while an agent_question submit is in flight, before onSubmitted. */
131
135
  beforeQuestionSubmit?: () => void
132
136
  /** Lets race tests hold every replies call until all contenders are polling. */
@@ -142,6 +146,16 @@ interface Recorder {
142
146
  acknowledgementChecks?: string[]
143
147
  }
144
148
 
149
+ function isQuestionSubmit(entry: { draft: { reply?: unknown } }): boolean {
150
+ return entry.draft.reply !== undefined
151
+ }
152
+
153
+ function isRetirementSubmit(entry: {
154
+ draft: { lifecycle?: { retires_request_id?: string } }
155
+ }): boolean {
156
+ return entry.draft.lifecycle?.retires_request_id !== undefined
157
+ }
158
+
145
159
  function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
146
160
  let submissions = 0
147
161
  const recordedReplies = (requestId: string): ReplyView[] => {
@@ -215,7 +229,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
215
229
  health: async () => true,
216
230
  submit: async (body) => {
217
231
  if (recorder.failSubmits === true) throw new Error('offline')
218
- if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
232
+ if (body.draft.reply !== undefined) recorder.beforeQuestionSubmit?.()
219
233
  recorder.submitted.push(body)
220
234
  submissions += 1
221
235
  const requestId = body.request_id ?? `req_hook_${submissions}`
@@ -250,6 +264,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
250
264
  }) satisfies ListRepliesResponse
251
265
  },
252
266
  closeReplies: async (requestId) => {
267
+ if (recorder.failCloses === true) throw new Error('offline')
253
268
  recorder.closed.push(requestId)
254
269
  return {
255
270
  request_id: requestId,
@@ -736,7 +751,7 @@ describe('what the hook leaves behind', () => {
736
751
  )
737
752
 
738
753
  const raw = readFileSync(activeLogPath(h.env), 'utf8')
739
- expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
754
+ expect(raw).not.toContain('Allow exactly once')
740
755
  const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
741
756
  expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
742
757
  'queued',
@@ -867,7 +882,7 @@ describe('terminal-first grace window', () => {
867
882
 
868
883
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
869
884
 
870
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
885
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
871
886
  expect(submittedAt).toBe(NOW)
872
887
  })
873
888
 
@@ -900,7 +915,7 @@ describe('terminal-first grace window', () => {
900
915
 
901
916
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
902
917
 
903
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
918
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
904
919
  expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
905
920
  })
906
921
 
@@ -929,7 +944,7 @@ describe('terminal-first grace window', () => {
929
944
  expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
930
945
  expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
931
946
  expect(
932
- h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
947
+ h.recorder.submitted.find((entry) => isQuestionSubmit(entry))?.draft.reply
933
948
  ?.expires_in_seconds,
934
949
  ).toBeGreaterThanOrEqual(60)
935
950
  })
@@ -1038,7 +1053,7 @@ describe('nagging guards', () => {
1038
1053
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
1039
1054
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
1040
1055
 
1041
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
1056
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(1)
1042
1057
  })
1043
1058
 
1044
1059
  it('respects the harness recursion guard', async () => {
@@ -1066,7 +1081,7 @@ describe('nagging guards', () => {
1066
1081
 
1067
1082
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
1068
1083
 
1069
- const questions = h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')
1084
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
1070
1085
  expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
1071
1086
  'First question?',
1072
1087
  'Follow-up question?',
@@ -1123,7 +1138,7 @@ describe('late answer collection', () => {
1123
1138
 
1124
1139
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
1125
1140
 
1126
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
1141
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1127
1142
  expect(h.recorder.closed).toContain('req_live')
1128
1143
  expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
1129
1144
  expect(h.io.outLines).toHaveLength(1)
@@ -1170,14 +1185,10 @@ describe('late answer collection', () => {
1170
1185
  stdin({ session_id: 'late-prompt', stop_hook_active: true }),
1171
1186
  )
1172
1187
 
1173
- const retirement = h.recorder.submitted.find(
1174
- (entry) => entry.draft.event === 'question_retired',
1175
- )?.draft
1176
- expect(retirement?.lifecycle).toMatchObject({
1177
- state: 'answered',
1178
- retires_request_id: 'req_live',
1179
- })
1180
- expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
1188
+ expect(h.recorder.closed).toContain('req_live')
1189
+ expect(
1190
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
1191
+ ).toHaveLength(0)
1181
1192
  expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
1182
1193
  expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
1183
1194
  })
@@ -1191,10 +1202,10 @@ describe('OpenCode answer continuation', () => {
1191
1202
 
1192
1203
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
1193
1204
 
1194
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
1205
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1195
1206
  expect(h.recorder.closed).toEqual([])
1196
1207
  expect(
1197
- h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
1208
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
1198
1209
  ).toHaveLength(0)
1199
1210
  expect(readSessionState('open1', h.env).pending).toBeUndefined()
1200
1211
  expect(h.io.outLines).toEqual([])
@@ -1268,7 +1279,7 @@ describe('the waiter owning one question to the end', () => {
1268
1279
  return {
1269
1280
  ...client,
1270
1281
  submit: async (body: SubmitNotificationRequestT) => {
1271
- if (body.draft.event !== 'agent_question') return client.submit(body, 0)
1282
+ if (body.draft.reply === undefined) return client.submit(body, 0)
1272
1283
  acceptedRequestId = body.request_id
1273
1284
  h.recorder.submitted.push(body)
1274
1285
  h.recorder.receipts.push(body.request_id!)
@@ -1496,7 +1507,7 @@ describe('the waiter owning one question to the end', () => {
1496
1507
 
1497
1508
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
1498
1509
 
1499
- expect(h.deps.now?.()).toBe(NOW + 60_000)
1510
+ expect(h.deps.now?.()).toBe(NOW + 57_000)
1500
1511
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1501
1512
  expect(readSessionState('deadline', h.env).pending).toBeUndefined()
1502
1513
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
@@ -1512,7 +1523,7 @@ describe('the waiter owning one question to the end', () => {
1512
1523
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
1513
1524
 
1514
1525
  const windows = h.recorder.submitted
1515
- .filter((entry) => entry.draft.event === 'agent_question')
1526
+ .filter((entry) => isQuestionSubmit(entry))
1516
1527
  .map((entry) => entry.draft.reply?.expires_in_seconds)
1517
1528
  // How long the user may answer does not shrink because this owner spent
1518
1529
  // part of its own budget getting the question out. Both questions stay
@@ -1552,7 +1563,116 @@ describe('the waiter owning one question to the end', () => {
1552
1563
 
1553
1564
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
1554
1565
  expect(readSessionState('successor', h.env).pending).toBeUndefined()
1555
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(2)
1566
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(2)
1567
+ })
1568
+
1569
+ it('does not start another waiter for a live question whose owner lease is already spent', async () => {
1570
+ const h = harness([])
1571
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1572
+ writeSessionState('spent-owner', h.env, {
1573
+ last_prompt_at: AWAY,
1574
+ pending: [
1575
+ {
1576
+ question: 'Old question?',
1577
+ asked_at: NOW - 3_300_000,
1578
+ request_id: 'req_old',
1579
+ collapse_key: 'question-old',
1580
+ device_ids: ['dev_iphone'],
1581
+ reply_deadline_at: NOW + 86_400_000,
1582
+ owner_deadline_at: NOW - 1,
1583
+ },
1584
+ ],
1585
+ })
1586
+
1587
+ const started = h.deps.now?.() ?? NOW
1588
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'spent-owner' }))
1589
+
1590
+ expect(h.deps.now?.()).toBeLessThan(started + 30_000)
1591
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toHaveLength(0)
1592
+ expect(readSessionState('spent-owner', h.env).pending?.[0]?.request_id).toBe('req_old')
1593
+ expect(h.io.errLines.join('\n')).toContain('previous waiter already used its ceiling')
1594
+ })
1595
+
1596
+ it('does not re-arm a live question written before owner leases were persisted', async () => {
1597
+ const h = harness([])
1598
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1599
+ writeSessionState('legacy-spent', h.env, {
1600
+ last_prompt_at: AWAY,
1601
+ pending: [
1602
+ {
1603
+ question: 'Degraded waiter leftover?',
1604
+ asked_at: NOW - 3_300_000,
1605
+ request_id: 'req_ATU4_legacy',
1606
+ collapse_key: 'question-legacy',
1607
+ device_ids: ['dev_iphone'],
1608
+ reply_deadline_at: NOW + 86_400_000,
1609
+ },
1610
+ ],
1611
+ })
1612
+
1613
+ const started = h.deps.now?.() ?? NOW
1614
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'legacy-spent' }))
1615
+
1616
+ expect(h.deps.now?.()).toBeLessThan(started + 30_000)
1617
+ expect(readSessionState('legacy-spent', h.env).pending?.[0]?.request_id).toBe(
1618
+ 'req_ATU4_legacy',
1619
+ )
1620
+ })
1621
+
1622
+ it('pushes a newly registered question on the next Stop after a spent waiter', async () => {
1623
+ const h = harness([])
1624
+ h.recorder.replyExpiresAt = new Date(NOW + 86_400_000).toISOString()
1625
+ writeSessionState('after-degraded', h.env, {
1626
+ last_prompt_at: AWAY,
1627
+ pending: [
1628
+ {
1629
+ question: 'Earlier question?',
1630
+ asked_at: NOW - 3_300_000,
1631
+ request_id: 'req_earlier',
1632
+ collapse_key: 'question-earlier',
1633
+ device_ids: ['dev_iphone'],
1634
+ reply_deadline_at: NOW + 86_400_000,
1635
+ },
1636
+ ],
1637
+ })
1638
+
1639
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
1640
+ registerQuestion('after-degraded', h.env, { question: 'New question?' }, h.deps.now?.())
1641
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'after-degraded' }))
1642
+
1643
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
1644
+ expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['New question?'])
1645
+ expect(readSessionState('after-degraded', h.env).pending?.map((entry) => entry.question)).toEqual(
1646
+ ['Earlier question?', 'New question?'],
1647
+ )
1648
+ })
1649
+
1650
+ it('still waits out a live owner lease for an independent answer', async () => {
1651
+ const h = harness([])
1652
+ const answerAt = NOW + 10_000
1653
+ h.recorder.repliesFor = (requestId) =>
1654
+ requestId === 'req_live' && (h.deps.now?.() ?? 0) >= answerAt
1655
+ ? [reply({ text: 'Later' })]
1656
+ : []
1657
+ writeSessionState('lease', h.env, {
1658
+ last_prompt_at: AWAY,
1659
+ pending: [
1660
+ {
1661
+ question: 'Still waiting?',
1662
+ asked_at: NOW,
1663
+ request_id: 'req_live',
1664
+ collapse_key: 'question-live',
1665
+ device_ids: ['dev_iphone'],
1666
+ reply_deadline_at: NOW + 86_400_000,
1667
+ owner_deadline_at: NOW + 60_000,
1668
+ },
1669
+ ],
1670
+ })
1671
+
1672
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'lease' }))
1673
+
1674
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Later')
1675
+ expect(h.deps.now?.()).toBeGreaterThanOrEqual(answerAt)
1556
1676
  })
1557
1677
  })
1558
1678
 
@@ -1588,14 +1708,9 @@ describe('Cursor stop output', () => {
1588
1708
  * questions piling up on a phone teach the user to ignore the surface.
1589
1709
  */
1590
1710
  describe('question queueing and retirement debt', () => {
1591
- /** The lifecycle state of each retirement push the recorder captured. */
1592
- function retirements(h: Harness): { state: unknown; retires: unknown }[] {
1593
- return h.recorder.submitted
1594
- .filter((s) => s.draft.event === 'question_retired')
1595
- .map((s) => ({
1596
- state: s.draft.lifecycle?.state,
1597
- retires: s.draft.lifecycle?.retires_request_id,
1598
- }))
1711
+ /** Request ids whose close fence proved retirement. */
1712
+ function retirements(h: Harness): string[] {
1713
+ return h.recorder.closed
1599
1714
  }
1600
1715
 
1601
1716
  function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
@@ -1635,13 +1750,17 @@ describe('question queueing and retirement debt', () => {
1635
1750
 
1636
1751
  // Offline when the user returns: the wipe parks the retirement, the
1637
1752
  // drain fails, and it must not forget what it was for.
1638
- h.recorder.failSubmits = true
1639
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup2' }))
1753
+ h.recorder.failCloses = true
1754
+ await hookRunCommand(
1755
+ h.deps,
1756
+ 'user-prompt-submit',
1757
+ stdin({ session_id: 'sup2', prompt: 'done' }),
1758
+ )
1640
1759
  expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
1641
1760
 
1642
- h.recorder.failSubmits = false
1761
+ h.recorder.failCloses = false
1643
1762
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
1644
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1763
+ expect(retirements(h)).toContain(first)
1645
1764
  expect(readSessionState('sup2', h.env).retiring).toEqual([])
1646
1765
  })
1647
1766
 
@@ -1661,16 +1780,22 @@ describe('question queueing and retirement debt', () => {
1661
1780
  const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
1662
1781
  mkdirSync(configDir, { recursive: true })
1663
1782
  writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
1664
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup-targets' }))
1665
-
1666
- const retirement = h.recorder.submitted.find(
1667
- (submission) => submission.draft.event === 'question_retired',
1783
+ h.recorder.failCloses = true
1784
+ await hookRunCommand(
1785
+ h.deps,
1786
+ 'user-prompt-submit',
1787
+ stdin({ session_id: 'sup-targets', prompt: 'done' }),
1668
1788
  )
1669
- expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
1670
- expect(retirement?.draft.targets).toEqual({
1671
- mode: 'selected',
1672
- device_ids: ['dev_iphone', 'dev_mac'],
1673
- })
1789
+
1790
+ expect(readSessionState('sup-targets', h.env).retiring).toEqual([
1791
+ expect.objectContaining({
1792
+ request_id: 'req_live',
1793
+ device_ids: ['dev_iphone', 'dev_mac'],
1794
+ }),
1795
+ ])
1796
+ expect(
1797
+ h.recorder.submitted.filter((submission) => isRetirementSubmit(submission)),
1798
+ ).toHaveLength(0)
1674
1799
  })
1675
1800
 
1676
1801
  it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
@@ -1681,14 +1806,18 @@ describe('question queueing and retirement debt', () => {
1681
1806
  seedLive(h, 'sup3')
1682
1807
  const first = 'req_live'
1683
1808
 
1684
- h.recorder.failSubmits = true
1685
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
1809
+ h.recorder.failCloses = true
1810
+ await hookRunCommand(
1811
+ h.deps,
1812
+ 'user-prompt-submit',
1813
+ stdin({ session_id: 'sup3', prompt: 'done' }),
1814
+ )
1686
1815
  expect(readSessionState('sup3', h.env).retiring).toHaveLength(1)
1687
- h.recorder.failSubmits = false
1816
+ h.recorder.failCloses = false
1688
1817
 
1689
1818
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
1690
1819
 
1691
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1820
+ expect(retirements(h)).toContain(first)
1692
1821
  expect(readSessionState('sup3', h.env).retiring).toEqual([])
1693
1822
  })
1694
1823
 
@@ -1701,13 +1830,21 @@ describe('question queueing and retirement debt', () => {
1701
1830
 
1702
1831
  // The first return is offline: the wipe parks, the drain fails, and the
1703
1832
  // next prompt must still find the debt.
1704
- h.recorder.failSubmits = true
1705
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
1706
- h.recorder.failSubmits = false
1833
+ h.recorder.failCloses = true
1834
+ await hookRunCommand(
1835
+ h.deps,
1836
+ 'user-prompt-submit',
1837
+ stdin({ session_id: 'sup4', prompt: 'done' }),
1838
+ )
1839
+ h.recorder.failCloses = false
1707
1840
 
1708
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
1841
+ await hookRunCommand(
1842
+ h.deps,
1843
+ 'user-prompt-submit',
1844
+ stdin({ session_id: 'sup4', prompt: 'ok' }),
1845
+ )
1709
1846
 
1710
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
1847
+ expect(retirements(h)).toContain(first)
1711
1848
  expect(readSessionState('sup4', h.env).retiring).toEqual([])
1712
1849
  })
1713
1850
 
@@ -1741,7 +1878,7 @@ describe('several questions in flight', () => {
1741
1878
 
1742
1879
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi1' }))
1743
1880
 
1744
- const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1881
+ const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
1745
1882
  expect(questions.map((s) => s.draft.presentation.body)).toEqual(['Ship it?', 'Deploy where?'])
1746
1883
  // Each is its own notification with its own collapse key — one ask never
1747
1884
  // stands in for, or replaces, another.
@@ -1785,7 +1922,7 @@ describe('several questions in flight', () => {
1785
1922
  'codex',
1786
1923
  )
1787
1924
 
1788
- expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
1925
+ expect(h.recorder.submitted.filter((s) => isQuestionSubmit(s))).toHaveLength(1)
1789
1926
  expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
1790
1927
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
1791
1928
  expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
@@ -1819,7 +1956,7 @@ describe('several questions in flight', () => {
1819
1956
 
1820
1957
  // Submit recorded the durable id before any wait; a 500 must not erase it
1821
1958
  // mid-flight, and recovery must still surface the late answer.
1822
- const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
1959
+ const questions = h.recorder.submitted.filter((s) => isQuestionSubmit(s))
1823
1960
  expect(questions).toHaveLength(1)
1824
1961
  expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1825
1962
  expect(polls).toBeGreaterThanOrEqual(3)
@@ -1959,13 +2096,10 @@ describe('several questions in flight', () => {
1959
2096
  )
1960
2097
  expect(h.io.outLines).toEqual([])
1961
2098
  expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
2099
+ expect(h.recorder.closed).toContain('req_existing')
1962
2100
  expect(
1963
- h.recorder.submitted.find(
1964
- (entry) =>
1965
- entry.draft.event === 'question_retired' &&
1966
- entry.draft.lifecycle?.retires_request_id === 'req_existing',
1967
- )?.draft.lifecycle?.state,
1968
- ).toBe('answered')
2101
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
2102
+ ).toHaveLength(0)
1969
2103
  })
1970
2104
 
1971
2105
  it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
@@ -2155,19 +2289,14 @@ describe('several questions in flight', () => {
2155
2289
  expect(followup.reason).toContain('Staging')
2156
2290
  expect(readSessionState('multi3', h.env).pending).toBeUndefined()
2157
2291
  expect(
2158
- h.recorder.submitted.filter((s) => s.draft.event === 'agent_question'),
2292
+ h.recorder.submitted.filter((s) => isQuestionSubmit(s)),
2159
2293
  ).toHaveLength(2)
2160
2294
  })
2161
2295
  })
2162
2296
 
2163
2297
  describe('a question that outlives its session', () => {
2164
- function retirements(h: Harness): { state: unknown; retires: unknown }[] {
2165
- return h.recorder.submitted
2166
- .filter((s) => s.draft.event === 'question_retired')
2167
- .map((s) => ({
2168
- state: s.draft.lifecycle?.state,
2169
- retires: s.draft.lifecycle?.retires_request_id,
2170
- }))
2298
+ function retirements(h: Harness): string[] {
2299
+ return h.recorder.closed
2171
2300
  }
2172
2301
 
2173
2302
  /** State a still-owned request can have when SessionEnd races its Stop. */
@@ -2198,7 +2327,7 @@ describe('a question that outlives its session', () => {
2198
2327
  // A different session's next hook holds a client and inherits the debt.
2199
2328
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
2200
2329
  expect(h.recorder.closed).toContain(first)
2201
- expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
2330
+ expect(retirements(h)).toContain(first)
2202
2331
  })
2203
2332
 
2204
2333
  it('carries parked retirements across SessionEnd too', async () => {
@@ -2207,13 +2336,17 @@ describe('a question that outlives its session', () => {
2207
2336
 
2208
2337
  // Died while offline: the user came back, the wipe parked the
2209
2338
  // retirement, and the drain could not send it.
2210
- h.recorder.failSubmits = true
2211
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'dead2' }))
2212
- h.recorder.failSubmits = false
2339
+ h.recorder.failCloses = true
2340
+ await hookRunCommand(
2341
+ h.deps,
2342
+ 'user-prompt-submit',
2343
+ stdin({ session_id: 'dead2', prompt: 'done' }),
2344
+ )
2345
+ h.recorder.failCloses = false
2213
2346
 
2214
2347
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
2215
2348
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
2216
- expect(retirements(h)).toContainEqual({ state: 'answered_elsewhere', retires: first })
2349
+ expect(retirements(h)).toContain(first)
2217
2350
  })
2218
2351
 
2219
2352
  it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
@@ -2222,16 +2355,16 @@ describe('a question that outlives its session', () => {
2222
2355
  await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
2223
2356
 
2224
2357
  // Still offline: the queue must survive a failed drain.
2225
- h.recorder.failSubmits = true
2358
+ h.recorder.failCloses = true
2226
2359
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2227
- h.recorder.failSubmits = false
2360
+ h.recorder.failCloses = false
2228
2361
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2229
- expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
2362
+ expect(retirements(h)).toContain(first)
2230
2363
 
2231
2364
  // And a second drain does not send it twice.
2232
- const count = retirements(h).filter((r) => r.retires === first).length
2365
+ const count = retirements(h).filter((id) => id === first).length
2233
2366
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
2234
- expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
2367
+ expect(retirements(h).filter((id) => id === first)).toHaveLength(count)
2235
2368
  })
2236
2369
 
2237
2370
  it('gives up on an orphan that outlived the longest answerable window', async () => {
@@ -2498,7 +2631,373 @@ describe('ask registration', () => {
2498
2631
  })
2499
2632
  })
2500
2633
 
2634
+ describe('session-start hook', () => {
2635
+ it('adds proactive Notifai context before setup or machine authentication exists', async () => {
2636
+ const h = harness()
2637
+ h.deps.store.load = () => null
2638
+
2639
+ for (const hookHarness of ['claude-code', 'codex'] as const) {
2640
+ h.io.outLines = []
2641
+ await hookRunCommand(
2642
+ h.deps,
2643
+ 'session-start',
2644
+ stdin({ session_id: `proactive-guidance-${hookHarness}`, source: 'startup' }),
2645
+ hookHarness,
2646
+ )
2647
+
2648
+ const first = JSON.parse(h.io.outLines.at(-1)!) as {
2649
+ hookSpecificOutput?: { hookEventName?: string; additionalContext?: string }
2650
+ }
2651
+ expect(first.hookSpecificOutput).toMatchObject({
2652
+ hookEventName: 'SessionStart',
2653
+ })
2654
+ expect(first.hookSpecificOutput?.additionalContext).toMatch(/Notifai.*session/i)
2655
+ expect(first.hookSpecificOutput?.additionalContext).toContain('# How to read this guidance')
2656
+ expect(first.hookSpecificOutput?.additionalContext).toContain(
2657
+ '<!-- notifai:guidance topic=when-to-notify from=shipped default -->',
2658
+ )
2659
+ expect(first.hookSpecificOutput?.additionalContext).not.toContain('run `notifai guidance` once')
2660
+ expect(h.io.errLines.join('\n')).not.toMatch(/not paired/i)
2661
+ }
2662
+ })
2663
+
2664
+ it('caps the total root lifecycle context and falls back without partial guidance', async () => {
2665
+ const h = harness()
2666
+ const guidance = path.join(h.deps.cwd, '.notifai', 'guidance')
2667
+ mkdirSync(guidance, { recursive: true })
2668
+ writeFileSync(path.join(guidance, 'titles.md'), `private-start-${'x'.repeat(16_000)}`)
2669
+ writeFileSync(path.join(guidance, 'bodies.md'), `private-end-${'y'.repeat(16_000)}`)
2670
+
2671
+ await hookRunCommand(
2672
+ h.deps,
2673
+ 'session-start',
2674
+ stdin({ session_id: 'bounded-root-guidance', source: 'startup' }),
2675
+ 'claude-code',
2676
+ )
2677
+ const output = JSON.parse(h.io.outLines.at(-1) ?? '{}') as {
2678
+ hookSpecificOutput?: { additionalContext?: string }
2679
+ }
2680
+ const context = output.hookSpecificOutput?.additionalContext ?? ''
2681
+ expect(Buffer.byteLength(context, 'utf8')).toBeLessThanOrEqual(GUIDANCE_CONTEXT_MAX_BYTES)
2682
+ expect(context).toContain('above the')
2683
+ expect(context).toContain('run `notifai guidance` once')
2684
+ expect(context).not.toContain('private-start-')
2685
+ expect(context).not.toContain('private-end-')
2686
+ })
2687
+
2688
+ it('uses each supported lifecycle output contract and gives workers deterministic ownership', async () => {
2689
+ const h = harness()
2690
+ h.deps.store.load = () => null
2691
+
2692
+ await hookRunCommand(
2693
+ h.deps,
2694
+ 'session-start',
2695
+ stdin({ conversation_id: 'cursor-session', workspace_roots: [h.deps.cwd] }),
2696
+ 'cursor',
2697
+ )
2698
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2699
+ additional_context: expect.stringMatching(/Notifai.*session/i),
2700
+ })
2701
+
2702
+ h.io.outLines = []
2703
+ await hookRunCommand(
2704
+ h.deps,
2705
+ 'subagent-start',
2706
+ stdin({ session_id: 'subagent-session' }),
2707
+ 'claude-code',
2708
+ )
2709
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2710
+ hookSpecificOutput: {
2711
+ hookEventName: 'SubagentStart',
2712
+ additionalContext: expect.stringMatching(
2713
+ /report Agent Events to the parent.*do not send.*unless the parent explicitly delegated/i,
2714
+ ),
2715
+ },
2716
+ })
2717
+ expect(h.io.outLines.at(-1)).not.toContain('topic=titles')
2718
+ expect(h.io.outLines.at(-1)).not.toContain('topic=bodies')
2719
+ })
2720
+
2721
+ it('uses Cursor native Stop follow-up once when its SessionStart context is lossy', async () => {
2722
+ const h = harness()
2723
+ h.deps.store.load = () => null
2724
+ const input = stdin({
2725
+ conversation_id: 'cursor-activation',
2726
+ workspace_roots: [h.deps.cwd],
2727
+ loop_count: 0,
2728
+ })
2729
+
2730
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2731
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toMatchObject({
2732
+ followup_message: expect.stringMatching(/effective, provenance-marked guidance.*when-to-notify/is),
2733
+ })
2734
+
2735
+ h.io.outLines = []
2736
+ await hookRunCommand(
2737
+ h.deps,
2738
+ 'activation-stop',
2739
+ stdin({
2740
+ conversation_id: 'cursor-activation',
2741
+ workspace_roots: [h.deps.cwd],
2742
+ loop_count: 1,
2743
+ }),
2744
+ 'cursor',
2745
+ )
2746
+ expect(h.io.outLines).toEqual([])
2747
+ expect(readSessionState('cursor-activation', h.env).cursor_activation_confirmed_at).toBe(NOW)
2748
+
2749
+ await hookRunCommand(
2750
+ h.deps,
2751
+ 'session-start',
2752
+ stdin({ conversation_id: 'cursor-activation', workspace_roots: [h.deps.cwd] }),
2753
+ 'cursor',
2754
+ )
2755
+ h.io.outLines = []
2756
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2757
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2758
+ })
2759
+
2760
+ it('does not let Cursor activation override cancellation or a live question continuation', async () => {
2761
+ const h = harness()
2762
+ const base = {
2763
+ conversation_id: 'cursor-coexistence',
2764
+ workspace_roots: [h.deps.cwd],
2765
+ loop_count: 0,
2766
+ }
2767
+
2768
+ await hookRunCommand(
2769
+ h.deps,
2770
+ 'activation-stop',
2771
+ stdin({ ...base, status: 'aborted' }),
2772
+ 'cursor',
2773
+ )
2774
+ expect(h.io.outLines).toEqual([])
2775
+
2776
+ registerQuestion('cursor-coexistence', h.env, { question: 'Choose a path?' })
2777
+ await hookRunCommand(
2778
+ h.deps,
2779
+ 'activation-stop',
2780
+ stdin({ ...base, status: 'completed' }),
2781
+ 'cursor',
2782
+ )
2783
+ expect(h.io.outLines).toEqual([])
2784
+ expect(readSessionState('cursor-coexistence', h.env).cursor_activation_claimed_at).toBeUndefined()
2785
+
2786
+ await hookRunCommand(
2787
+ h.deps,
2788
+ 'activation-stop',
2789
+ stdin({ ...base, status: 'completed', loop_count: 1 }),
2790
+ 'cursor',
2791
+ )
2792
+ expect(h.io.outLines).toEqual([])
2793
+ expect(readSessionState('cursor-coexistence', h.env).cursor_activation_confirmed_at).toBeUndefined()
2794
+ })
2795
+
2796
+ it('retries an unconfirmed Cursor activation claim after the crash guard expires', async () => {
2797
+ const h = harness()
2798
+ const input = stdin({
2799
+ conversation_id: 'cursor-activation-retry',
2800
+ workspace_roots: [h.deps.cwd],
2801
+ status: 'completed',
2802
+ loop_count: 0,
2803
+ })
2804
+
2805
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2806
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2807
+
2808
+ h.io.outLines = []
2809
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2810
+ expect(h.io.outLines).toEqual([])
2811
+
2812
+ h.advanceClock(30_001)
2813
+ await hookRunCommand(h.deps, 'activation-stop', input, 'cursor')
2814
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2815
+ })
2816
+
2817
+ it('fails closed without a Cursor conversation id and still activates after an errored turn', async () => {
2818
+ const h = harness()
2819
+
2820
+ await hookRunCommand(
2821
+ h.deps,
2822
+ 'activation-stop',
2823
+ stdin({ workspace_roots: [h.deps.cwd], status: 'completed', loop_count: 0 }),
2824
+ 'cursor',
2825
+ )
2826
+ expect(h.io.outLines).toEqual([])
2827
+
2828
+ await hookRunCommand(
2829
+ h.deps,
2830
+ 'activation-stop',
2831
+ stdin({
2832
+ conversation_id: 'cursor-error-activation',
2833
+ workspace_roots: [h.deps.cwd],
2834
+ status: 'error',
2835
+ loop_count: 0,
2836
+ }),
2837
+ 'cursor',
2838
+ )
2839
+ expect(JSON.parse(h.io.outLines.at(-1) ?? '{}')).toHaveProperty('followup_message')
2840
+ })
2841
+
2842
+ it('makes a Claude compatibility copy inside Cursor a no-op', async () => {
2843
+ const h = harness()
2844
+ h.deps.env['CURSOR_PROJECT_DIR'] = h.deps.cwd
2845
+
2846
+ await hookRunCommand(
2847
+ h.deps,
2848
+ 'session-start',
2849
+ stdin({ session_id: 'cursor-compat-copy', source: 'startup' }),
2850
+ 'claude-code',
2851
+ )
2852
+
2853
+ expect(h.io.outLines).toEqual([])
2854
+ })
2855
+ })
2856
+
2501
2857
  describe('user-prompt-submit hook', () => {
2858
+ it('activates once as a compatibility fallback when SessionStart was absent', async () => {
2859
+ const h = harness()
2860
+
2861
+ await hookRunCommand(
2862
+ h.deps,
2863
+ 'user-prompt-submit',
2864
+ stdin({ session_id: 'presence-only' }),
2865
+ 'claude-code',
2866
+ )
2867
+
2868
+ expect(h.io.outLines).toHaveLength(1)
2869
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2870
+ hookSpecificOutput: {
2871
+ hookEventName: 'UserPromptSubmit',
2872
+ additionalContext: expect.stringMatching(/Notifai.*session/i),
2873
+ },
2874
+ })
2875
+ expect(readSessionState('presence-only', h.env).last_prompt_at).toBe(NOW)
2876
+
2877
+ await hookRunCommand(
2878
+ h.deps,
2879
+ 'user-prompt-submit',
2880
+ stdin({ session_id: 'presence-only' }),
2881
+ 'claude-code',
2882
+ )
2883
+ expect(h.io.outLines).toHaveLength(1)
2884
+ })
2885
+
2886
+ it('stays silent when SessionStart already delivered activation', async () => {
2887
+ const h = harness()
2888
+ h.deps.codexSourcePid = 4242
2889
+
2890
+ await hookRunCommand(
2891
+ h.deps,
2892
+ 'session-start',
2893
+ stdin({ session_id: 'lifecycle-owned' }),
2894
+ 'codex',
2895
+ )
2896
+ h.io.outLines = []
2897
+
2898
+ await hookRunCommand(
2899
+ h.deps,
2900
+ 'user-prompt-submit',
2901
+ stdin({ session_id: 'lifecycle-owned' }),
2902
+ 'codex',
2903
+ )
2904
+
2905
+ expect(h.io.outLines).toEqual([])
2906
+ })
2907
+
2908
+ it('reactivates a resumed session owned by a new harness process', async () => {
2909
+ const h = harness()
2910
+ h.deps.codexSourcePid = 4242
2911
+ await hookRunCommand(
2912
+ h.deps,
2913
+ 'session-start',
2914
+ stdin({ session_id: 'resumed-owner' }),
2915
+ 'codex',
2916
+ )
2917
+ h.io.outLines = []
2918
+ h.deps.codexSourcePid = 4343
2919
+
2920
+ await hookRunCommand(
2921
+ h.deps,
2922
+ 'user-prompt-submit',
2923
+ stdin({ session_id: 'resumed-owner' }),
2924
+ 'codex',
2925
+ )
2926
+
2927
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2928
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2929
+ })
2930
+ })
2931
+
2932
+ it('uses the adapter-propagated harness owner across per-hook CLI processes', async () => {
2933
+ const h = harness()
2934
+ h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5252'
2935
+
2936
+ await hookRunCommand(
2937
+ h.deps,
2938
+ 'user-prompt-submit',
2939
+ stdin({ session_id: 'adapter-owned' }),
2940
+ 'codex',
2941
+ )
2942
+ h.io.outLines = []
2943
+ await hookRunCommand(
2944
+ h.deps,
2945
+ 'user-prompt-submit',
2946
+ stdin({ session_id: 'adapter-owned' }),
2947
+ 'codex',
2948
+ )
2949
+ expect(h.io.outLines).toEqual([])
2950
+
2951
+ h.env['NOTIFAI_HOOK_SOURCE_PID'] = '5353'
2952
+ await hookRunCommand(
2953
+ h.deps,
2954
+ 'user-prompt-submit',
2955
+ stdin({ session_id: 'adapter-owned' }),
2956
+ 'codex',
2957
+ )
2958
+ expect(h.io.outLines).toHaveLength(1)
2959
+ })
2960
+
2961
+ it('does not let a worker lifecycle suppress parent prompt activation', async () => {
2962
+ const h = harness()
2963
+ h.deps.codexSourcePid = 4242
2964
+ await hookRunCommand(
2965
+ h.deps,
2966
+ 'subagent-start',
2967
+ stdin({ session_id: 'shared-parent-worker' }),
2968
+ 'codex',
2969
+ )
2970
+ h.io.outLines = []
2971
+
2972
+ await hookRunCommand(
2973
+ h.deps,
2974
+ 'user-prompt-submit',
2975
+ stdin({ session_id: 'shared-parent-worker' }),
2976
+ 'codex',
2977
+ )
2978
+
2979
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2980
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2981
+ })
2982
+ })
2983
+
2984
+ it('activates before config and authentication readiness', async () => {
2985
+ const h = harness()
2986
+ h.deps.cwd = path.join(h.deps.cwd, 'missing-project')
2987
+ h.deps.store.load = () => null
2988
+
2989
+ await hookRunCommand(
2990
+ h.deps,
2991
+ 'user-prompt-submit',
2992
+ stdin({ session_id: 'first-run', cwd: h.deps.cwd }),
2993
+ 'codex',
2994
+ )
2995
+
2996
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
2997
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
2998
+ })
2999
+ })
3000
+
2502
3001
  it('records presence', async () => {
2503
3002
  const h = harness()
2504
3003
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
@@ -2564,7 +3063,7 @@ describe('user-prompt-submit hook', () => {
2564
3063
  expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
2565
3064
  })
2566
3065
 
2567
- it('preserves a delivered question through a second ask and the prompt transition', async () => {
3066
+ it('preserves a delivered question through a second ask and an unrelated prompt', async () => {
2568
3067
  const h = harness([])
2569
3068
  const live = {
2570
3069
  question: 'Ship it?',
@@ -2575,10 +3074,6 @@ describe('user-prompt-submit hook', () => {
2575
3074
  }
2576
3075
  writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
2577
3076
 
2578
- // A second plain `ask` resolves through the project pointer and joins the
2579
- // queue behind the live question — it must not touch it. The next prompt
2580
- // then resets presence before another Stop can run — the transition that
2581
- // used to erase the retirement debt.
2582
3077
  registerQuestion('transition', h.env, { question: 'Deploy it?' })
2583
3078
  const queued = readSessionState('transition', h.env)
2584
3079
  expect(queued.retiring ?? []).toEqual([])
@@ -2587,35 +3082,22 @@ describe('user-prompt-submit hook', () => {
2587
3082
  await hookRunCommand(
2588
3083
  h.deps,
2589
3084
  'user-prompt-submit',
2590
- stdin({ session_id: 'transition', cwd: h.deps.cwd }),
3085
+ stdin({
3086
+ session_id: 'transition',
3087
+ cwd: h.deps.cwd,
3088
+ prompt: 'keep going on the billing work',
3089
+ }),
2591
3090
  'claude-code',
2592
3091
  )
2593
3092
 
2594
- // The delivered question retires truthfully. The never-delivered second
2595
- // one must remain queued: if Codex skipped Stop, erasing it here loses the
2596
- // only copy before it ever reached a device.
2597
- const retirement = h.recorder.submitted.find(
2598
- (submission) => submission.draft.event === 'question_retired',
2599
- )?.draft
2600
- expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
2601
- expect(retirement?.lifecycle).toEqual({
2602
- tier: 'done',
2603
- state: 'answered_elsewhere',
2604
- retires_request_id: live.request_id,
2605
- })
2606
- expect(retirement?.targets).toEqual({
2607
- mode: 'selected',
2608
- device_ids: ['dev_iphone', 'dev_mac'],
2609
- })
3093
+ expect(h.recorder.closed).toEqual([])
2610
3094
  expect(
2611
- h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
2612
- ).toHaveLength(1)
3095
+ h.recorder.submitted.filter((s) => isRetirementSubmit(s)),
3096
+ ).toHaveLength(0)
2613
3097
  expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
3098
+ 'Ship it?',
2614
3099
  'Deploy it?',
2615
3100
  ])
2616
-
2617
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
2618
- expect(readSessionState('transition', h.env).pending).toBeUndefined()
2619
3101
  })
2620
3102
 
2621
3103
  it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
@@ -2625,14 +3107,14 @@ describe('user-prompt-submit hook', () => {
2625
3107
 
2626
3108
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
2627
3109
  expect(readSessionState('s11', h.env).pending).toBeUndefined()
2628
- const retirementCount = h.recorder.submitted.filter(
2629
- (entry) => entry.draft.event === 'question_retired',
2630
- ).length
3110
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2631
3111
 
2632
3112
  await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
2633
3113
 
2634
3114
  expect(h.recorder.closed).toContain(h.recorder.receipts[0])
2635
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired')).toHaveLength(retirementCount + 1)
3115
+ expect(
3116
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3117
+ ).toHaveLength(0)
2636
3118
  })
2637
3119
 
2638
3120
  it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
@@ -2651,16 +3133,16 @@ describe('user-prompt-submit hook', () => {
2651
3133
  }],
2652
3134
  })
2653
3135
 
2654
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
3136
+ await hookRunCommand(
3137
+ h.deps,
3138
+ 'user-prompt-submit',
3139
+ stdin({ session_id: 's15', cwd: '/repo', prompt: 'done' }),
3140
+ )
2655
3141
 
2656
- const retirement = h.recorder.submitted.at(-1)?.draft
2657
- expect(retirement?.lifecycle).toEqual({
2658
- tier: 'done',
2659
- state: 'answered_elsewhere',
2660
- // The history-entry correlation id: companions key entries by request
2661
- // id and never persisted the collapse key.
2662
- retires_request_id: 'req_live',
2663
- })
3142
+ expect(h.recorder.closed).toContain('req_live')
3143
+ expect(
3144
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3145
+ ).toHaveLength(0)
2664
3146
  })
2665
3147
 
2666
3148
  it('retires as done/answered when the answer came from a device', async () => {
@@ -2676,12 +3158,71 @@ describe('user-prompt-submit hook', () => {
2676
3158
  stdin({ session_id: 's16', stop_hook_active: true }),
2677
3159
  )
2678
3160
 
2679
- const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
2680
- expect(retirement?.draft.lifecycle).toEqual({
2681
- tier: 'done',
2682
- state: 'answered',
2683
- retires_request_id: h.recorder.receipts[0],
3161
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3162
+ expect(
3163
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3164
+ ).toHaveLength(0)
3165
+ })
3166
+
3167
+ it('retires only the outstanding question a prompt uniquely answers', async () => {
3168
+ const h = harness([])
3169
+ writeSessionState('prompt-match', h.env, {
3170
+ last_prompt_at: AWAY,
3171
+ pending: [
3172
+ {
3173
+ question: 'Ship it?',
3174
+ questions: [
3175
+ {
3176
+ id: 'q1',
3177
+ text: 'Ship it?',
3178
+ choices: [
3179
+ { id: 'ship', label: 'Ship it' },
3180
+ { id: 'wait', label: 'Wait' },
3181
+ ],
3182
+ },
3183
+ ],
3184
+ request_id: 'req_live',
3185
+ collapse_key: 'collapse-live',
3186
+ device_ids: ['dev_iphone'],
3187
+ reply_deadline_at: NOW + 60_000,
3188
+ },
3189
+ ],
3190
+ })
3191
+
3192
+ await hookRunCommand(
3193
+ h.deps,
3194
+ 'user-prompt-submit',
3195
+ stdin({ session_id: 'prompt-match', prompt: 'Ship it' }),
3196
+ )
3197
+
3198
+ expect(h.recorder.closed).toContain('req_live')
3199
+ expect(readSessionState('prompt-match', h.env).pending).toBeUndefined()
3200
+ })
3201
+
3202
+ it('leaves an unrelated prompt’s live question open for the reply window', async () => {
3203
+ const h = harness([])
3204
+ writeSessionState('prompt-unrelated', h.env, {
3205
+ last_prompt_at: AWAY,
3206
+ pending: [{
3207
+ question: 'Ship it?',
3208
+ request_id: 'req_live',
3209
+ collapse_key: 'collapse-live',
3210
+ device_ids: ['dev_iphone'],
3211
+ reply_deadline_at: NOW + 60_000,
3212
+ }],
2684
3213
  })
3214
+
3215
+ await hookRunCommand(
3216
+ h.deps,
3217
+ 'user-prompt-submit',
3218
+ stdin({
3219
+ session_id: 'prompt-unrelated',
3220
+ prompt: 'continue investigating the billing failure on staging',
3221
+ }),
3222
+ )
3223
+
3224
+ expect(h.recorder.closed).toEqual([])
3225
+ expect(readSessionState('prompt-unrelated', h.env).pending?.[0]?.request_id).toBe('req_live')
2685
3226
  })
2686
3227
 
2687
3228
  it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
@@ -2762,6 +3303,83 @@ describe('user-prompt-submit hook', () => {
2762
3303
  })
2763
3304
  })
2764
3305
 
3306
+ describe('reconciling a conversation answer', () => {
3307
+ it('matches a unique closed choice and a lone short confirmation', () => {
3308
+ const choiceQuestion: PendingQuestion = {
3309
+ question: 'Ship it?',
3310
+ questions: [
3311
+ {
3312
+ id: 'q1',
3313
+ text: 'Ship it?',
3314
+ choices: [
3315
+ { id: 'ship', label: 'Ship it' },
3316
+ { id: 'wait', label: 'Wait' },
3317
+ ],
3318
+ },
3319
+ ],
3320
+ }
3321
+ const freeText: PendingQuestion = { question: 'Create the key?' }
3322
+ expect(pendingAnsweredByPrompt('Ship it', [choiceQuestion]).map((entry) => entry.question)).toEqual([
3323
+ 'Ship it?',
3324
+ ])
3325
+ expect(pendingAnsweredByPrompt('done', [freeText])).toEqual([freeText])
3326
+ expect(pendingAnsweredByPrompt('done', [choiceQuestion, freeText])).toEqual([])
3327
+ expect(pendingAnsweredByPrompt('keep going on the billing work', [freeText])).toEqual([])
3328
+ })
3329
+
3330
+ it('does not push a conversation-answered question after a newer one is registered', async () => {
3331
+ const h = harness([])
3332
+ writeSessionState('stale-then-new', h.env, { last_prompt_at: AWAY })
3333
+ registerQuestion('stale-then-new', h.env, { question: 'Create the Apple IAP key?' })
3334
+
3335
+ await hookRunCommand(
3336
+ h.deps,
3337
+ 'user-prompt-submit',
3338
+ stdin({ session_id: 'stale-then-new', prompt: 'done' }),
3339
+ )
3340
+ expect(readSessionState('stale-then-new', h.env).pending).toBeUndefined()
3341
+
3342
+ registerQuestion('stale-then-new', h.env, { question: 'Which ASN endpoint?' })
3343
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'stale-then-new' }))
3344
+
3345
+ const questions = h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))
3346
+ expect(questions.map((entry) => entry.draft.presentation.body)).toEqual(['Which ASN endpoint?'])
3347
+ })
3348
+
3349
+ it('reserves a close-fence budget when the owner deadline is nearly now', async () => {
3350
+ const h = harness([])
3351
+ const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
3352
+ mkdirSync(dir, { recursive: true })
3353
+ writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
3354
+ writeSessionState('near-zero', h.env, {
3355
+ last_prompt_at: AWAY,
3356
+ pending: [{
3357
+ question: 'Ship it?',
3358
+ asked_at: NOW - 60_000,
3359
+ request_id: 'req_live',
3360
+ collapse_key: 'collapse-live',
3361
+ device_ids: ['dev_iphone'],
3362
+ reply_deadline_at: NOW + 4_001,
3363
+ owner_deadline_at: NOW + 4_001,
3364
+ }],
3365
+ })
3366
+
3367
+ await hookRunCommand(
3368
+ { ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) },
3369
+ 'stop',
3370
+ stdin({ session_id: 'near-zero', cwd: h.deps.cwd }),
3371
+ )
3372
+
3373
+ expect(h.recorder.closed).toContain('req_live')
3374
+ expect(
3375
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3376
+ ).toHaveLength(0)
3377
+ expect(h.deps.now?.()).toBeLessThan(NOW + 10_000)
3378
+ const records = readLogRecords(h.env, { event: ['hook.retirement'] }).records
3379
+ expect(records.some((record) => record.data?.['proven'] === true)).toBe(true)
3380
+ })
3381
+ })
3382
+
2765
3383
  describe('session-end hook', () => {
2766
3384
  it('drops local state without touching the network and records the lifecycle', async () => {
2767
3385
  const h = harness()
@@ -2805,6 +3423,7 @@ describe('session-end hook', () => {
2805
3423
  expect(JSON.parse(readFileSync(stateFile, 'utf8'))).toEqual({
2806
3424
  future_root: { retained: true },
2807
3425
  acknowledgement_due: [acknowledgement],
3426
+ session_id: sessionId,
2808
3427
  })
2809
3428
  })
2810
3429
 
@@ -3029,7 +3648,7 @@ describe('telling concurrent agents apart', () => {
3029
3648
  'claude-code',
3030
3649
  )
3031
3650
 
3032
- const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3651
+ const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
3033
3652
  expect(question?.draft.source).toEqual({
3034
3653
  session_id: 'session-branch-transition',
3035
3654
  session_label: 'Branch transition',
@@ -3051,15 +3670,10 @@ describe('telling concurrent agents apart', () => {
3051
3670
  'claude-code',
3052
3671
  )
3053
3672
 
3054
- const retirement = h.recorder.submitted.find(
3055
- (entry) => entry.draft.event === 'question_retired',
3056
- )
3057
- expect(retirement?.draft.source).toEqual({
3058
- session_id: 'session-branch-transition',
3059
- session_label: 'Branch transition',
3060
- harness: 'claude-code',
3061
- branch: 'branch-a',
3062
- })
3673
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3674
+ expect(
3675
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3676
+ ).toHaveLength(0)
3063
3677
  })
3064
3678
 
3065
3679
  it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
@@ -3089,7 +3703,7 @@ describe('telling concurrent agents apart', () => {
3089
3703
  'claude-code',
3090
3704
  )
3091
3705
 
3092
- const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3706
+ const question = h.recorder.submitted.find((entry) => isQuestionSubmit(entry))
3093
3707
  expect(question?.draft.source).toEqual({
3094
3708
  session_id: 'session-detached',
3095
3709
  session_label: 'Detached review',
@@ -3125,7 +3739,7 @@ describe('telling concurrent agents apart', () => {
3125
3739
  'claude-code',
3126
3740
  )
3127
3741
  const question = h.recorder.submitted.find(
3128
- (entry) => entry.draft.event === 'agent_question',
3742
+ (entry) => isQuestionSubmit(entry),
3129
3743
  )
3130
3744
  expect(question?.draft.source).toEqual({
3131
3745
  session_id: 'session-worktree-transition',
@@ -3147,15 +3761,10 @@ describe('telling concurrent agents apart', () => {
3147
3761
  }),
3148
3762
  'claude-code',
3149
3763
  )
3150
- const retirement = h.recorder.submitted.find(
3151
- (entry) => entry.draft.event === 'question_retired',
3152
- )
3153
- expect(retirement?.draft.source).toEqual({
3154
- session_id: 'session-worktree-transition',
3155
- session_label: 'Worktree transition',
3156
- harness: 'claude-code',
3157
- branch: 'main',
3158
- })
3764
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3765
+ expect(
3766
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3767
+ ).toHaveLength(0)
3159
3768
  } finally {
3160
3769
  runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
3161
3770
  }
@@ -3211,12 +3820,10 @@ describe('telling concurrent agents apart', () => {
3211
3820
  stdin({ session_id: 'sess-abc', stop_hook_active: true }),
3212
3821
  )
3213
3822
 
3214
- const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
3215
- expect(retirement?.draft.source).toEqual({
3216
- session_id: 'sess-abc',
3217
- session_label: 'Semantic session',
3218
- harness: 'claude-code',
3219
- })
3823
+ expect(h.recorder.closed).toContain(h.recorder.receipts[0])
3824
+ expect(
3825
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
3826
+ ).toHaveLength(0)
3220
3827
  })
3221
3828
 
3222
3829
  it('preserves a human session label without displaying the opaque id', async () => {
@@ -3342,7 +3949,10 @@ describe('durable state writes', () => {
3342
3949
 
3343
3950
  writeSessionState('truncated', h.env, { last_prompt_at: NOW })
3344
3951
 
3345
- expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ last_prompt_at: NOW })
3952
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
3953
+ last_prompt_at: NOW,
3954
+ session_id: 'truncated',
3955
+ })
3346
3956
  expect(readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp'))).toEqual([])
3347
3957
  })
3348
3958
 
@@ -3394,7 +4004,7 @@ describe('durable state writes', () => {
3394
4004
  expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
3395
4005
  })
3396
4006
 
3397
- it('persists each successful retirement before a later corrupt entry interrupts the drain', async () => {
4007
+ it('persists each successful retirement before a later close failure interrupts the drain', async () => {
3398
4008
  const h = harness()
3399
4009
  writeSessionState('partial-drain', h.env, {
3400
4010
  retiring: [
@@ -3408,24 +4018,30 @@ describe('durable state writes', () => {
3408
4018
  {
3409
4019
  request_id: 'req_bad',
3410
4020
  collapse_key: 'collapse-bad',
3411
- device_ids: [],
4021
+ device_ids: ['dev_iphone'],
3412
4022
  question: 'Second?',
3413
4023
  state: 'expired',
3414
4024
  },
3415
4025
  ],
3416
4026
  })
4027
+ const base = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer x')
3417
4028
  const ctx = {
3418
- client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'),
4029
+ client: {
4030
+ ...base,
4031
+ closeReplies: async (requestId: string) => {
4032
+ if (requestId === 'req_bad') throw new Error('offline')
4033
+ return base.closeReplies(requestId)
4034
+ },
4035
+ },
3419
4036
  config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
3420
4037
  }
3421
4038
 
3422
- await expect(drainRetirements(ctx, 'partial-drain', h.env)).rejects.toThrow(
3423
- /missing its device identifiers/,
3424
- )
4039
+ await drainRetirements(ctx, 'partial-drain', h.env)
3425
4040
 
3426
4041
  expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
3427
4042
  'req_bad',
3428
4043
  ])
4044
+ expect(h.recorder.closed).toEqual(['req_good'])
3429
4045
  })
3430
4046
  })
3431
4047
 
@@ -3748,14 +4364,10 @@ describe('Claude Code Stop wake route', () => {
3748
4364
  expect(wake.sent).toHaveLength(1)
3749
4365
  expect(wake.resumed).toEqual([])
3750
4366
  expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
3751
- // Settled, not merely dropped: the answered question is retired truthfully.
4367
+ expect(h.recorder.closed).toContain('req_existing')
3752
4368
  expect(
3753
- h.recorder.submitted.find(
3754
- (entry) =>
3755
- entry.draft.event === 'question_retired' &&
3756
- entry.draft.lifecycle?.retires_request_id === 'req_existing',
3757
- )?.draft.lifecycle?.state,
3758
- ).toBe('answered')
4369
+ h.recorder.submitted.filter((entry) => isRetirementSubmit(entry)),
4370
+ ).toHaveLength(0)
3759
4371
  })
3760
4372
 
3761
4373
  it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
@@ -3783,7 +4395,7 @@ describe('Claude Code Stop wake route', () => {
3783
4395
  'claude-code',
3784
4396
  )
3785
4397
 
3786
- expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
4398
+ expect(h.recorder.submitted.filter((entry) => isQuestionSubmit(entry))).toEqual(
3787
4399
  [],
3788
4400
  )
3789
4401
  expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')