@raidiant/notifai 4.0.0 → 6.0.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 (140) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +7 -2
  3. package/dist/client.d.ts +8 -3
  4. package/dist/client.js +24 -3
  5. package/dist/client.js.map +1 -1
  6. package/dist/commands-auth.d.ts +14 -0
  7. package/dist/commands-auth.js +154 -0
  8. package/dist/commands-auth.js.map +1 -0
  9. package/dist/commands-config.d.ts +31 -0
  10. package/dist/commands-config.js +246 -0
  11. package/dist/commands-config.js.map +1 -0
  12. package/dist/commands-core.d.ts +102 -0
  13. package/dist/commands-core.js +135 -0
  14. package/dist/commands-core.js.map +1 -0
  15. package/dist/commands-devices.d.ts +31 -0
  16. package/dist/commands-devices.js +124 -0
  17. package/dist/commands-devices.js.map +1 -0
  18. package/dist/commands-doctor.d.ts +24 -0
  19. package/dist/commands-doctor.js +1011 -0
  20. package/dist/commands-doctor.js.map +1 -0
  21. package/dist/commands-harness-context.d.ts +61 -0
  22. package/dist/commands-harness-context.js +93 -0
  23. package/dist/commands-harness-context.js.map +1 -0
  24. package/dist/commands-hook-shape.d.ts +17 -0
  25. package/dist/commands-hook-shape.js +37 -0
  26. package/dist/commands-hook-shape.js.map +1 -0
  27. package/dist/commands-hooks.d.ts +115 -0
  28. package/dist/commands-hooks.js +1225 -0
  29. package/dist/commands-hooks.js.map +1 -0
  30. package/dist/commands-init.d.ts +35 -0
  31. package/dist/commands-init.js +580 -0
  32. package/dist/commands-init.js.map +1 -0
  33. package/dist/commands-io.d.ts +2 -0
  34. package/dist/commands-io.js +110 -0
  35. package/dist/commands-io.js.map +1 -0
  36. package/dist/commands-logs.d.ts +30 -0
  37. package/dist/commands-logs.js +201 -0
  38. package/dist/commands-logs.js.map +1 -0
  39. package/dist/commands-send-support.d.ts +44 -0
  40. package/dist/commands-send-support.js +180 -0
  41. package/dist/commands-send-support.js.map +1 -0
  42. package/dist/commands-send.d.ts +33 -0
  43. package/dist/commands-send.js +519 -0
  44. package/dist/commands-send.js.map +1 -0
  45. package/dist/commands-setup-proof.d.ts +15 -0
  46. package/dist/commands-setup-proof.js +53 -0
  47. package/dist/commands-setup-proof.js.map +1 -0
  48. package/dist/commands-skill.d.ts +10 -0
  49. package/dist/commands-skill.js +71 -0
  50. package/dist/commands-skill.js.map +1 -0
  51. package/dist/commands.d.ts +12 -378
  52. package/dist/commands.js +12 -4420
  53. package/dist/commands.js.map +1 -1
  54. package/dist/config-schema.js +15 -6
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +4 -2
  57. package/dist/config.js +13 -0
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.js +13 -3
  60. package/dist/credentials.js.map +1 -1
  61. package/dist/file-lock.d.ts +6 -1
  62. package/dist/file-lock.js +55 -9
  63. package/dist/file-lock.js.map +1 -1
  64. package/dist/harnesses.d.ts +1 -1
  65. package/dist/harnesses.js.map +1 -1
  66. package/dist/hooks.d.ts +74 -7
  67. package/dist/hooks.js +522 -166
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/install-hooks.d.ts +91 -14
  70. package/dist/install-hooks.js +226 -34
  71. package/dist/install-hooks.js.map +1 -1
  72. package/dist/interactive.d.ts +1 -1
  73. package/dist/interactive.js +5 -5
  74. package/dist/interactive.js.map +1 -1
  75. package/dist/logging.d.ts +8 -0
  76. package/dist/logging.js +9 -0
  77. package/dist/logging.js.map +1 -1
  78. package/dist/main.js +45 -18
  79. package/dist/main.js.map +1 -1
  80. package/dist/opencode-plugin.d.ts +8 -0
  81. package/dist/opencode-plugin.js +24 -162
  82. package/dist/opencode-plugin.js.map +1 -1
  83. package/dist/readiness.d.ts +3 -1
  84. package/dist/readiness.js.map +1 -1
  85. package/dist/send.d.ts +6 -0
  86. package/dist/send.js +25 -14
  87. package/dist/send.js.map +1 -1
  88. package/dist/ui/help.d.ts +0 -1
  89. package/dist/ui/help.js +6 -3
  90. package/dist/ui/help.js.map +1 -1
  91. package/dist/ui/theme.d.ts +0 -15
  92. package/dist/ui/theme.js +2 -18
  93. package/dist/ui/theme.js.map +1 -1
  94. package/package.json +2 -2
  95. package/src/client.test.ts +90 -1
  96. package/src/client.ts +35 -2
  97. package/src/commands-auth.ts +174 -0
  98. package/src/commands-config.ts +307 -0
  99. package/src/commands-core.ts +240 -0
  100. package/src/commands-devices.ts +150 -0
  101. package/src/commands-doctor.ts +1209 -0
  102. package/src/commands-harness-context.ts +126 -0
  103. package/src/commands-hook-shape.ts +53 -0
  104. package/src/commands-hooks.ts +1586 -0
  105. package/src/commands-init.ts +721 -0
  106. package/src/commands-io.ts +108 -0
  107. package/src/commands-logs.ts +243 -0
  108. package/src/commands-send-support.ts +224 -0
  109. package/src/commands-send.ts +623 -0
  110. package/src/commands-setup-proof.ts +71 -0
  111. package/src/commands-skill.ts +84 -0
  112. package/src/commands.test.ts +677 -89
  113. package/src/commands.ts +58 -5472
  114. package/src/config-schema.test.ts +1 -1
  115. package/src/config-schema.ts +17 -6
  116. package/src/config.test.ts +50 -9
  117. package/src/config.ts +19 -1
  118. package/src/credentials.test.ts +25 -1
  119. package/src/credentials.ts +13 -3
  120. package/src/file-lock.test.ts +41 -0
  121. package/src/file-lock.ts +59 -9
  122. package/src/harnesses.test.ts +37 -29
  123. package/src/harnesses.ts +1 -4
  124. package/src/hooks.test.ts +703 -26
  125. package/src/hooks.ts +590 -182
  126. package/src/install-hooks.test.ts +232 -12
  127. package/src/install-hooks.ts +241 -37
  128. package/src/interactive.test.ts +19 -1
  129. package/src/interactive.ts +5 -5
  130. package/src/logging-process-worker.test.ts +8 -2
  131. package/src/logging.test.ts +48 -20
  132. package/src/logging.ts +12 -0
  133. package/src/main.ts +58 -24
  134. package/src/opencode-plugin.ts +24 -164
  135. package/src/question-caps.test.ts +49 -0
  136. package/src/readiness.ts +3 -1
  137. package/src/send.ts +32 -16
  138. package/src/skill.test.ts +199 -146
  139. package/src/ui/help.ts +8 -3
  140. package/src/ui/theme.ts +2 -16
