@raidiant/notifai 0.1.7 → 0.1.8

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.
@@ -141,9 +141,61 @@ describe('draft building', () => {
141
141
  presentation: { title: 'T', body: 'B' },
142
142
  targets: { mode: 'all' },
143
143
  delivery: { ttl_seconds: 86400, collapse_key: null },
144
+ platform: {
145
+ ios: { sound: null, interruption_level: 'passive' },
146
+ macos: { sound: null, interruption_level: 'passive' },
147
+ },
144
148
  })
145
149
  })
146
150
 
151
+ it.each([
152
+ { flags: {}, sound: null, level: 'passive' },
153
+ { flags: { kind: 'done' }, sound: 'done', level: 'passive' },
154
+ { flags: { reply: true }, sound: 'attention', level: 'active' },
155
+ ])('derives the $sound / $level kind profile from $flags', ({ flags, sound, level }) => {
156
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
157
+ const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
158
+ if (!build.ok) throw new Error(build.error)
159
+ expect(build.draft.platform).toEqual({
160
+ ios: { sound, interruption_level: level },
161
+ macos: { sound, interruption_level: level },
162
+ })
163
+ })
164
+
165
+ it('orders explicit flags over user config over the kind profile', () => {
166
+ const { env, cwd } = setup({
167
+ projectToml: 'sound = "alert"\ninterruption_level = "active"\n',
168
+ })
169
+ const config = loadConfig({ cwd, env })
170
+ const configured = buildDraft(config, { title: 'T', body: 'B', kind: 'done' })
171
+ if (!configured.ok) throw new Error(configured.error)
172
+ expect(configured.draft.platform?.ios).toEqual({
173
+ sound: 'alert',
174
+ interruption_level: 'active',
175
+ })
176
+
177
+ const explicit = buildDraft(config, {
178
+ title: 'T',
179
+ body: 'B',
180
+ kind: 'done',
181
+ sound: 'none',
182
+ level: 'time_sensitive',
183
+ })
184
+ if (!explicit.ok) throw new Error(explicit.error)
185
+ expect(explicit.draft.platform?.ios).toEqual({
186
+ sound: null,
187
+ interruption_level: 'time_sensitive',
188
+ })
189
+ })
190
+
191
+ it('keeps a blocking reply answerable for one hour by default', () => {
192
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
193
+ const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
194
+ if (!build.ok) throw new Error(build.error)
195
+
196
+ expect(build.draft.reply?.expires_in_seconds).toBe(3_600)
197
+ })
198
+
147
199
  it('stamps the project from flag or project config', () => {
148
200
  const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
149
201
  const config = loadConfig({ cwd, env })
@@ -200,6 +252,7 @@ describe('draft building', () => {
200
252
  interruption_level: 'time_sensitive',
201
253
  custom_data: { run_id: '42', branch: 'main' },
202
254
  })
255
+ expect(build.draft.platform?.macos).toEqual(build.draft.platform?.ios)
203
256
  })
204
257
 
