@raidiant/notifai 4.0.0 → 5.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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +5 -0
  3. package/dist/commands.d.ts +10 -1
  4. package/dist/commands.js +174 -58
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +14 -5
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +72 -5
  17. package/dist/hooks.js +407 -79
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/logging.d.ts +8 -0
  25. package/dist/logging.js +8 -0
  26. package/dist/logging.js.map +1 -1
  27. package/dist/main.js +44 -17
  28. package/dist/main.js.map +1 -1
  29. package/dist/opencode-plugin.d.ts +8 -0
  30. package/dist/opencode-plugin.js +24 -162
  31. package/dist/opencode-plugin.js.map +1 -1
  32. package/dist/send.d.ts +6 -0
  33. package/dist/send.js +25 -14
  34. package/dist/send.js.map +1 -1
  35. package/dist/ui/help.d.ts +0 -1
  36. package/dist/ui/help.js +6 -3
  37. package/dist/ui/help.js.map +1 -1
  38. package/dist/ui/theme.d.ts +0 -15
  39. package/dist/ui/theme.js +2 -18
  40. package/dist/ui/theme.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/commands.test.ts +185 -51
  43. package/src/commands.ts +198 -56
  44. package/src/config-schema.test.ts +1 -1
  45. package/src/config-schema.ts +16 -5
  46. package/src/config.test.ts +41 -9
  47. package/src/config.ts +13 -0
  48. package/src/file-lock.test.ts +41 -0
  49. package/src/file-lock.ts +59 -9
  50. package/src/harnesses.test.ts +37 -29
  51. package/src/harnesses.ts +1 -4
  52. package/src/hooks.test.ts +358 -21
  53. package/src/hooks.ts +457 -97
  54. package/src/install-hooks.test.ts +232 -12
  55. package/src/install-hooks.ts +241 -37
  56. package/src/interactive.ts +1 -1
  57. package/src/logging-process-worker.test.ts +8 -2
  58. package/src/logging.test.ts +33 -18
  59. package/src/logging.ts +11 -0
  60. package/src/main.ts +57 -23
  61. package/src/opencode-plugin.ts +24 -164
  62. package/src/question-caps.test.ts +49 -0
  63. package/src/send.ts +32 -16
  64. package/src/skill.test.ts +199 -146
  65. package/src/ui/help.ts +8 -3
  66. package/src/ui/theme.ts +2 -16
package/src/hooks.test.ts CHANGED
@@ -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
  }
@@ -176,7 +189,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
176
189
  return {
177
190
  status: 'recorded',
178
191
  agent_acknowledgement: {
179
- text: body.text,
192
+ text: body.text ?? '',
180
193
  created_at: new Date(NOW).toISOString(),
181
194
  },
182
195
  }