package/src/hooks.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn, type ChildProcess } from 'node:child_process'
1
+ import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
2
2
  import {
3
3
  existsSync,
4
4
  mkdirSync,
@@ -49,6 +49,7 @@ import {
49
49
  drainOrphanRetirements,
50
50
  runEscalationWaiter,
51
51
  MAX_CONTINUATION_COUNT,
52
+ MAX_HELD_DELIVERIES,
52
53
  orphanRetirements,
53
54
  pruneAbandonedSessions,
54
55
  releaseQuestionPush,
@@ -60,7 +61,15 @@ import {
60
61
  writeSessionState as persistSessionState,
61
62
  type PendingQuestion,
62
63
  type SessionState,
64
+ waiterCeilingSeconds,
65
+ WAITER_CEILING_SECONDS,
66
+ DETACHED_WAITER_CEILING_SECONDS,
63
67
  } from './hooks.js'
68
+ import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
69
+ import {
70
+ BLOCKING_STOP_TIMEOUT_SECONDS,
71
+ CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
72
+ } from './install-hooks.js'
64
73
 
65
74
  /** New-format test fixtures always carry the canonical body explicitly. */
66
75
  function registerQuestion(
@@ -126,6 +135,10 @@ interface Recorder {
126
135
  /** Per-request answer stream for multi-question ownership tests. */
127
136
  repliesFor?: (requestId: string) => ReplyView[]
128
137
  acknowledgementRequiredFor?: (requestId: string) => boolean
138
+ /** The window the server commits to, when a test needs one unlike the waiter's. */
139
+ replyExpiresAt?: string
140
+ /** Whether that request's acknowledgement must carry text; default true. */
141
+ acknowledgementTextRequiredFor?: (requestId: string) => boolean
129
142
  acknowledged?: Set<string>
130
143
  acknowledgementChecks?: string[]
131
144
  }
@@ -154,7 +167,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
154
167
  platform: 'ios' as const,
155
168
  permission_status: 'authorized',
156
169
  registration_healthy: true,
157
- reply_protocol_version: 2,
170
+ capabilities: ['answer'],
171
+ derived_status: 'working',
158
172
  last_seen_at: null,
159
173
  },
160
174
  {
@@ -163,7 +177,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
163
177
  platform: 'macos' as const,
164
178
  permission_status: 'authorized',
165
179
  registration_healthy: true,
166
- reply_protocol_version: 2,
180
+ capabilities: ['answer'],
181
+ derived_status: 'working',
167
182
  last_seen_at: null,
168
183
  },
169
184
  ],
@@ -176,7 +191,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
176
191
  return {
177
192
  status: 'recorded',
178
193
  agent_acknowledgement: {
179
- text: body.text,
194
+ text: body.text ?? '',
180
195
  created_at: new Date(NOW).toISOString(),
181
196
  },
182
197
  }
@@ -188,6 +203,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
188
203
  return {
189
204
  request_id: requestId,
190
205
  agent_acknowledgement_required: required,
206
+ agent_acknowledgement_text_required:
207
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
191
208
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
192
209
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
193
210
  : null,
@@ -207,9 +224,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
207
224
  recorder.receipts.push(requestId)
208
225
  return {
209
226
  request_id: requestId,
210
- reply_expires_at: new Date(NOW + 480_000).toISOString(),
227
+ reply_expires_at: recorder.replyExpiresAt ?? new Date(NOW + 480_000).toISOString(),
211
228
  agent_acknowledgement_required:
212
229
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
230
+ agent_acknowledgement_text_required:
231
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
213
232
  replayed: false,
214
233
  overall: 'provider_accepted_all',
215
234
  deliveries: [],
@@ -223,6 +242,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
223
242
  reply_expires_at: null,
224
243
  agent_acknowledgement_required:
225
244
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
245
+ agent_acknowledgement_text_required:
246
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
226
247
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
227
248
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
228
249
  : null,
@@ -236,6 +257,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
236
257
  reply_expires_at: new Date(NOW).toISOString(),
237
258
  agent_acknowledgement_required:
238
259
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
260
+ agent_acknowledgement_text_required:
261
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
239
262
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
240
263
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
241
264
  : null,
@@ -316,6 +339,23 @@ function harness(replies: ReplyView[] = []): Harness {
316
339
  }
317
340
  }
318
341
 
342
+ function runFixtureGit(cwd: string, ...args: string[]): string {
343
+ return execFileSync('git', ['-C', cwd, ...args], {
344
+ encoding: 'utf8',
345
+ stdio: ['ignore', 'pipe', 'pipe'],
346
+ }).trim()
347
+ }
348
+
349
+ function initializeFixtureRepository(root: string, branch = 'branch-a'): void {
350
+ runFixtureGit(root, 'init')
351
+ runFixtureGit(root, 'config', 'user.email', 'notifai-tests@example.invalid')
352
+ runFixtureGit(root, 'config', 'user.name', 'Notifai Tests')
353
+ writeFileSync(path.join(root, 'tracked.txt'), 'fixture\n')
354
+ runFixtureGit(root, 'add', 'tracked.txt')
355
+ runFixtureGit(root, 'commit', '-m', 'fixture')
356
+ runFixtureGit(root, 'branch', '-M', branch)
357
+ }
358
+
319
359
  function stdin(payload: unknown): () => Promise<string> {
320
360
  return async () => JSON.stringify(payload)
321
361
  }
@@ -451,7 +491,48 @@ function writeGlobalConfig(h: Harness, toml: string): void {
451
491
  /** Long enough ago that no test is accidentally sensitive to the stamp. */
452
492
  const AWAY = NOW - 600_000
453
493
 
494
+ describe('the waiter wall clock', () => {
495
+ it('spends a held turn sparingly and a detached one generously', () => {
496
+ // A held turn is somebody's terminal, so the short ceiling is the cost of
497
+ // waiting. Detached, nothing is held, and the same number only shortens how
498
+ // long a direct wake stays possible.
499
+ expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
500
+ expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
501
+ expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
502
+ })
503
+
504
+ it('stays under the timeout the async Stop handler declares', () => {
505
+ // That declaration's kill is silent: past it the waiter vanishes and the
506
+ // answer the user already gave is never delivered.
507
+ expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
508
+ })
509
+
510
+ it('leaves the blocking host headroom above the waiter it declares for', () => {
511
+ expect(BLOCKING_STOP_TIMEOUT_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
512
+ })
513
+ })
514
+
454
515
  describe('pushing a registered question', () => {
516
+ it('keeps unknown future session keys through an older read-modify-write', () => {
517
+ const h = harness([])
518
+ const sessionId = 'future-session-format'
519
+ const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
520
+ mkdirSync(path.dirname(file), { recursive: true })
521
+ writeFileSync(
522
+ file,
523
+ `${JSON.stringify({
524
+ last_prompt_at: NOW,
525
+ future_session_key: { nested: ['kept'] },
526
+ })}\n`,
527
+ )
528
+
529
+ registerQuestion(sessionId, h.env, { question: 'Still preserved?' }, NOW)
530
+
531
+ const rewritten = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
532
+ expect(rewritten['future_session_key']).toEqual({ nested: ['kept'] })
533
+ expect(rewritten['pending']).toHaveLength(1)
534
+ })
535
+
455
536
  // Found by a live Claude Code session: a spawned agent always has a
456
537
  // just-set last_prompt_at, and the routing gate once read that as the user
457
538
  // being present — so its FIRST question could never escalate, which is the
@@ -471,7 +552,8 @@ describe('pushing a registered question', () => {
471
552
  title: 'Ship it?',
472
553
  body: 'Ship it?',
473
554
  })
474
- expect(h.recorder.submitted[0]?.draft.platform).toBeUndefined()
555
+ // A pushed question is a question, and a question has an attention tone.
556
+ expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
475
557
  })
476
558
 
477
559
  it('keeps sparse alt text paired with its original media item', async () => {
@@ -821,7 +903,7 @@ describe('terminal-first grace window', () => {
821
903
  ).toBeGreaterThanOrEqual(60)
822
904
  })
823
905
 
824
- it('never pushes a question to a Companion without the current reply protocol', async () => {
906
+ it('never pushes a question to a Companion that did not advertise answer', async () => {
825
907
  const h = harness([])
826
908
  const factory = h.deps.clientFactory
827
909
  h.deps.clientFactory = () => {
@@ -831,7 +913,7 @@ describe('terminal-first grace window', () => {
831
913
  listDevices: async () => ({
832
914
  devices: (await client.listDevices()).devices.map((device) => ({
833
915
  ...device,
834
- reply_protocol_version: null,
916
+ capabilities: [],
835
917
  })),
836
918
  }),
837
919
  } as ApiClient
@@ -1045,6 +1127,12 @@ describe('late answer collection', () => {
1045
1127
 
1046
1128
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
1047
1129
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
1130
+ // The obligation survives the user typing — that is the whole point of
1131
+ // recording it — so the turn after the answer is held for it. Satisfy it
1132
+ // the way the agent would, then the retirement proceeds.
1133
+ expect(readSessionState('late-prompt', h.env).acknowledgement_due).toHaveLength(1)
1134
+ h.recorder.acknowledged = new Set(['req_live'])
1135
+
1048
1136
  await hookRunCommand(
1049
1137
  h.deps,
1050
1138
  'stop',
@@ -1216,10 +1304,66 @@ describe('the waiter owning one question to the end', () => {
1216
1304
  expect(h.recorder.submitted[0]).toEqual(firstAttempt)
1217
1305
  })
1218
1306
 
1219
- it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
1307
+ it('remints a frozen draft after a terminal 422 instead of replaying it', async () => {
1308
+ const h = harness([])
1309
+ const factory = h.deps.clientFactory
1310
+ h.deps.clientFactory = () => {
1311
+ const client = factory!()
1312
+ return {
1313
+ ...client,
1314
+ submit: async () => {
1315
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1316
+ },
1317
+ } as ApiClient
1318
+ }
1319
+ writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1320
+ registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1321
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1322
+ const frozen = readSessionState('rejected-submit', h.env).pending?.[0]?.submission
1323
+ if (frozen === undefined) throw new Error('expected a persisted submission intent')
1324
+
1325
+ let submits = 0
1326
+ h.deps.clientFactory = () => {
1327
+ const client = factory!()
1328
+ return {
1329
+ ...client,
1330
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
1331
+ submits += 1
1332
+ if (body.request_id === frozen.request_id) {
1333
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.', null, [
1334
+ { path: 'presentation.detail', message: 'Unexpected property' },
1335
+ ])
1336
+ }
1337
+ return client.submit(body, waitSeconds)
1338
+ },
1339
+ } as ApiClient
1340
+ }
1341
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1342
+
1343
+ expect(submits).toBe(2)
1344
+ expect(h.recorder.submitted[0]?.request_id).toBeDefined()
1345
+ expect(h.recorder.submitted[0]?.request_id).not.toBe(frozen.request_id)
1346
+ expect(h.io.errLines.join('\n')).toContain('reminting the draft')
1347
+ expect(readSessionState('rejected-submit', h.env).pending).toBeUndefined()
1348
+ })
1349
+
1350
+ it('retires a reminted draft that the server still rejects', async () => {
1220
1351
  const h = harness([])
1221
1352
  const factory = h.deps.clientFactory
1222
- let polls = 0
1353
+ h.deps.clientFactory = () => {
1354
+ const client = factory!()
1355
+ return {
1356
+ ...client,
1357
+ submit: async () => {
1358
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1359
+ },
1360
+ } as ApiClient
1361
+ }
1362
+ writeSessionState('rejected-forever', h.env, { last_prompt_at: AWAY })
1363
+ registerQuestion('rejected-forever', h.env, { question: 'Deploy?' }, NOW)
1364
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1365
+ expect(readSessionState('rejected-forever', h.env).pending?.[0]?.submission).toBeDefined()
1366
+
1223
1367
  h.deps.clientFactory = () => {
1224
1368
  const client = factory!()
1225
1369
  return {
@@ -1227,29 +1371,72 @@ describe('the waiter owning one question to the end', () => {
1227
1371
  submit: async () => {
1228
1372
  throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1229
1373
  },
1374
+ } as ApiClient
1375
+ }
1376
+ h.advanceClock(600_000)
1377
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1378
+ expect(readSessionState('rejected-forever', h.env).pending).toBeUndefined()
1379
+
1380
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1381
+ expect(h.recorder.submitted).toEqual([])
1382
+ expect(h.io.errLines.join('\n')).toContain('retiring the question so it is not retried forever')
1383
+ })
1384
+
1385
+ it('retires a freshly minted draft the current contract rejects', async () => {
1386
+ const h = harness([])
1387
+ const factory = h.deps.clientFactory
1388
+ h.deps.clientFactory = () => {
1389
+ const client = factory!()
1390
+ return {
1391
+ ...client,
1392
+ submit: async () => {
1393
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1394
+ },
1395
+ } as ApiClient
1396
+ }
1397
+ writeSessionState('fresh-422', h.env, { last_prompt_at: AWAY })
1398
+ registerQuestion('fresh-422', h.env, { question: 'Deploy?' }, NOW)
1399
+
1400
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fresh-422' }))
1401
+
1402
+ expect(readSessionState('fresh-422', h.env).pending).toBeUndefined()
1403
+ expect(h.io.errLines.join('\n')).toContain('retiring it because the current draft will never be accepted')
1404
+ })
1405
+
1406
+ it('preserves a frozen intent after a retryable client rejection', async () => {
1407
+ const h = harness([])
1408
+ const factory = h.deps.clientFactory
1409
+ let polls = 0
1410
+ h.deps.clientFactory = () => {
1411
+ const client = factory!()
1412
+ return {
1413
+ ...client,
1414
+ submit: async () => {
1415
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1416
+ },
1230
1417
  replies: async () => {
1231
1418
  polls += 1
1232
1419
  throw new Error('must not poll an unaccepted request')
1233
1420
  },
1234
1421
  } as ApiClient
1235
1422
  }
1236
- writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1237
- registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1423
+ writeSessionState('auth-submit', h.env, { last_prompt_at: AWAY })
1424
+ registerQuestion('auth-submit', h.env, { question: 'Deploy?' }, NOW)
1238
1425
 
1239
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1426
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
1240
1427
 
1241
- const pending = readSessionState('rejected-submit', h.env).pending?.[0]
1428
+ const pending = readSessionState('auth-submit', h.env).pending?.[0]
1242
1429
  expect(pending?.request_id).toBeUndefined()
1243
1430
  expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1244
1431
  expect(polls).toBe(0)
1245
- expect(h.io.errLines.join('\n')).toContain('submission was rejected')
1432
+ expect(h.io.errLines.join('\n')).toContain('preserving it for recovery')
1246
1433
 
1247
1434
  const frozen = pending?.submission
1248
1435
  if (frozen === undefined) throw new Error('expected a persisted submission intent')
1249
1436
  writeGlobalConfig(h, 'project = "changed-after-crash"\n')
1250
1437
  h.advanceClock(600_000)
1251
1438
  h.deps.clientFactory = factory
1252
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1439
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
1253
1440
 
1254
1441
  expect(h.recorder.submitted[0]).toEqual({
1255
1442
  request_id: frozen.request_id,
@@ -1284,7 +1471,7 @@ describe('the waiter owning one question to the end', () => {
1284
1471
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
1285
1472
  })
1286
1473
 
1287
- it('charges sequential submission latency to one deadline', async () => {
1474
+ it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
1288
1475
  const h = harness([reply({ text: 'Done' })])
1289
1476
  writeSessionState('latency', h.env, { last_prompt_at: AWAY })
1290
1477
  registerQuestion('latency', h.env, { question: 'First?' }, NOW)
@@ -1296,9 +1483,12 @@ describe('the waiter owning one question to the end', () => {
1296
1483
  const windows = h.recorder.submitted
1297
1484
  .filter((entry) => entry.draft.event === 'agent_question')
1298
1485
  .map((entry) => entry.draft.reply?.expires_in_seconds)
1299
- // Both windows end at the same absolute moment: the second question's is
1300
- // shorter by exactly the 40s the first submission spent.
1301
- expect(windows).toEqual([480, 440])
1486
+ // How long the user may answer does not shrink because this owner spent
1487
+ // part of its own budget getting the question out. Both questions stay
1488
+ // answerable for the configured window.
1489
+ expect(windows).toEqual([86_400, 86_400])
1490
+ // The owner's own budget is still one shared deadline, and it is still
1491
+ // charged for the latency: the whole pass fits inside the waiter ceiling.
1302
1492
  expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
1303
1493
  })
1304
1494
 
@@ -1763,7 +1953,7 @@ describe('several questions in flight', () => {
1763
1953
 
1764
1954
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
1765
1955
  expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
1766
- { request_id: 'req_ack_required', recorded_at: NOW },
1956
+ { request_id: 'req_ack_required', recorded_at: NOW, text_required: true },
1767
1957
  ])
1768
1958
 
1769
1959
  h.io.outLines = []
@@ -1783,6 +1973,66 @@ describe('several questions in flight', () => {
1783
1973
  expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
1784
1974
  })
1785
1975
 
1976
+ it('still blocks Stop, without asking for text, when the account turned text off', async () => {
1977
+ const h = harness([reply({ text: 'Ship it' })])
1978
+ h.recorder.acknowledgementTextRequiredFor = () => false
1979
+ writeSessionState('ack-textless', h.env, {
1980
+ pending: [
1981
+ {
1982
+ question: 'Ship it?',
1983
+ request_id: 'req_ack_textless',
1984
+ collapse_key: 'collapse-ack-textless',
1985
+ device_ids: ['dev_iphone'],
1986
+ reply_deadline_at: NOW + 60_000,
1987
+ },
1988
+ ],
1989
+ })
1990
+
1991
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-textless' }))
1992
+ // The obligation is unchanged by the preference: the acknowledgement still
1993
+ // has to happen, because the user's read-state depends on it.
1994
+ expect(readSessionState('ack-textless', h.env).acknowledgement_due).toEqual([
1995
+ { request_id: 'req_ack_textless', recorded_at: NOW, text_required: false },
1996
+ ])
1997
+
1998
+ h.io.outLines = []
1999
+ await hookRunCommand(
2000
+ h.deps,
2001
+ 'stop',
2002
+ stdin({ session_id: 'ack-textless', stop_hook_active: true }),
2003
+ )
2004
+
2005
+ const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
2006
+ expect(blocked.decision).toBe('block')
2007
+ expect(blocked.reason).toContain('notifai acknowledge req_ack_textless`')
2008
+ expect(blocked.reason).not.toContain('--text')
2009
+ })
2010
+
2011
+ it('gives up holding the turn rather than wedging the session for ever', async () => {
2012
+ // The acknowledgement gate is the one place hooks break the fail-open rule.
2013
+ // Unbounded, an agent that never acknowledges — or a server that stays
2014
+ // unreachable, since an error counts as unresolved — held every turn of
2015
+ // this session for good.
2016
+ const h = harness([])
2017
+ writeSessionState('ack-wedge', h.env, {
2018
+ acknowledgement_due: [{ request_id: 'req_wedge', recorded_at: NOW }],
2019
+ })
2020
+
2021
+ const blocked: unknown[] = []
2022
+ for (let turn = 0; turn < 3; turn += 1) {
2023
+ h.io.outLines.length = 0
2024
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
2025
+ blocked.push(h.io.outLines.length > 0)
2026
+ }
2027
+ expect(blocked).toEqual([true, true, true])
2028
+
2029
+ // The fourth turn is let through, and the obligation stops being owed.
2030
+ h.io.outLines.length = 0
2031
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
2032
+ expect(h.io.outLines).toEqual([])
2033
+ expect(readSessionState('ack-wedge', h.env).acknowledgement_due).toBeUndefined()
2034
+ })
2035
+
1786
2036
  it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
1787
2037
  const h = harness([])
1788
2038
  writeSessionState('ack-heal', h.env, {
@@ -1953,7 +2203,7 @@ describe('a question that outlives its session', () => {
1953
2203
  expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
1954
2204
  })
1955
2205
 
1956
- it('gives up on an orphan older than a day instead of queueing it for ever', async () => {
2206
+ it('gives up on an orphan that outlived the longest answerable window', async () => {
1957
2207
  const h = harness([])
1958
2208
  orphanRetirements(
1959
2209
  h.env,
@@ -1964,7 +2214,7 @@ describe('a question that outlives its session', () => {
1964
2214
  question: 'Old?',
1965
2215
  state: 'expired',
1966
2216
  }],
1967
- NOW - 25 * 3600 * 1000,
2217
+ NOW - (REPLY_MAX_WINDOW_SECONDS + 2 * 3600) * 1000,
1968
2218
  )
1969
2219
  const drained = await drainOrphanRetirements(
1970
2220
  { client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
@@ -2427,6 +2677,58 @@ describe('user-prompt-submit hook', () => {
2427
2677
  clearSessionState('s14', h.env)
2428
2678
  expect(readProjectSession(project, h.env, NOW)).toBeNull()
2429
2679
  })
2680
+
2681
+ it('round-trips future project-pointer fields while publishing a session', () => {
2682
+ const h = harness()
2683
+ const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
2684
+ const futureEntry = {
2685
+ format: 2,
2686
+ session: { id: 'future-session', runtime: 'future-harness' },
2687
+ }
2688
+ mkdirSync(path.dirname(pointer), { recursive: true })
2689
+ writeFileSync(
2690
+ pointer,
2691
+ `${JSON.stringify({
2692
+ future_root: { retained: true },
2693
+ sessions: [
2694
+ {
2695
+ session_id: 'still-running',
2696
+ updated_at: NOW - 1,
2697
+ harness: 'codex',
2698
+ future_entry: { retained: true },
2699
+ },
2700
+ {
2701
+ session_id: 'new-session',
2702
+ updated_at: NOW - 2,
2703
+ harness: 'claude-code',
2704
+ future_current_entry: { retained: true },
2705
+ },
2706
+ futureEntry,
2707
+ ],
2708
+ })}\n`,
2709
+ )
2710
+
2711
+ writeProjectSession(h.deps.cwd, h.env, 'new-session', NOW, 'claude-code')
2712
+
2713
+ expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
2714
+ future_root: { retained: true },
2715
+ sessions: [
2716
+ {
2717
+ session_id: 'still-running',
2718
+ updated_at: NOW - 1,
2719
+ harness: 'codex',
2720
+ future_entry: { retained: true },
2721
+ },
2722
+ futureEntry,
2723
+ {
2724
+ session_id: 'new-session',
2725
+ updated_at: NOW,
2726
+ harness: 'claude-code',
2727
+ future_current_entry: { retained: true },
2728
+ },
2729
+ ],
2730
+ })
2731
+ })
2430
2732
  })
2431
2733
 
2432
2734
  describe('session-end hook', () => {
@@ -2445,6 +2747,36 @@ describe('session-end hook', () => {
2445
2747
  expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
2446
2748
  })
2447
2749
 
2750
+ it('round-trips future session-state fields when SessionEnd preserves an obligation', async () => {
2751
+ const h = harness()
2752
+ const sessionId = 'session-end-future-state'
2753
+ const stateFile = path.join(
2754
+ stateDir(h.env),
2755
+ 'sessions',
2756
+ `${sanitizeSessionId(sessionId)}.json`,
2757
+ )
2758
+ const acknowledgement = {
2759
+ request_id: 'req_future_ack',
2760
+ recorded_at: NOW,
2761
+ future_entry: { retained: true },
2762
+ }
2763
+ mkdirSync(path.dirname(stateFile), { recursive: true })
2764
+ writeFileSync(
2765
+ stateFile,
2766
+ `${JSON.stringify({
2767
+ future_root: { retained: true },
2768
+ acknowledgement_due: [acknowledgement],
2769
+ })}\n`,
2770
+ )
2771
+
2772
+ await hookRunCommand(h.deps, 'session-end', stdin({ session_id: sessionId }))
2773
+
2774
+ expect(JSON.parse(readFileSync(stateFile, 'utf8'))).toEqual({
2775
+ future_root: { retained: true },
2776
+ acknowledgement_due: [acknowledgement],
2777
+ })
2778
+ })
2779
+
2448
2780
  it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
2449
2781
  const h = harness()
2450
2782
  const sessionId = 'session-end-budget'
@@ -2561,6 +2893,54 @@ describe('session-end hook', () => {
2561
2893
  ).toEqual({ sessionId: 'still-running', harness: 'codex' })
2562
2894
  })
2563
2895
 
2896
+ it('keeps future project-pointer data when clearing the ending session', async () => {
2897
+ const h = harness()
2898
+ const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
2899
+ const futureEntry = {
2900
+ format: 2,
2901
+ session: { id: 'future-session', runtime: 'future-harness' },
2902
+ }
2903
+ writeSessionState('still-running', h.env, { last_prompt_at: NOW })
2904
+ writeSessionState('ending', h.env, { last_prompt_at: NOW })
2905
+ mkdirSync(path.dirname(pointer), { recursive: true })
2906
+ writeFileSync(
2907
+ pointer,
2908
+ `${JSON.stringify({
2909
+ future_root: { retained: true },
2910
+ sessions: [
2911
+ {
2912
+ session_id: 'still-running',
2913
+ updated_at: NOW - 1,
2914
+ harness: 'codex',
2915
+ future_entry: { retained: true },
2916
+ },
2917
+ futureEntry,
2918
+ { session_id: 'ending', updated_at: NOW, harness: 'claude-code' },
2919
+ ],
2920
+ })}\n`,
2921
+ )
2922
+
2923
+ await hookRunCommand(
2924
+ h.deps,
2925
+ 'session-end',
2926
+ stdin({ session_id: 'ending', cwd: h.deps.cwd }),
2927
+ 'claude-code',
2928
+ )
2929
+
2930
+ expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
2931
+ future_root: { retained: true },
2932
+ sessions: [
2933
+ {
2934
+ session_id: 'still-running',
2935
+ updated_at: NOW - 1,
2936
+ harness: 'codex',
2937
+ future_entry: { retained: true },
2938
+ },
2939
+ futureEntry,
2940
+ ],
2941
+ })
2942
+ })
2943
+
2564
2944
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
2565
2945
  const h = harness()
2566
2946
  writeSessionState('s-incomplete', h.env, {
@@ -2592,6 +2972,164 @@ describe('malformed input', () => {
2592
2972
  })
2593
2973
 
2594
2974
  describe('telling concurrent agents apart', () => {
2975
+ it('uses each hook event branch while keeping one immutable session identity', async () => {
2976
+ const h = harness([reply({ text: 'Yes' })])
2977
+ initializeFixtureRepository(h.deps.cwd)
2978
+ registerQuestion(
2979
+ 'session-branch-transition',
2980
+ h.env,
2981
+ {
2982
+ question: 'Ship it?',
2983
+ source: {
2984
+ session_id: 'session-branch-transition',
2985
+ session_label: 'Branch transition',
2986
+ harness: 'claude-code',
2987
+ branch: 'branch-a',
2988
+ },
2989
+ },
2990
+ NOW,
2991
+ )
2992
+
2993
+ runFixtureGit(h.deps.cwd, 'checkout', '-b', 'branch-b')
2994
+ await hookRunCommand(
2995
+ h.deps,
2996
+ 'stop',
2997
+ stdin({ session_id: 'session-branch-transition', cwd: h.deps.cwd }),
2998
+ 'claude-code',
2999
+ )
3000
+
3001
+ const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3002
+ expect(question?.draft.source).toEqual({
3003
+ session_id: 'session-branch-transition',
3004
+ session_label: 'Branch transition',
3005
+ harness: 'claude-code',
3006
+ branch: 'branch-b',
3007
+ })
3008
+
3009
+ runFixtureGit(h.deps.cwd, 'checkout', 'branch-a')
3010
+ h.recorder.acknowledged ??= new Set()
3011
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
3012
+ await hookRunCommand(
3013
+ h.deps,
3014
+ 'stop',
3015
+ stdin({
3016
+ session_id: 'session-branch-transition',
3017
+ cwd: h.deps.cwd,
3018
+ stop_hook_active: true,
3019
+ }),
3020
+ 'claude-code',
3021
+ )
3022
+
3023
+ const retirement = h.recorder.submitted.find(
3024
+ (entry) => entry.draft.event === 'question_retired',
3025
+ )
3026
+ expect(retirement?.draft.source).toEqual({
3027
+ session_id: 'session-branch-transition',
3028
+ session_label: 'Branch transition',
3029
+ harness: 'claude-code',
3030
+ branch: 'branch-a',
3031
+ })
3032
+ })
3033
+
3034
+ it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
3035
+ const h = harness([])
3036
+ initializeFixtureRepository(h.deps.cwd)
3037
+ registerQuestion(
3038
+ 'session-detached',
3039
+ h.env,
3040
+ {
3041
+ question: 'Ship it?',
3042
+ source: {
3043
+ session_id: 'session-detached',
3044
+ session_label: 'Detached review',
3045
+ harness: 'claude-code',
3046
+ branch: 'branch-a',
3047
+ worktree: 'stale-worktree',
3048
+ },
3049
+ },
3050
+ NOW,
3051
+ )
3052
+ runFixtureGit(h.deps.cwd, 'checkout', '--detach', 'HEAD')
3053
+
3054
+ await hookRunCommand(
3055
+ h.deps,
3056
+ 'stop',
3057
+ stdin({ session_id: 'session-detached', cwd: h.deps.cwd }),
3058
+ 'claude-code',
3059
+ )
3060
+
3061
+ const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
3062
+ expect(question?.draft.source).toEqual({
3063
+ session_id: 'session-detached',
3064
+ session_label: 'Detached review',
3065
+ harness: 'claude-code',
3066
+ })
3067
+ })
3068
+
3069
+ it('captures entry into and exit from a linked worktree on the same session', async () => {
3070
+ const h = harness([reply({ text: 'Yes' })])
3071
+ initializeFixtureRepository(h.deps.cwd, 'main')
3072
+ const linked = `${h.deps.cwd}-linked`
3073
+ runFixtureGit(h.deps.cwd, 'worktree', 'add', '-b', 'worktree-branch', linked)
3074
+ try {
3075
+ registerQuestion(
3076
+ 'session-worktree-transition',
3077
+ h.env,
3078
+ {
3079
+ question: 'Ship it?',
3080
+ source: {
3081
+ session_id: 'session-worktree-transition',
3082
+ session_label: 'Worktree transition',
3083
+ harness: 'claude-code',
3084
+ branch: 'main',
3085
+ },
3086
+ },
3087
+ NOW,
3088
+ )
3089
+
3090
+ await hookRunCommand(
3091
+ h.deps,
3092
+ 'stop',
3093
+ stdin({ session_id: 'session-worktree-transition', cwd: linked }),
3094
+ 'claude-code',
3095
+ )
3096
+ const question = h.recorder.submitted.find(
3097
+ (entry) => entry.draft.event === 'agent_question',
3098
+ )
3099
+ expect(question?.draft.source).toEqual({
3100
+ session_id: 'session-worktree-transition',
3101
+ session_label: 'Worktree transition',
3102
+ harness: 'claude-code',
3103
+ branch: 'worktree-branch',
3104
+ worktree: path.basename(linked),
3105
+ })
3106
+
3107
+ h.recorder.acknowledged ??= new Set()
3108
+ h.recorder.acknowledged.add(h.recorder.receipts[0]!)
3109
+ await hookRunCommand(
3110
+ h.deps,
3111
+ 'stop',
3112
+ stdin({
3113
+ session_id: 'session-worktree-transition',
3114
+ cwd: h.deps.cwd,
3115
+ stop_hook_active: true,
3116
+ }),
3117
+ 'claude-code',
3118
+ )
3119
+ const retirement = h.recorder.submitted.find(
3120
+ (entry) => entry.draft.event === 'question_retired',
3121
+ )
3122
+ expect(retirement?.draft.source).toEqual({
3123
+ session_id: 'session-worktree-transition',
3124
+ session_label: 'Worktree transition',
3125
+ harness: 'claude-code',
3126
+ branch: 'main',
3127
+ })
3128
+ } finally {
3129
+ runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
3130
+ }
3131
+ })
3132
+
2595
3133
  it('stamps structured Source Context on the question it pushes', async () => {
2596
3134
  const h = harness([])
2597
3135
  registerQuestion(
@@ -3561,6 +4099,47 @@ describe('escalation waiter delivery seam', () => {
3561
4099
  expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
3562
4100
  })
3563
4101
 
4102
+ it('does not spend the delivery cap on turns where nothing was handed over', async () => {
4103
+ // The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
4104
+ // where a liveness probe could not reach a busy session were enough to
4105
+ // settle an answer the user had already given, unread.
4106
+ const h = harness([])
4107
+ const sessionId = 'waiter-transient-holds'
4108
+ journaled(h, sessionId)
4109
+ const context = waiterContext(h)
4110
+ let handedOver = 0
4111
+ let pass = 0
4112
+
4113
+ for (; pass < MAX_CONTINUATION_COUNT + 2; pass += 1) {
4114
+ await runEscalationWaiter(context, {
4115
+ sessionId,
4116
+ envelope: { session_id: sessionId, stop_hook_active: false },
4117
+ route: {
4118
+ kind: 'inbox-socket',
4119
+ deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
4120
+ },
4121
+ processDeadlineAt: NOW + 480_000,
4122
+ })
4123
+ }
4124
+ // Still journaled after more holds than the delivery cap allows.
4125
+ expect(readSessionState(sessionId, h.env).accepted).toBeDefined()
4126
+
4127
+ // And when a route can finally take it, it is delivered rather than lost.
4128
+ await runEscalationWaiter(context, {
4129
+ sessionId,
4130
+ envelope: { session_id: sessionId, stop_hook_active: false },
4131
+ route: {
4132
+ kind: 'inbox-socket',
4133
+ deliver: async () => {
4134
+ handedOver += 1
4135
+ return { notes: [], acknowledgement: 'delivered' as const }
4136
+ },
4137
+ },
4138
+ processDeadlineAt: NOW + 480_000,
4139
+ })
4140
+ expect(handedOver).toBe(1)
4141
+ })
4142
+
3564
4143
  it('bounds deliveries by the continuation cap on every route', async () => {
3565
4144
  // A route that never acknowledges must still stop. The cap lives where all
3566
4145
  // routes pass, so no future route can be the one it does not cover.
@@ -3572,7 +4151,7 @@ describe('escalation waiter delivery seam', () => {
3572
4151
  let deliveries = 0
3573
4152
  const notes: string[] = []
3574
4153
 
3575
- for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
4154
+ for (let pass = 0; pass < MAX_HELD_DELIVERIES + 3; pass += 1) {
3576
4155
  const outcome = await runEscalationWaiter(context, {
3577
4156
  sessionId,
3578
4157
  envelope: { session_id: sessionId, stop_hook_active: false },
@@ -3588,9 +4167,11 @@ describe('escalation waiter delivery seam', () => {
3588
4167
  notes.push(...outcome.notes)
3589
4168
  }
3590
4169
 
3591
- expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
4170
+ // A hold is not a delivery — nothing was handed over — so it does not
4171
+ // spend the wake-loop cap. It still has to end, on its own looser bound.
4172
+ expect(deliveries).toBe(MAX_HELD_DELIVERIES)
3592
4173
  expect(notes.join('\n')).toContain(
3593
- `${MAX_CONTINUATION_COUNT} times without being acknowledged`,
4174
+ `could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns`,
3594
4175
  )
3595
4176
  expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
3596
4177
  }
@@ -3624,3 +4205,99 @@ describe('escalation waiter delivery seam', () => {
3624
4205
  releaseQuestionPush('waiter-failed-route', h.env)
3625
4206
  })
3626
4207
  })
4208
+
4209
+
4210
+ describe('session state across a prompt', () => {
4211
+ it('carries known and future session state across a typed prompt', async () => {
4212
+ // The prompt transition deliberately changes pending/retiring state and the
4213
+ // continuation count, but fields introduced by a newer CLI remain opaque.
4214
+ const h = harness([])
4215
+ const before = {
4216
+ last_prompt_at: AWAY,
4217
+ last_stop_at: NOW - 1_000,
4218
+ retiring: [
4219
+ {
4220
+ request_id: 'req_carry',
4221
+ collapse_key: 'collapse-carry',
4222
+ device_ids: ['dev_iphone'],
4223
+ question: 'Carried?',
4224
+ state: 'expired' as const,
4225
+ },
4226
+ ],
4227
+ continuation: { answered_at: NOW - 2_000, count: 2 },
4228
+ acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
4229
+ acknowledgement_blocks: 2,
4230
+ }
4231
+ const futureSessionValue = { nested: ['kept'], revision: 2 }
4232
+ writeSessionState('carry-all', h.env, {
4233
+ ...before,
4234
+ future_session_key: futureSessionValue,
4235
+ } as SessionState & { future_session_key: typeof futureSessionValue })
4236
+
4237
+ await hookRunCommand(
4238
+ h.deps,
4239
+ 'user-prompt-submit',
4240
+ stdin({ session_id: 'carry-all', cwd: h.deps.cwd }),
4241
+ )
4242
+
4243
+ const after = readSessionState('carry-all', h.env)
4244
+ for (const key of Object.keys(before) as (keyof typeof before)[]) {
4245
+ expect(after[key], `${key} did not survive the prompt`).toBeDefined()
4246
+ }
4247
+ const persisted = JSON.parse(
4248
+ readFileSync(
4249
+ path.join(
4250
+ stateDir(h.env),
4251
+ 'sessions',
4252
+ `${sanitizeSessionId('carry-all')}.json`,
4253
+ ),
4254
+ 'utf8',
4255
+ ),
4256
+ ) as Record<string, unknown>
4257
+ expect(persisted['future_session_key']).toEqual(futureSessionValue)
4258
+ // The one field a prompt resets rather than carries.
4259
+ expect(after.continuation?.count).toBe(0)
4260
+ expect(after.acknowledgement_blocks).toBe(2)
4261
+ })
4262
+ })
4263
+
4264
+ describe('a question outliving the waiter that pushed it', () => {
4265
+ it('records when the server stops accepting, not when this owner stops waiting', async () => {
4266
+ // These were the same number until the window became a setting, and taking
4267
+ // the minimum capped every question at the waiter's ceiling: a day-long
4268
+ // window was swept as stale about eight minutes later and retired, so the
4269
+ // answer given over lunch met a closed question.
4270
+ const h = harness([])
4271
+ const aDayOut = NOW + 86_400_000
4272
+ h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
4273
+ writeSessionState('outlive', h.env, { last_prompt_at: AWAY })
4274
+ registerQuestion('outlive', h.env, { question: 'Ship it?' }, NOW)
4275
+
4276
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
4277
+
4278
+ const state = readSessionState('outlive', h.env)
4279
+ const live = state.pending?.[0]
4280
+ // Still live, still carrying the server's window — not the waiter's.
4281
+ expect(live?.request_id).toBeDefined()
4282
+ expect(live?.reply_deadline_at).toBe(aDayOut)
4283
+ // Not closed and not retired: those are what capped it at eight minutes.
4284
+ expect(state.retiring).toBeUndefined()
4285
+ expect(h.recorder.closed).toEqual([])
4286
+ // And the waiter still stopped at its own ceiling rather than waiting a day.
4287
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
4288
+ })
4289
+
4290
+ it('still closes a question the server has genuinely stopped accepting', async () => {
4291
+ const h = harness([])
4292
+ h.recorder.replyExpiresAt = new Date(NOW + 90_000).toISOString()
4293
+ writeSessionState('short-window', h.env, { last_prompt_at: AWAY })
4294
+ registerQuestion('short-window', h.env, { question: 'Ship it?' }, NOW)
4295
+
4296
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
4297
+
4298
+ // Its window closed inside the waiter's ceiling, so it is retired here
4299
+ // rather than left for a turn that could never collect it.
4300
+ expect(readSessionState('short-window', h.env).pending).toBeUndefined()
4301
+ expect(h.recorder.closed.length).toBe(1)
4302
+ })
4303
+ })