205
258
  it('maps optional fields into the selected macOS platform slot', () => {
@@ -209,11 +262,8 @@ describe('draft building', () => {
209
262
  body: 'B',
210
263
  platform: 'macos',
211
264
  sound: 'none',
212
- badge: 7,
213
265
  threadId: 'desktop-builds',
214
266
  level: 'passive',
215
- relevance: 0.8,
216
- targetContentId: 'build-detail',
217
267
  data: ['run_id=42'],
218
268
  })
219
269
  if (!build.ok) throw new Error(build.error)
@@ -222,16 +272,28 @@ describe('draft building', () => {
222
272
  expect(build.draft.platform).toEqual({
223
273
  macos: {
224
274
  sound: null,
225
- badge: 7,
226
275
  thread_id: 'desktop-builds',
227
276
  interruption_level: 'passive',
228
- relevance_score: 0.8,
229
- target_content_id: 'build-detail',
230
277
  custom_data: { run_id: '42' },
231
278
  },
232
279
  })
233
280
  })
234
281
 
282
+ it('limits collapse keys by UTF-8 bytes', () => {
283
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
284
+ expect(buildDraft(config, { title: 'T', body: 'B', collapseKey: '😀'.repeat(16) }).ok).toBe(
285
+ true,
286
+ )
287
+ const oversized = buildDraft(config, {
288
+ title: 'T',
289
+ body: 'B',
290
+ collapseKey: '😀'.repeat(17),
291
+ })
292
+ expect(oversized.ok).toBe(false)
293
+ if (oversized.ok) throw new Error('expected rejection')
294
+ expect(oversized.error).toContain('64 UTF-8 bytes')
295
+ })
296
+
235
297
  it('rejects malformed inputs with usage errors', () => {
236
298
  const config = loadConfig({ cwd: base.cwd, env: base.env })
237
299
  expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
package/src/hooks.test.ts CHANGED
@@ -1,4 +1,13 @@
1
- import { mkdirSync, mkdtempSync, realpathSync, utimesSync, writeFileSync } from 'node:fs'
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ realpathSync,
7
+ symlinkSync,
8
+ utimesSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
2
11
  import os from 'node:os'
3
12
  import path from 'node:path'
4
13
  import type {
@@ -10,9 +19,11 @@ import type {
10
19
  import { describe, expect, it } from 'vitest'
11
20
  import type { ApiClient } from './client.js'
12
21
  import { EXIT, askCommand, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
13
- import { loadConfig, sanitizeSessionId } from './config.js'
22
+ import { loadConfig, projectSessionPointerPath, sanitizeSessionId } from './config.js'
14
23
  import {
15
24
  claimQuestionPush,
25
+ clearSessionState,
26
+ drainRetirements,
16
27
  drainOrphanRetirements,
17
28
  isUserAway,
18
29
  orphanRetirements,
@@ -21,6 +32,7 @@ import {
21
32
  readProjectSession,
22
33
  readSessionState,
23
34
  registerQuestion,
35
+ writeProjectSession,
24
36
  writeSessionState,
25
37
  } from './hooks.js'
26
38
 
@@ -46,6 +58,8 @@ interface Recorder {
46
58
  closed: string[]
47
59
  /** Simulates an offline machine; retirement has to survive one. */
48
60
  failSubmits?: boolean
61
+ /** Runs while an agent_question submit is in flight, before onSubmitted. */
62
+ beforeQuestionSubmit?: () => void
49
63
  }
50
64
 
51
65
  function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
@@ -81,6 +95,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
81
95
  health: async () => true,
82
96
  submit: async (body) => {
83
97
  if (recorder.failSubmits === true) throw new Error('offline')
98
+ if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
84
99
  recorder.submitted.push(body)
85
100
  submissions += 1
86
101
  recorder.receipts.push(`req_hook_${submissions}`)
@@ -224,6 +239,30 @@ describe('presence gate', () => {
224
239
  mode: 'selected',
225
240
  device_ids: ['dev_iphone', 'dev_mac'],
226
241
  })
242
+ expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
243
+ title: 'Question',
244
+ body: 'Ship it?',
245
+ })
246
+ expect(h.recorder.submitted[0]?.draft.platform).toEqual({
247
+ ios: { sound: 'attention', interruption_level: 'active' },
248
+ macos: { sound: 'attention', interruption_level: 'active' },
249
+ })
250
+ })
251
+
252
+ it('names the resolved project in the pushed question title', async () => {
253
+ const h = harness([reply({ text: 'Yes' })], 900)
254
+ const projectDir = path.join(h.deps.cwd, '.notifai')
255
+ mkdirSync(projectDir, { recursive: true })
256
+ writeFileSync(
257
+ path.join(projectDir, 'config.toml'),
258
+ 'project = "notifai-cli"\nask_grace_seconds = 0\n',
259
+ )
260
+ writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
261
+ registerQuestion('project-title', h.env, { question: 'Ship it?' }, NOW)
262
+
263
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
264
+
265
+ expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
227
266
  })
228
267
 
229
268
  it('falls back to elapsed time where no idle source exists', () => {
@@ -438,6 +477,142 @@ describe('nagging guards', () => {
438
477
  expect(h.recorder.submitted).toEqual([])
439
478
  expect(h.io.outLines).toEqual([])
440
479
  })
480
+
481
+ it('delivers a new question registered during an answer continuation', async () => {
482
+ const h = harness([reply({ text: 'First answer' })], 900)
483
+ writeSessionState('n3', h.env, { last_prompt_at: AWAY })
484
+ registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
485
+
486
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3' }))
487
+ await h.deps.sleep?.(1)
488
+ registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
489
+ h.io.outLines = []
490
+
491
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
492
+
493
+ const questions = h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')
494
+ expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
495
+ 'First question?',
496
+ 'Follow-up question?',
497
+ ])
498
+ expect(h.io.outLines.join('\n')).toContain('First answer')
499
+ })
500
+
501
+ it('stops chained questions at the consecutive continuation cap', async () => {
502
+ const h = harness([], 900)
503
+ writeSessionState('n4', h.env, {
504
+ last_prompt_at: AWAY,
505
+ continuation: { answered_at: NOW - 1, count: 3 },
506
+ })
507
+ registerQuestion('n4', h.env, { question: 'One more?' }, NOW)
508
+
509
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
510
+
511
+ expect(h.recorder.submitted).toEqual([])
512
+ expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
513
+ expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
514
+ })
515
+ })
516
+
517
+ describe('late answer collection', () => {
518
+ it('collects a late answer on Stop and resumes with it instead of asking twice', async () => {
519
+ const answers: ReplyView[] = []
520
+ const h = harness(answers, 900)
521
+ writeSessionState('late-stop', h.env, { last_prompt_at: AWAY })
522
+ registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
523
+
524
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
525
+ expect(readSessionState('late-stop', h.env).pending?.request_id).toBe('req_hook_1')
526
+ answers.push(reply({ text: 'Ship it' }))
527
+ h.io.outLines = []
528
+
529
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
530
+
531
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
532
+ expect(h.recorder.closed).toContain('req_hook_1')
533
+ expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
534
+ expect(h.io.outLines).toHaveLength(1)
535
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
536
+ decision: 'block',
537
+ reason: expect.stringContaining('Ship it'),
538
+ })
539
+ })
540
+
541
+ it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
542
+ const answers: ReplyView[] = []
543
+ const h = harness(answers, 900)
544
+ writeSessionState('late-prompt', h.env, { last_prompt_at: AWAY })
545
+ registerQuestion('late-prompt', h.env, { question: 'Ship it?' }, NOW)
546
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
547
+ answers.push(reply({ text: 'Hold' }))
548
+ h.io.outLines = []
549
+
550
+ await hookRunCommand(
551
+ h.deps,
552
+ 'user-prompt-submit',
553
+ stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
554
+ )
555
+
556
+ const retirement = h.recorder.submitted.find(
557
+ (entry) => entry.draft.event === 'question_retired',
558
+ )?.draft
559
+ expect(retirement?.lifecycle).toMatchObject({
560
+ state: 'answered',
561
+ retires_request_id: 'req_hook_1',
562
+ })
563
+ expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
564
+ expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
565
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toEqual({
566
+ hookSpecificOutput: {
567
+ hookEventName: 'UserPromptSubmit',
568
+ additionalContext: expect.stringContaining('Hold'),
569
+ },
570
+ })
571
+ })
572
+ })
573
+
574
+ describe('OpenCode answer preservation', () => {
575
+ it('pushes without waiting and leaves the answerable request intact', async () => {
576
+ const h = harness([reply({ text: 'Approve' })], 900)
577
+ writeSessionState('open1', h.env, { last_prompt_at: AWAY })
578
+ registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
579
+
580
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
581
+
582
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
583
+ expect(h.recorder.closed).toEqual([])
584
+ expect(
585
+ h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
586
+ ).toEqual([])
587
+ expect(readSessionState('open1', h.env).pending).toMatchObject({
588
+ question: 'Deploy?',
589
+ request_id: 'req_hook_1',
590
+ device_ids: ['dev_iphone', 'dev_mac'],
591
+ })
592
+ expect(h.io.outLines).toEqual([])
593
+ })
594
+ })
595
+
596
+ describe('reply-wait presence monitoring', () => {
597
+ it('returns the terminal when the user comes back after the push', async () => {
598
+ const h = harness([], 900)
599
+ let checks = 0
600
+ h.deps.idleSeconds = () => {
601
+ checks += 1
602
+ return checks >= 4 ? 1 : 900
603
+ }
604
+ writeSessionState('returned', h.env, { last_prompt_at: AWAY })
605
+ registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
606
+
607
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
608
+
609
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
610
+ expect(h.recorder.closed).toEqual([])
611
+ expect(readSessionState('returned', h.env).pending?.request_id).toBe('req_hook_1')
612
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
613
+ expect(h.io.errLines.join('\n')).toContain('came back')
614
+ expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
615
+ })
441
616
  })