@@ -188,6 +201,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
188
201
  return {
189
202
  request_id: requestId,
190
203
  agent_acknowledgement_required: required,
204
+ agent_acknowledgement_text_required:
205
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
191
206
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
192
207
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
193
208
  : null,
@@ -207,9 +222,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
207
222
  recorder.receipts.push(requestId)
208
223
  return {
209
224
  request_id: requestId,
210
- reply_expires_at: new Date(NOW + 480_000).toISOString(),
225
+ reply_expires_at: recorder.replyExpiresAt ?? new Date(NOW + 480_000).toISOString(),
211
226
  agent_acknowledgement_required:
212
227
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
228
+ agent_acknowledgement_text_required:
229
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
213
230
  replayed: false,
214
231
  overall: 'provider_accepted_all',
215
232
  deliveries: [],
@@ -223,6 +240,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
223
240
  reply_expires_at: null,
224
241
  agent_acknowledgement_required:
225
242
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
243
+ agent_acknowledgement_text_required:
244
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
226
245
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
227
246
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
228
247
  : null,
@@ -236,6 +255,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
236
255
  reply_expires_at: new Date(NOW).toISOString(),
237
256
  agent_acknowledgement_required:
238
257
  recorder.acknowledgementRequiredFor?.(requestId) ?? true,
258
+ agent_acknowledgement_text_required:
259
+ recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
239
260
  agent_acknowledgement: recorder.acknowledged?.has(requestId)
240
261
  ? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
241
262
  : null,
@@ -451,6 +472,27 @@ function writeGlobalConfig(h: Harness, toml: string): void {
451
472
  /** Long enough ago that no test is accidentally sensitive to the stamp. */
452
473
  const AWAY = NOW - 600_000
453
474
 
475
+ describe('the waiter wall clock', () => {
476
+ it('spends a held turn sparingly and a detached one generously', () => {
477
+ // A held turn is somebody's terminal, so the short ceiling is the cost of
478
+ // waiting. Detached, nothing is held, and the same number only shortens how
479
+ // long a direct wake stays possible.
480
+ expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
481
+ expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
482
+ expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
483
+ })
484
+
485
+ it('stays under the timeout the async Stop handler declares', () => {
486
+ // That declaration's kill is silent: past it the waiter vanishes and the
487
+ // answer the user already gave is never delivered.
488
+ expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
489
+ })
490
+
491
+ it('leaves the blocking host headroom above the waiter it declares for', () => {
492
+ expect(BLOCKING_STOP_TIMEOUT_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
493
+ })
494
+ })
495
+
454
496
  describe('pushing a registered question', () => {
455
497
  // Found by a live Claude Code session: a spawned agent always has a
456
498
  // just-set last_prompt_at, and the routing gate once read that as the user
@@ -471,7 +513,8 @@ describe('pushing a registered question', () => {
471
513
  title: 'Ship it?',
472
514
  body: 'Ship it?',
473
515
  })
474
- expect(h.recorder.submitted[0]?.draft.platform).toBeUndefined()
516
+ // A pushed question is a question, and a question has an attention tone.
517
+ expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
475
518
  })
476
519
 
477
520
  it('keeps sparse alt text paired with its original media item', async () => {
@@ -1045,6 +1088,12 @@ describe('late answer collection', () => {
1045
1088
 
1046
1089
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
1047
1090
  expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
1091
+ // The obligation survives the user typing — that is the whole point of
1092
+ // recording it — so the turn after the answer is held for it. Satisfy it
1093
+ // the way the agent would, then the retirement proceeds.
1094
+ expect(readSessionState('late-prompt', h.env).acknowledgement_due).toHaveLength(1)
1095
+ h.recorder.acknowledged = new Set(['req_live'])
1096
+
1048
1097
  await hookRunCommand(
1049
1098
  h.deps,
1050
1099
  'stop',
@@ -1216,10 +1265,66 @@ describe('the waiter owning one question to the end', () => {
1216
1265
  expect(h.recorder.submitted[0]).toEqual(firstAttempt)
1217
1266
  })
1218
1267
 
1219
- it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
1268
+ it('remints a frozen draft after a terminal 422 instead of replaying it', async () => {
1220
1269
  const h = harness([])
1221
1270
  const factory = h.deps.clientFactory
1222
- let polls = 0
1271
+ h.deps.clientFactory = () => {
1272
+ const client = factory!()
1273
+ return {
1274
+ ...client,
1275
+ submit: async () => {
1276
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1277
+ },
1278
+ } as ApiClient
1279
+ }
1280
+ writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1281
+ registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1282
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1283
+ const frozen = readSessionState('rejected-submit', h.env).pending?.[0]?.submission
1284
+ if (frozen === undefined) throw new Error('expected a persisted submission intent')
1285
+
1286
+ let submits = 0
1287
+ h.deps.clientFactory = () => {
1288
+ const client = factory!()
1289
+ return {
1290
+ ...client,
1291
+ submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
1292
+ submits += 1
1293
+ if (body.request_id === frozen.request_id) {
1294
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.', null, [
1295
+ { path: 'presentation.detail', message: 'Unexpected property' },
1296
+ ])
1297
+ }
1298
+ return client.submit(body, waitSeconds)
1299
+ },
1300
+ } as ApiClient
1301
+ }
1302
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1303
+
1304
+ expect(submits).toBe(2)
1305
+ expect(h.recorder.submitted[0]?.request_id).toBeDefined()
1306
+ expect(h.recorder.submitted[0]?.request_id).not.toBe(frozen.request_id)
1307
+ expect(h.io.errLines.join('\n')).toContain('reminting the draft')
1308
+ expect(readSessionState('rejected-submit', h.env).pending).toBeUndefined()
1309
+ })
1310
+
1311
+ it('retires a reminted draft that the server still rejects', async () => {
1312
+ const h = harness([])
1313
+ const factory = h.deps.clientFactory
1314
+ h.deps.clientFactory = () => {
1315
+ const client = factory!()
1316
+ return {
1317
+ ...client,
1318
+ submit: async () => {
1319
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1320
+ },
1321
+ } as ApiClient
1322
+ }
1323
+ writeSessionState('rejected-forever', h.env, { last_prompt_at: AWAY })
1324
+ registerQuestion('rejected-forever', h.env, { question: 'Deploy?' }, NOW)
1325
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1326
+ expect(readSessionState('rejected-forever', h.env).pending?.[0]?.submission).toBeDefined()
1327
+
1223
1328
  h.deps.clientFactory = () => {
1224
1329
  const client = factory!()
1225
1330
  return {
@@ -1227,29 +1332,72 @@ describe('the waiter owning one question to the end', () => {
1227
1332
  submit: async () => {
1228
1333
  throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1229
1334
  },
1335
+ } as ApiClient
1336
+ }
1337
+ h.advanceClock(600_000)
1338
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1339
+ expect(readSessionState('rejected-forever', h.env).pending).toBeUndefined()
1340
+
1341
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
1342
+ expect(h.recorder.submitted).toEqual([])
1343
+ expect(h.io.errLines.join('\n')).toContain('retiring the question so it is not retried forever')
1344
+ })
1345
+
1346
+ it('retires a freshly minted draft the current contract rejects', async () => {
1347
+ const h = harness([])
1348
+ const factory = h.deps.clientFactory
1349
+ h.deps.clientFactory = () => {
1350
+ const client = factory!()
1351
+ return {
1352
+ ...client,
1353
+ submit: async () => {
1354
+ throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
1355
+ },
1356
+ } as ApiClient
1357
+ }
1358
+ writeSessionState('fresh-422', h.env, { last_prompt_at: AWAY })
1359
+ registerQuestion('fresh-422', h.env, { question: 'Deploy?' }, NOW)
1360
+
1361
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fresh-422' }))
1362
+
1363
+ expect(readSessionState('fresh-422', h.env).pending).toBeUndefined()
1364
+ expect(h.io.errLines.join('\n')).toContain('retiring it because the current draft will never be accepted')
1365
+ })
1366
+
1367
+ it('preserves a frozen intent after a retryable client rejection', async () => {
1368
+ const h = harness([])
1369
+ const factory = h.deps.clientFactory
1370
+ let polls = 0
1371
+ h.deps.clientFactory = () => {
1372
+ const client = factory!()
1373
+ return {
1374
+ ...client,
1375
+ submit: async () => {
1376
+ throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
1377
+ },
1230
1378
  replies: async () => {
1231
1379
  polls += 1
1232
1380
  throw new Error('must not poll an unaccepted request')
1233
1381
  },
1234
1382
  } as ApiClient
1235
1383
  }
1236
- writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
1237
- registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
1384
+ writeSessionState('auth-submit', h.env, { last_prompt_at: AWAY })
1385
+ registerQuestion('auth-submit', h.env, { question: 'Deploy?' }, NOW)
1238
1386
 
1239
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1387
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
1240
1388
 
1241
- const pending = readSessionState('rejected-submit', h.env).pending?.[0]
1389
+ const pending = readSessionState('auth-submit', h.env).pending?.[0]
1242
1390
  expect(pending?.request_id).toBeUndefined()
1243
1391
  expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
1244
1392
  expect(polls).toBe(0)
1245
- expect(h.io.errLines.join('\n')).toContain('submission was rejected')
1393
+ expect(h.io.errLines.join('\n')).toContain('preserving it for recovery')
1246
1394
 
1247
1395
  const frozen = pending?.submission
1248
1396
  if (frozen === undefined) throw new Error('expected a persisted submission intent')
1249
1397
  writeGlobalConfig(h, 'project = "changed-after-crash"\n')
1250
1398
  h.advanceClock(600_000)
1251
1399
  h.deps.clientFactory = factory
1252
- await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
1400
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
1253
1401
 
1254
1402
  expect(h.recorder.submitted[0]).toEqual({
1255
1403
  request_id: frozen.request_id,
@@ -1284,7 +1432,7 @@ describe('the waiter owning one question to the end', () => {
1284
1432
  expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
1285
1433
  })
1286
1434
 
1287
- it('charges sequential submission latency to one deadline', async () => {
1435
+ it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
1288
1436
  const h = harness([reply({ text: 'Done' })])
1289
1437
  writeSessionState('latency', h.env, { last_prompt_at: AWAY })
1290
1438
  registerQuestion('latency', h.env, { question: 'First?' }, NOW)
@@ -1296,9 +1444,12 @@ describe('the waiter owning one question to the end', () => {
1296
1444
  const windows = h.recorder.submitted
1297
1445
  .filter((entry) => entry.draft.event === 'agent_question')
1298
1446
  .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])
1447
+ // How long the user may answer does not shrink because this owner spent
1448
+ // part of its own budget getting the question out. Both questions stay
1449
+ // answerable for the configured window.
1450
+ expect(windows).toEqual([86_400, 86_400])
1451
+ // The owner's own budget is still one shared deadline, and it is still
1452
+ // charged for the latency: the whole pass fits inside the waiter ceiling.
1302
1453
  expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
1303
1454
  })
1304
1455
 
@@ -1763,7 +1914,7 @@ describe('several questions in flight', () => {
1763
1914
 
1764
1915
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
1765
1916
  expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
1766
- { request_id: 'req_ack_required', recorded_at: NOW },
1917
+ { request_id: 'req_ack_required', recorded_at: NOW, text_required: true },
1767
1918
  ])
1768
1919
 
1769
1920
  h.io.outLines = []
@@ -1783,6 +1934,66 @@ describe('several questions in flight', () => {
1783
1934
  expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
1784
1935
  })
1785
1936
 
1937
+ it('still blocks Stop, without asking for text, when the account turned text off', async () => {
1938
+ const h = harness([reply({ text: 'Ship it' })])
1939
+ h.recorder.acknowledgementTextRequiredFor = () => false
1940
+ writeSessionState('ack-textless', h.env, {
1941
+ pending: [
1942
+ {
1943
+ question: 'Ship it?',
1944
+ request_id: 'req_ack_textless',
1945
+ collapse_key: 'collapse-ack-textless',
1946
+ device_ids: ['dev_iphone'],
1947
+ reply_deadline_at: NOW + 60_000,
1948
+ },
1949
+ ],
1950
+ })
1951
+
1952
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-textless' }))
1953
+ // The obligation is unchanged by the preference: the acknowledgement still
1954
+ // has to happen, because the user's read-state depends on it.
1955
+ expect(readSessionState('ack-textless', h.env).acknowledgement_due).toEqual([
1956
+ { request_id: 'req_ack_textless', recorded_at: NOW, text_required: false },
1957
+ ])
1958
+
1959
+ h.io.outLines = []
1960
+ await hookRunCommand(
1961
+ h.deps,
1962
+ 'stop',
1963
+ stdin({ session_id: 'ack-textless', stop_hook_active: true }),
1964
+ )
1965
+
1966
+ const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
1967
+ expect(blocked.decision).toBe('block')
1968
+ expect(blocked.reason).toContain('notifai acknowledge req_ack_textless`')
1969
+ expect(blocked.reason).not.toContain('--text')
1970
+ })
1971
+
1972
+ it('gives up holding the turn rather than wedging the session for ever', async () => {
1973
+ // The acknowledgement gate is the one place hooks break the fail-open rule.
1974
+ // Unbounded, an agent that never acknowledges — or a server that stays
1975
+ // unreachable, since an error counts as unresolved — held every turn of
1976
+ // this session for good.
1977
+ const h = harness([])
1978
+ writeSessionState('ack-wedge', h.env, {
1979
+ acknowledgement_due: [{ request_id: 'req_wedge', recorded_at: NOW }],
1980
+ })
1981
+
1982
+ const blocked: unknown[] = []
1983
+ for (let turn = 0; turn < 3; turn += 1) {
1984
+ h.io.outLines.length = 0
1985
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
1986
+ blocked.push(h.io.outLines.length > 0)
1987
+ }
1988
+ expect(blocked).toEqual([true, true, true])
1989
+
1990
+ // The fourth turn is let through, and the obligation stops being owed.
1991
+ h.io.outLines.length = 0
1992
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
1993
+ expect(h.io.outLines).toEqual([])
1994
+ expect(readSessionState('ack-wedge', h.env).acknowledgement_due).toBeUndefined()
1995
+ })
1996
+
1786
1997
  it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
1787
1998
  const h = harness([])
1788
1999
  writeSessionState('ack-heal', h.env, {
@@ -1953,7 +2164,7 @@ describe('a question that outlives its session', () => {
1953
2164
  expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
1954
2165
  })
1955
2166
 
1956
- it('gives up on an orphan older than a day instead of queueing it for ever', async () => {
2167
+ it('gives up on an orphan that outlived the longest answerable window', async () => {
1957
2168
  const h = harness([])
1958
2169
  orphanRetirements(
1959
2170
  h.env,
@@ -1964,7 +2175,7 @@ describe('a question that outlives its session', () => {
1964
2175
  question: 'Old?',
1965
2176
  state: 'expired',
1966
2177
  }],
1967
- NOW - 25 * 3600 * 1000,
2178
+ NOW - (REPLY_MAX_WINDOW_SECONDS + 2 * 3600) * 1000,
1968
2179
  )
1969
2180
  const drained = await drainOrphanRetirements(
1970
2181
  { client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
@@ -3561,6 +3772,47 @@ describe('escalation waiter delivery seam', () => {
3561
3772
  expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
3562
3773
  })
3563
3774
 
3775
+ it('does not spend the delivery cap on turns where nothing was handed over', async () => {
3776
+ // The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
3777
+ // where a liveness probe could not reach a busy session were enough to
3778
+ // settle an answer the user had already given, unread.
3779
+ const h = harness([])
3780
+ const sessionId = 'waiter-transient-holds'
3781
+ journaled(h, sessionId)
3782
+ const context = waiterContext(h)
3783
+ let handedOver = 0
3784
+ let pass = 0
3785
+
3786
+ for (; pass < MAX_CONTINUATION_COUNT + 2; pass += 1) {
3787
+ await runEscalationWaiter(context, {
3788
+ sessionId,
3789
+ envelope: { session_id: sessionId, stop_hook_active: false },
3790
+ route: {
3791
+ kind: 'inbox-socket',
3792
+ deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
3793
+ },
3794
+ processDeadlineAt: NOW + 480_000,
3795
+ })
3796
+ }
3797
+ // Still journaled after more holds than the delivery cap allows.
3798
+ expect(readSessionState(sessionId, h.env).accepted).toBeDefined()
3799
+
3800
+ // And when a route can finally take it, it is delivered rather than lost.
3801
+ await runEscalationWaiter(context, {
3802
+ sessionId,
3803
+ envelope: { session_id: sessionId, stop_hook_active: false },
3804
+ route: {
3805
+ kind: 'inbox-socket',
3806
+ deliver: async () => {
3807
+ handedOver += 1
3808
+ return { notes: [], acknowledgement: 'delivered' as const }
3809
+ },
3810
+ },
3811
+ processDeadlineAt: NOW + 480_000,
3812
+ })
3813
+ expect(handedOver).toBe(1)
3814
+ })
3815
+
3564
3816
  it('bounds deliveries by the continuation cap on every route', async () => {
3565
3817
  // A route that never acknowledges must still stop. The cap lives where all
3566
3818
  // routes pass, so no future route can be the one it does not cover.
@@ -3572,7 +3824,7 @@ describe('escalation waiter delivery seam', () => {
3572
3824
  let deliveries = 0
3573
3825
  const notes: string[] = []
3574
3826
 
3575
- for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
3827
+ for (let pass = 0; pass < MAX_HELD_DELIVERIES + 3; pass += 1) {
3576
3828
  const outcome = await runEscalationWaiter(context, {
3577
3829
  sessionId,
3578
3830
  envelope: { session_id: sessionId, stop_hook_active: false },
@@ -3588,9 +3840,11 @@ describe('escalation waiter delivery seam', () => {
3588
3840
  notes.push(...outcome.notes)
3589
3841
  }
3590
3842
 
3591
- expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
3843
+ // A hold is not a delivery — nothing was handed over — so it does not
3844
+ // spend the wake-loop cap. It still has to end, on its own looser bound.
3845
+ expect(deliveries).toBe(MAX_HELD_DELIVERIES)
3592
3846
  expect(notes.join('\n')).toContain(
3593
- `${MAX_CONTINUATION_COUNT} times without being acknowledged`,
3847
+ `could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns`,
3594
3848
  )
3595
3849
  expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
3596
3850
  }
@@ -3624,3 +3878,86 @@ describe('escalation waiter delivery seam', () => {
3624
3878
  releaseQuestionPush('waiter-failed-route', h.env)
3625
3879
  })
3626
3880
  })
3881
+
3882
+
3883
+ describe('session state across a prompt', () => {
3884
+ it('carries every kind of session state across a typed prompt', async () => {
3885
+ // The prompt handler rebuilds state from named fields, so a field nobody
3886
+ // remembered to name is erased by the user typing. That is how an
3887
+ // acknowledgement obligation once vanished. This fails if a future field is
3888
+ // added to SessionState and not carried across.
3889
+ const h = harness([])
3890
+ const before = {
3891
+ last_prompt_at: AWAY,
3892
+ last_stop_at: NOW - 1_000,
3893
+ retiring: [
3894
+ {
3895
+ request_id: 'req_carry',
3896
+ collapse_key: 'collapse-carry',
3897
+ device_ids: ['dev_iphone'],
3898
+ question: 'Carried?',
3899
+ state: 'expired' as const,
3900
+ },
3901
+ ],
3902
+ continuation: { answered_at: NOW - 2_000, count: 2 },
3903
+ acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
3904
+ acknowledgement_blocks: 2,
3905
+ }
3906
+ writeSessionState('carry-all', h.env, before)
3907
+
3908
+ await hookRunCommand(
3909
+ h.deps,
3910
+ 'user-prompt-submit',
3911
+ stdin({ session_id: 'carry-all', cwd: h.deps.cwd }),
3912
+ )
3913
+
3914
+ const after = readSessionState('carry-all', h.env)
3915
+ for (const key of Object.keys(before) as (keyof typeof before)[]) {
3916
+ expect(after[key], `${key} did not survive the prompt`).toBeDefined()
3917
+ }
3918
+ // The one field a prompt resets rather than carries.
3919
+ expect(after.continuation?.count).toBe(0)
3920
+ expect(after.acknowledgement_blocks).toBe(2)
3921
+ })
3922
+ })
3923
+
3924
+ describe('a question outliving the waiter that pushed it', () => {
3925
+ it('records when the server stops accepting, not when this owner stops waiting', async () => {
3926
+ // These were the same number until the window became a setting, and taking
3927
+ // the minimum capped every question at the waiter's ceiling: a day-long
3928
+ // window was swept as stale about eight minutes later and retired, so the
3929
+ // answer given over lunch met a closed question.
3930
+ const h = harness([])
3931
+ const aDayOut = NOW + 86_400_000
3932
+ h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
3933
+ writeSessionState('outlive', h.env, { last_prompt_at: AWAY })
3934
+ registerQuestion('outlive', h.env, { question: 'Ship it?' }, NOW)
3935
+
3936
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
3937
+
3938
+ const state = readSessionState('outlive', h.env)
3939
+ const live = state.pending?.[0]
3940
+ // Still live, still carrying the server's window — not the waiter's.
3941
+ expect(live?.request_id).toBeDefined()
3942
+ expect(live?.reply_deadline_at).toBe(aDayOut)
3943
+ // Not closed and not retired: those are what capped it at eight minutes.
3944
+ expect(state.retiring).toBeUndefined()
3945
+ expect(h.recorder.closed).toEqual([])
3946
+ // And the waiter still stopped at its own ceiling rather than waiting a day.
3947
+ expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
3948
+ })
3949
+
3950
+ it('still closes a question the server has genuinely stopped accepting', async () => {
3951
+ const h = harness([])
3952
+ h.recorder.replyExpiresAt = new Date(NOW + 90_000).toISOString()
3953
+ writeSessionState('short-window', h.env, { last_prompt_at: AWAY })
3954
+ registerQuestion('short-window', h.env, { question: 'Ship it?' }, NOW)
3955
+
3956
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
3957
+
3958
+ // Its window closed inside the waiter's ceiling, so it is retired here
3959
+ // rather than left for a turn that could never collect it.
3960
+ expect(readSessionState('short-window', h.env).pending).toBeUndefined()
3961
+ expect(h.recorder.closed.length).toBe(1)
3962
+ })
3963
+ })