442
617
 
443
618
  describe('Cursor stop output', () => {
@@ -460,7 +635,7 @@ describe('Cursor stop output', () => {
460
635
  expect(h.io.outLines).toHaveLength(1)
461
636
  const output = JSON.parse(h.io.outLines[0] ?? '{}') as Record<string, unknown>
462
637
  expect(output['followup_message']).toContain(
463
- 'NotifAI — the user answered from Furankuphone: "Ship it". Continue with that answer.',
638
+ 'Notifai — the user answered from Furankuphone: "Ship it". Continue with that answer.',
464
639
  )
465
640
  expect(output).not.toHaveProperty('decision')
466
641
  })
@@ -760,7 +935,12 @@ describe('ask registration', () => {
760
935
  // harness's own id, so the pointer must outrank the env var.
761
936
  const h = harness()
762
937
  h.deps.env['NOTIFAI_SESSION'] = 'my-label'
763
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'real1', cwd: h.deps.cwd }))
938
+ await hookRunCommand(
939
+ h.deps,
940
+ 'user-prompt-submit',
941
+ stdin({ session_id: 'real1', cwd: h.deps.cwd }),
942
+ 'claude-code',
943
+ )
764
944
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
765
945
  expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
766
946
  expect(readSessionState('my-label', h.env).pending).toBeUndefined()
@@ -798,12 +978,34 @@ describe('user-prompt-submit hook', () => {
798
978
  expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
799
979
  })
800
980
 
981
+ it('diagnoses malformed or truncated hook input instead of silently doing nothing', async () => {
982
+ const h = harness()
983
+
984
+ expect(await hookRunCommand(h.deps, 'user-prompt-submit', async () => '{"session_id":')).toBe(
985
+ EXIT.ok,
986
+ )
987
+
988
+ expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
989
+ })
990
+
991
+ it('explains when a missing machine credential makes a hook skip routing', async () => {
992
+ const h = harness()
993
+ h.deps.store.load = () => null
994
+
995
+ expect(
996
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'unpaired' })),
997
+ ).toBe(EXIT.ok)
998
+
999
+ expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
1000
+ })
1001
+
801
1002
  it('preserves a delivered question through ask replacement and prompt transition', async () => {
802
1003
  const h = harness([], 900)
803
1004
  await hookRunCommand(
804
1005
  h.deps,
805
1006
  'user-prompt-submit',
806
1007
  stdin({ session_id: 'transition', cwd: h.deps.cwd }),
1008
+ 'claude-code',
807
1009
  )
808
1010
  expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
809
1011
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
@@ -834,6 +1036,7 @@ describe('user-prompt-submit hook', () => {
834
1036
  h.deps,
835
1037
  'user-prompt-submit',
836
1038
  stdin({ session_id: 'transition', cwd: h.deps.cwd }),
1039
+ 'claude-code',
837
1040
  )
838
1041
 
839
1042
  const retirement = h.recorder.submitted.find(
@@ -919,12 +1122,20 @@ describe('user-prompt-submit hook', () => {
919
1122
  const project = realpathSync(viaSymlink)
920
1123
  if (project === viaSymlink) return // no symlink on this platform; nothing to prove
921
1124
 
922
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's14', cwd: viaSymlink }))
1125
+ await hookRunCommand(
1126
+ h.deps,
1127
+ 'user-prompt-submit',
1128
+ stdin({ session_id: 's14', cwd: viaSymlink }),
1129
+ 'claude-code',
1130
+ )
923
1131
 
924
1132
  expect(readProjectSession(project, h.env, NOW)).toBe('s14')
925
1133
  expect(readProjectSession(viaSymlink, h.env, NOW)).toBe('s14')
926
1134
  // A pointer older than a day is not evidence of a live session.
927
1135
  expect(readProjectSession(project, h.env, NOW + 2 * 24 * 3600 * 1000)).toBeNull()
1136
+
1137
+ clearSessionState('s14', h.env)
1138
+ expect(readProjectSession(project, h.env, NOW)).toBeNull()
928
1139
  })
929
1140
  })
930
1141
 
@@ -940,6 +1151,26 @@ describe('session-end hook', () => {
940
1151
  expect(h.recorder.closed).toEqual([])
941
1152
  })
942
1153
 
1154
+ it('clears only the ending session project pointer', async () => {
1155
+ const h = harness()
1156
+ await hookRunCommand(
1157
+ h.deps,
1158
+ 'user-prompt-submit',
1159
+ stdin({ session_id: 'ending', cwd: h.deps.cwd }),
1160
+ 'claude-code',
1161
+ )
1162
+ expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
1163
+
1164
+ await hookRunCommand(
1165
+ h.deps,
1166
+ 'session-end',
1167
+ stdin({ session_id: 'ending', cwd: h.deps.cwd }),
1168
+ 'claude-code',
1169
+ )
1170
+
1171
+ expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBeNull()
1172
+ })
1173
+
943
1174
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
944
1175
  const h = harness()
945
1176
  writeSessionState('s-incomplete', h.env, {
@@ -1106,6 +1337,113 @@ describe('pruning abandoned session state', () => {
1106
1337
  })
1107
1338
  })
1108
1339
 
1340
+ describe('durable state writes', () => {
1341
+ function sessionFile(h: Harness, sessionId: string): string {
1342
+ return path.join(
1343
+ h.env['XDG_STATE_HOME'] as string,
1344
+ 'notifai',
1345
+ 'sessions',
1346
+ `${sanitizeSessionId(sessionId)}.json`,
1347
+ )
1348
+ }
1349
+
1350
+ it('recovers a truncated state file with one complete atomic document', () => {
1351
+ const h = harness()
1352
+ const file = sessionFile(h, 'truncated')
1353
+ mkdirSync(path.dirname(file), { recursive: true })
1354
+ writeFileSync(file, '{"pending":')
1355
+ expect(readSessionState('truncated', h.env)).toEqual({})
1356
+
1357
+ writeSessionState('truncated', h.env, { last_prompt_at: NOW })
1358
+
1359
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ last_prompt_at: NOW })
1360
+ expect(readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp'))).toEqual([])
1361
+ })
1362
+
1363
+ it('refuses a session-state symlink instead of writing through it', () => {
1364
+ const h = harness()
1365
+ const file = sessionFile(h, 'linked')
1366
+ const target = path.join(path.dirname(file), 'target.json')
1367
+ mkdirSync(path.dirname(file), { recursive: true })
1368
+ writeFileSync(target, '{"untouched":true}\n')
1369
+ symlinkSync(target, file)
1370
+
1371
+ expect(() => writeSessionState('linked', h.env, { last_prompt_at: NOW })).toThrow(/symlink/)
1372
+ expect(readFileSync(target, 'utf8')).toBe('{"untouched":true}\n')
1373
+ })
1374
+
1375
+ it('refuses project-pointer and retirement-queue symlinks too', () => {
1376
+ const h = harness()
1377
+ writeSessionState('linked-pointer', h.env, { last_prompt_at: NOW })
1378
+ const target = path.join(h.env['XDG_STATE_HOME'] as string, 'pointer-target.json')
1379
+ writeFileSync(target, '{"untouched":true}\n')
1380
+ const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
1381
+ mkdirSync(path.dirname(pointer), { recursive: true })
1382
+ symlinkSync(target, pointer)
1383
+
1384
+ expect(() =>
1385
+ writeProjectSession(h.deps.cwd, h.env, 'linked-pointer', NOW, 'claude-code'),
1386
+ ).toThrow(/symlink/)
1387
+ expect(readFileSync(target, 'utf8')).toBe('{"untouched":true}\n')
1388
+
1389
+ const queueTarget = path.join(h.env['XDG_STATE_HOME'] as string, 'queue-target.json')
1390
+ const queue = path.join(h.env['XDG_STATE_HOME'] as string, 'notifai', 'retire-queue.json')
1391
+ writeFileSync(queueTarget, '[]\n')
1392
+ symlinkSync(queueTarget, queue)
1393
+ expect(() =>
1394
+ orphanRetirements(
1395
+ h.env,
1396
+ [
1397
+ {
1398
+ request_id: 'req_linked',
1399
+ collapse_key: 'collapse-linked',
1400
+ device_ids: ['dev_iphone'],
1401
+ question: 'Still there?',
1402
+ state: 'expired',
1403
+ },
1404
+ ],
1405
+ 'linked-pointer',
1406
+ NOW,
1407
+ ),
1408
+ ).toThrow(/symlink/)
1409
+ expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
1410
+ })
1411
+
1412
+ it('persists each successful retirement before a later corrupt entry interrupts the drain', async () => {
1413
+ const h = harness()
1414
+ writeSessionState('partial-drain', h.env, {
1415
+ retiring: [
1416
+ {
1417
+ request_id: 'req_good',
1418
+ collapse_key: 'collapse-good',
1419
+ device_ids: ['dev_iphone'],
1420
+ question: 'First?',
1421
+ state: 'expired',
1422
+ },
1423
+ {
1424
+ request_id: 'req_bad',
1425
+ collapse_key: 'collapse-bad',
1426
+ device_ids: [],
1427
+ question: 'Second?',
1428
+ state: 'expired',
1429
+ },
1430
+ ],
1431
+ })
1432
+ const ctx = {
1433
+ client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'),
1434
+ config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
1435
+ }
1436
+
1437
+ await expect(drainRetirements(ctx, 'partial-drain', h.env)).rejects.toThrow(
1438
+ /missing its device identifiers/,
1439
+ )
1440
+
1441
+ expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
1442
+ 'req_bad',
1443
+ ])
1444
+ })
1445
+ })
1446
+
1109
1447
  /**
1110
1448
  * Path-independent ownership stops the usual cause
1111
1449
  * of two handlers firing; this stops the consequence when something else does.
@@ -1134,6 +1472,19 @@ describe('two hooks racing one question', () => {
1134
1472
  expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
1135
1473
  })
1136
1474
 
1475
+ it('serializes a contender that arrives while a stale claim is being replaced', () => {
1476
+ const h = harness()
1477
+ claimQuestionPush('race-break', h.env, REAL - 10 * 60_000)
1478
+ let contender: ReturnType<typeof claimQuestionPush> | undefined
1479
+
1480
+ const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
1481
+ contender = claimQuestionPush('race-break', h.env, REAL)
1482
+ })
1483
+
1484
+ expect(contender).toBeDefined()
1485
+ expect([recovered, contender].filter(Boolean)).toHaveLength(1)
1486
+ })
1487
+
1137
1488
  it('does not let two sessions block each other', () => {
1138
1489
  const h = harness()
1139
1490
  expect(claimQuestionPush('race4a', h.env, REAL)).toBe(true)
@@ -1153,3 +1504,45 @@ describe('two hooks racing one question', () => {
1153
1504
  expect(h.io.errLines.join(" ")).toContain('already handling')
1154
1505
  })
1155
1506
  })
1507
+
1508
+ describe('question registration racing a Stop submission', () => {
1509
+ it('preserves a newer question when the older submit publishes its request id', async () => {
1510
+ const h = harness([], 900)
1511
+ writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
1512
+ registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
1513
+ h.recorder.beforeQuestionSubmit = () => {
1514
+ h.recorder.beforeQuestionSubmit = undefined
1515
+ registerQuestion('submit-race', h.env, { question: 'New question?' }, NOW + 1)
1516
+ }
1517
+
1518
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
1519
+
1520
+ const state = readSessionState('submit-race', h.env)
1521
+ expect(state.pending?.question).toBe('New question?')
1522
+ expect(state.retiring).toContainEqual(
1523
+ expect.objectContaining({ request_id: 'req_hook_1', state: 'superseded' }),
1524
+ )
1525
+ })
1526
+
1527
+ it('keeps the newer question when the older in-flight question receives an answer', async () => {
1528
+ const h = harness([reply({ text: 'Old answer' })], 900)
1529
+ writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
1530
+ registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
1531
+ h.recorder.beforeQuestionSubmit = () => {
1532
+ h.recorder.beforeQuestionSubmit = undefined
1533
+ registerQuestion('answer-race', h.env, { question: 'New question?' }, NOW + 1)
1534
+ }
1535
+
1536
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
1537
+
1538
+ const state = readSessionState('answer-race', h.env)
1539
+ expect(state.pending?.question).toBe('New question?')
1540
+ expect(
1541
+ h.recorder.submitted.find(
1542
+ (entry) =>
1543
+ entry.draft.event === 'question_retired' &&
1544
+ entry.draft.lifecycle?.retires_request_id === 'req_hook_1',
1545
+ )?.draft.lifecycle?.state,
1546
+ ).toBe('answered')
1547
+ })
1548
+ })