@raidiant/notifai 0.1.7 → 0.2.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.
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}`)
@@ -112,7 +127,8 @@ function reply(overrides: Partial<ReplyView> = {}): ReplyView {
112
127
  device_id: 'dev_test',
113
128
  device_name: 'Furankuphone',
114
129
  text: 'Allow',
115
- choice_id: 'allow',
130
+ answers: [{ question_id: 'q1', choice_ids: ['allow'], text: null }],
131
+ source: null,
116
132
  created_at: new Date().toISOString(),
117
133
  ...overrides,
118
134
  }
@@ -224,6 +240,30 @@ describe('presence gate', () => {
224
240
  mode: 'selected',
225
241
  device_ids: ['dev_iphone', 'dev_mac'],
226
242
  })
243
+ expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
244
+ title: 'Question',
245
+ body: 'Ship it?',
246
+ })
247
+ expect(h.recorder.submitted[0]?.draft.platform).toEqual({
248
+ ios: { sound: 'attention', interruption_level: 'active' },
249
+ macos: { sound: 'attention', interruption_level: 'active' },
250
+ })
251
+ })
252
+
253
+ it('names the resolved project in the pushed question title', async () => {
254
+ const h = harness([reply({ text: 'Yes' })], 900)
255
+ const projectDir = path.join(h.deps.cwd, '.notifai')
256
+ mkdirSync(projectDir, { recursive: true })
257
+ writeFileSync(
258
+ path.join(projectDir, 'config.toml'),
259
+ 'project = "notifai-cli"\nask_grace_seconds = 0\n',
260
+ )
261
+ writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
262
+ registerQuestion('project-title', h.env, { question: 'Ship it?' }, NOW)
263
+
264
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
265
+
266
+ expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
227
267
  })
228
268
 
229
269
  it('falls back to elapsed time where no idle source exists', () => {
@@ -438,6 +478,142 @@ describe('nagging guards', () => {
438
478
  expect(h.recorder.submitted).toEqual([])
439
479
  expect(h.io.outLines).toEqual([])
440
480
  })
481
+
482
+ it('delivers a new question registered during an answer continuation', async () => {
483
+ const h = harness([reply({ text: 'First answer' })], 900)
484
+ writeSessionState('n3', h.env, { last_prompt_at: AWAY })
485
+ registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
486
+
487
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3' }))
488
+ await h.deps.sleep?.(1)
489
+ registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
490
+ h.io.outLines = []
491
+
492
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
493
+
494
+ const questions = h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')
495
+ expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
496
+ 'First question?',
497
+ 'Follow-up question?',
498
+ ])
499
+ expect(h.io.outLines.join('\n')).toContain('First answer')
500
+ })
501
+
502
+ it('stops chained questions at the consecutive continuation cap', async () => {
503
+ const h = harness([], 900)
504
+ writeSessionState('n4', h.env, {
505
+ last_prompt_at: AWAY,
506
+ continuation: { answered_at: NOW - 1, count: 3 },
507
+ })
508
+ registerQuestion('n4', h.env, { question: 'One more?' }, NOW)
509
+
510
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
511
+
512
+ expect(h.recorder.submitted).toEqual([])
513
+ expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
514
+ expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
515
+ })
516
+ })
517
+
518
+ describe('late answer collection', () => {
519
+ it('collects a late answer on Stop and resumes with it instead of asking twice', async () => {
520
+ const answers: ReplyView[] = []
521
+ const h = harness(answers, 900)
522
+ writeSessionState('late-stop', h.env, { last_prompt_at: AWAY })
523
+ registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
524
+
525
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
526
+ expect(readSessionState('late-stop', h.env).pending?.request_id).toBe('req_hook_1')
527
+ answers.push(reply({ text: 'Ship it' }))
528
+ h.io.outLines = []
529
+
530
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
531
+
532
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
533
+ expect(h.recorder.closed).toContain('req_hook_1')
534
+ expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
535
+ expect(h.io.outLines).toHaveLength(1)
536
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
537
+ decision: 'block',
538
+ reason: expect.stringContaining('Ship it'),
539
+ })
540
+ })
541
+
542
+ it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
543
+ const answers: ReplyView[] = []
544
+ const h = harness(answers, 900)
545
+ writeSessionState('late-prompt', h.env, { last_prompt_at: AWAY })
546
+ registerQuestion('late-prompt', h.env, { question: 'Ship it?' }, NOW)
547
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
548
+ answers.push(reply({ text: 'Hold' }))
549
+ h.io.outLines = []
550
+
551
+ await hookRunCommand(
552
+ h.deps,
553
+ 'user-prompt-submit',
554
+ stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
555
+ )
556
+
557
+ const retirement = h.recorder.submitted.find(
558
+ (entry) => entry.draft.event === 'question_retired',
559
+ )?.draft
560
+ expect(retirement?.lifecycle).toMatchObject({
561
+ state: 'answered',
562
+ retires_request_id: 'req_hook_1',
563
+ })
564
+ expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
565
+ expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
566
+ expect(JSON.parse(h.io.outLines[0] ?? '{}')).toEqual({
567
+ hookSpecificOutput: {
568
+ hookEventName: 'UserPromptSubmit',
569
+ additionalContext: expect.stringContaining('Hold'),
570
+ },
571
+ })
572
+ })
573
+ })
574
+
575
+ describe('OpenCode answer preservation', () => {
576
+ it('pushes without waiting and leaves the answerable request intact', async () => {
577
+ const h = harness([reply({ text: 'Approve' })], 900)
578
+ writeSessionState('open1', h.env, { last_prompt_at: AWAY })
579
+ registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
580
+
581
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
582
+
583
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
584
+ expect(h.recorder.closed).toEqual([])
585
+ expect(
586
+ h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
587
+ ).toEqual([])
588
+ expect(readSessionState('open1', h.env).pending).toMatchObject({
589
+ question: 'Deploy?',
590
+ request_id: 'req_hook_1',
591
+ device_ids: ['dev_iphone', 'dev_mac'],
592
+ })
593
+ expect(h.io.outLines).toEqual([])
594
+ })
595
+ })
596
+
597
+ describe('reply-wait presence monitoring', () => {
598
+ it('returns the terminal when the user comes back after the push', async () => {
599
+ const h = harness([], 900)
600
+ let checks = 0
601
+ h.deps.idleSeconds = () => {
602
+ checks += 1
603
+ return checks >= 4 ? 1 : 900
604
+ }
605
+ writeSessionState('returned', h.env, { last_prompt_at: AWAY })
606
+ registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
607
+
608
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
609
+
610
+ expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
611
+ expect(h.recorder.closed).toEqual([])
612
+ expect(readSessionState('returned', h.env).pending?.request_id).toBe('req_hook_1')
613
+ expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
614
+ expect(h.io.errLines.join('\n')).toContain('came back')
615
+ expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
616
+ })
441
617
  })
442
618
 
443
619
  describe('Cursor stop output', () => {
@@ -460,7 +636,7 @@ describe('Cursor stop output', () => {
460
636
  expect(h.io.outLines).toHaveLength(1)
461
637
  const output = JSON.parse(h.io.outLines[0] ?? '{}') as Record<string, unknown>
462
638
  expect(output['followup_message']).toContain(
463
- 'NotifAI — the user answered from Furankuphone: "Ship it". Continue with that answer.',
639
+ 'Notifai — the user answered from Furankuphone: "Ship it". Continue with that answer.',
464
640
  )
465
641
  expect(output).not.toHaveProperty('decision')
466
642
  })
@@ -740,11 +916,11 @@ describe('ask registration', () => {
740
916
  const h = harness()
741
917
  // Inside a hook, a rejection is only a stderr note the agent never reads —
742
918
  // so it would look registered and then silently never ask.
743
- expect(askCommand(h.deps, 'Ship it?', { choice: 'Yes|No', session: 'a1' })).toBe(EXIT.usage)
919
+ expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
744
920
  expect(readSessionState('a1', h.env).pending).toBeUndefined()
745
921
  })
746
922
 
747
- it('stores validated labels verbatim, commas and all', () => {
923
+ it('stores the validated question set, comma-bearing labels verbatim', () => {
748
924
  const h = harness()
749
925
  expect(
750
926
  askCommand(h.deps, 'Ship it?', {
@@ -752,7 +928,104 @@ describe('ask registration', () => {
752
928
  session: 'a2',
753
929
  }),
754
930
  ).toBe(EXIT.ok)
755
- expect(readSessionState('a2', h.env).pending?.choices).toEqual(['Yes, ship it', 'No, hold'])
931
+ expect(readSessionState('a2', h.env).pending?.questions).toEqual([
932
+ {
933
+ id: 'ship-it',
934
+ text: 'Ship it?',
935
+ choices: [
936
+ { id: 'yes-ship-it', label: 'Yes, ship it' },
937
+ { id: 'no-hold', label: 'No, hold' },
938
+ ],
939
+ },
940
+ ])
941
+ })
942
+
943
+ it('registers a multi-question form and pushes it as one set', async () => {
944
+ const h = harness([reply({ text: 'Yes' })], 900)
945
+ writeSessionState('form1', h.env, { last_prompt_at: AWAY })
946
+ expect(
947
+ askCommand(h.deps, undefined, {
948
+ session: 'form1',
949
+ form: JSON.stringify({
950
+ questions: [
951
+ { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
952
+ { text: 'Anything to watch?' },
953
+ ],
954
+ detail: '## Context\nThe long story.',
955
+ }),
956
+ }),
957
+ ).toBe(EXIT.ok)
958
+ const pending = readSessionState('form1', h.env).pending
959
+ expect(pending?.questions).toHaveLength(2)
960
+ expect(pending?.detail).toContain('long story')
961
+
962
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
963
+ const draft = h.recorder.submitted[0]?.draft
964
+ // The banner leads with the first question and admits the rest exists;
965
+ // the full set rides the payload for the answering card.
966
+ expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
967
+ expect(draft?.presentation.detail).toContain('long story')
968
+ expect(draft?.reply?.questions).toEqual([
969
+ {
970
+ id: 'deploy-where',
971
+ text: 'Deploy where?',
972
+ choices: [
973
+ { id: 'staging', label: 'Staging' },
974
+ { id: 'production', label: 'Production' },
975
+ ],
976
+ multi: true,
977
+ },
978
+ { id: 'anything-to-watch', text: 'Anything to watch?' },
979
+ ])
980
+ })
981
+
982
+ it('acts on the latest reply when answers conflict, because it is a correction', async () => {
983
+ const h = harness(
984
+ [
985
+ reply({ seq: 1, text: 'Yes', answers: [{ question_id: 'ship-it', choice_ids: ['yes'], text: null }] }),
986
+ reply({
987
+ seq: 2,
988
+ reply_id: 'rpl_2',
989
+ text: 'No',
990
+ answers: [{ question_id: 'ship-it', choice_ids: ['no'], text: null }],
991
+ }),
992
+ ],
993
+ 900,
994
+ )
995
+ writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
996
+ expect(
997
+ askCommand(h.deps, 'Ship it?', { session: 'latest1', choice: ['Yes', 'No'] }),
998
+ ).toBe(EXIT.ok)
999
+
1000
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
1001
+
1002
+ const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
1003
+ expect(decision.decision).toBe('block')
1004
+ expect(decision.reason).toContain('"No"')
1005
+ expect(decision.reason).not.toContain('"Yes"')
1006
+ })
1007
+
1008
+ it('hands a free-text answer written in parts to the agent in order', async () => {
1009
+ const h = harness(
1010
+ [
1011
+ reply({ seq: 1, text: 'Use blue', answers: [{ question_id: 'which-color', choice_ids: [], text: 'Use blue' }] }),
1012
+ reply({
1013
+ seq: 2,
1014
+ reply_id: 'rpl_2',
1015
+ text: 'actually teal',
1016
+ answers: [{ question_id: 'which-color', choice_ids: [], text: 'actually teal' }],
1017
+ }),
1018
+ ],
1019
+ 900,
1020
+ )
1021
+ writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
1022
+ expect(askCommand(h.deps, 'Which color?', { session: 'parts1' })).toBe(EXIT.ok)
1023
+
1024
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
1025
+
1026
+ const decision = JSON.parse(h.io.outLines.at(-1)!) as { reason: string }
1027
+ expect(decision.reason).toContain('in the order written')
1028
+ expect(decision.reason).toContain('"Use blue", then "actually teal"')
756
1029
  })
757
1030
 
758
1031
  it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
@@ -760,7 +1033,12 @@ describe('ask registration', () => {
760
1033
  // harness's own id, so the pointer must outrank the env var.
761
1034
  const h = harness()
762
1035
  h.deps.env['NOTIFAI_SESSION'] = 'my-label'
763
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'real1', cwd: h.deps.cwd }))
1036
+ await hookRunCommand(
1037
+ h.deps,
1038
+ 'user-prompt-submit',
1039
+ stdin({ session_id: 'real1', cwd: h.deps.cwd }),
1040
+ 'claude-code',
1041
+ )
764
1042
  expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
765
1043
  expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
766
1044
  expect(readSessionState('my-label', h.env).pending).toBeUndefined()
@@ -798,12 +1076,34 @@ describe('user-prompt-submit hook', () => {
798
1076
  expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
799
1077
  })
800
1078
 
1079
+ it('diagnoses malformed or truncated hook input instead of silently doing nothing', async () => {
1080
+ const h = harness()
1081
+
1082
+ expect(await hookRunCommand(h.deps, 'user-prompt-submit', async () => '{"session_id":')).toBe(
1083
+ EXIT.ok,
1084
+ )
1085
+
1086
+ expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
1087
+ })
1088
+
1089
+ it('explains when a missing machine credential makes a hook skip routing', async () => {
1090
+ const h = harness()
1091
+ h.deps.store.load = () => null
1092
+
1093
+ expect(
1094
+ await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'unpaired' })),
1095
+ ).toBe(EXIT.ok)
1096
+
1097
+ expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
1098
+ })
1099
+
801
1100
  it('preserves a delivered question through ask replacement and prompt transition', async () => {
802
1101
  const h = harness([], 900)
803
1102
  await hookRunCommand(
804
1103
  h.deps,
805
1104
  'user-prompt-submit',
806
1105
  stdin({ session_id: 'transition', cwd: h.deps.cwd }),
1106
+ 'claude-code',
807
1107
  )
808
1108
  expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
809
1109
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
@@ -834,6 +1134,7 @@ describe('user-prompt-submit hook', () => {
834
1134
  h.deps,
835
1135
  'user-prompt-submit',
836
1136
  stdin({ session_id: 'transition', cwd: h.deps.cwd }),
1137
+ 'claude-code',
837
1138
  )
838
1139
 
839
1140
  const retirement = h.recorder.submitted.find(
@@ -919,12 +1220,20 @@ describe('user-prompt-submit hook', () => {
919
1220
  const project = realpathSync(viaSymlink)
920
1221
  if (project === viaSymlink) return // no symlink on this platform; nothing to prove
921
1222
 
922
- await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's14', cwd: viaSymlink }))
1223
+ await hookRunCommand(
1224
+ h.deps,
1225
+ 'user-prompt-submit',
1226
+ stdin({ session_id: 's14', cwd: viaSymlink }),
1227
+ 'claude-code',
1228
+ )
923
1229
 
924
1230
  expect(readProjectSession(project, h.env, NOW)).toBe('s14')
925
1231
  expect(readProjectSession(viaSymlink, h.env, NOW)).toBe('s14')
926
1232
  // A pointer older than a day is not evidence of a live session.
927
1233
  expect(readProjectSession(project, h.env, NOW + 2 * 24 * 3600 * 1000)).toBeNull()
1234
+
1235
+ clearSessionState('s14', h.env)
1236
+ expect(readProjectSession(project, h.env, NOW)).toBeNull()
928
1237
  })
929
1238
  })
930
1239
 
@@ -940,6 +1249,26 @@ describe('session-end hook', () => {
940
1249
  expect(h.recorder.closed).toEqual([])
941
1250
  })
942
1251
 
1252
+ it('clears only the ending session project pointer', async () => {
1253
+ const h = harness()
1254
+ await hookRunCommand(
1255
+ h.deps,
1256
+ 'user-prompt-submit',
1257
+ stdin({ session_id: 'ending', cwd: h.deps.cwd }),
1258
+ 'claude-code',
1259
+ )
1260
+ expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
1261
+
1262
+ await hookRunCommand(
1263
+ h.deps,
1264
+ 'session-end',
1265
+ stdin({ session_id: 'ending', cwd: h.deps.cwd }),
1266
+ 'claude-code',
1267
+ )
1268
+
1269
+ expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBeNull()
1270
+ })
1271
+
943
1272
  it('preserves incomplete live state and reports why it cannot retire it', async () => {
944
1273
  const h = harness()
945
1274
  writeSessionState('s-incomplete', h.env, {
@@ -1106,6 +1435,113 @@ describe('pruning abandoned session state', () => {
1106
1435
  })
1107
1436
  })
1108
1437
 
1438
+ describe('durable state writes', () => {
1439
+ function sessionFile(h: Harness, sessionId: string): string {
1440
+ return path.join(
1441
+ h.env['XDG_STATE_HOME'] as string,
1442
+ 'notifai',
1443
+ 'sessions',
1444
+ `${sanitizeSessionId(sessionId)}.json`,
1445
+ )
1446
+ }
1447
+
1448
+ it('recovers a truncated state file with one complete atomic document', () => {
1449
+ const h = harness()
1450
+ const file = sessionFile(h, 'truncated')
1451
+ mkdirSync(path.dirname(file), { recursive: true })
1452
+ writeFileSync(file, '{"pending":')
1453
+ expect(readSessionState('truncated', h.env)).toEqual({})
1454
+
1455
+ writeSessionState('truncated', h.env, { last_prompt_at: NOW })
1456
+
1457
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ last_prompt_at: NOW })
1458
+ expect(readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp'))).toEqual([])
1459
+ })
1460
+
1461
+ it('refuses a session-state symlink instead of writing through it', () => {
1462
+ const h = harness()
1463
+ const file = sessionFile(h, 'linked')
1464
+ const target = path.join(path.dirname(file), 'target.json')
1465
+ mkdirSync(path.dirname(file), { recursive: true })
1466
+ writeFileSync(target, '{"untouched":true}\n')
1467
+ symlinkSync(target, file)
1468
+
1469
+ expect(() => writeSessionState('linked', h.env, { last_prompt_at: NOW })).toThrow(/symlink/)
1470
+ expect(readFileSync(target, 'utf8')).toBe('{"untouched":true}\n')
1471
+ })
1472
+
1473
+ it('refuses project-pointer and retirement-queue symlinks too', () => {
1474
+ const h = harness()
1475
+ writeSessionState('linked-pointer', h.env, { last_prompt_at: NOW })
1476
+ const target = path.join(h.env['XDG_STATE_HOME'] as string, 'pointer-target.json')
1477
+ writeFileSync(target, '{"untouched":true}\n')
1478
+ const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
1479
+ mkdirSync(path.dirname(pointer), { recursive: true })
1480
+ symlinkSync(target, pointer)
1481
+
1482
+ expect(() =>
1483
+ writeProjectSession(h.deps.cwd, h.env, 'linked-pointer', NOW, 'claude-code'),
1484
+ ).toThrow(/symlink/)
1485
+ expect(readFileSync(target, 'utf8')).toBe('{"untouched":true}\n')
1486
+
1487
+ const queueTarget = path.join(h.env['XDG_STATE_HOME'] as string, 'queue-target.json')
1488
+ const queue = path.join(h.env['XDG_STATE_HOME'] as string, 'notifai', 'retire-queue.json')
1489
+ writeFileSync(queueTarget, '[]\n')
1490
+ symlinkSync(queueTarget, queue)
1491
+ expect(() =>
1492
+ orphanRetirements(
1493
+ h.env,
1494
+ [
1495
+ {
1496
+ request_id: 'req_linked',
1497
+ collapse_key: 'collapse-linked',
1498
+ device_ids: ['dev_iphone'],
1499
+ question: 'Still there?',
1500
+ state: 'expired',
1501
+ },
1502
+ ],
1503
+ 'linked-pointer',
1504
+ NOW,
1505
+ ),
1506
+ ).toThrow(/symlink/)
1507
+ expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
1508
+ })
1509
+
1510
+ it('persists each successful retirement before a later corrupt entry interrupts the drain', async () => {
1511
+ const h = harness()
1512
+ writeSessionState('partial-drain', h.env, {
1513
+ retiring: [
1514
+ {
1515
+ request_id: 'req_good',
1516
+ collapse_key: 'collapse-good',
1517
+ device_ids: ['dev_iphone'],
1518
+ question: 'First?',
1519
+ state: 'expired',
1520
+ },
1521
+ {
1522
+ request_id: 'req_bad',
1523
+ collapse_key: 'collapse-bad',
1524
+ device_ids: [],
1525
+ question: 'Second?',
1526
+ state: 'expired',
1527
+ },
1528
+ ],
1529
+ })
1530
+ const ctx = {
1531
+ client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'),
1532
+ config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
1533
+ }
1534
+
1535
+ await expect(drainRetirements(ctx, 'partial-drain', h.env)).rejects.toThrow(
1536
+ /missing its device identifiers/,
1537
+ )
1538
+
1539
+ expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
1540
+ 'req_bad',
1541
+ ])
1542
+ })
1543
+ })
1544
+
1109
1545
  /**
1110
1546
  * Path-independent ownership stops the usual cause
1111
1547
  * of two handlers firing; this stops the consequence when something else does.
@@ -1134,6 +1570,19 @@ describe('two hooks racing one question', () => {
1134
1570
  expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
1135
1571
  })
1136
1572
 
1573
+ it('serializes a contender that arrives while a stale claim is being replaced', () => {
1574
+ const h = harness()
1575
+ claimQuestionPush('race-break', h.env, REAL - 10 * 60_000)
1576
+ let contender: ReturnType<typeof claimQuestionPush> | undefined
1577
+
1578
+ const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
1579
+ contender = claimQuestionPush('race-break', h.env, REAL)
1580
+ })
1581
+
1582
+ expect(contender).toBeDefined()
1583
+ expect([recovered, contender].filter(Boolean)).toHaveLength(1)
1584
+ })
1585
+
1137
1586
  it('does not let two sessions block each other', () => {
1138
1587
  const h = harness()
1139
1588
  expect(claimQuestionPush('race4a', h.env, REAL)).toBe(true)
@@ -1153,3 +1602,45 @@ describe('two hooks racing one question', () => {
1153
1602
  expect(h.io.errLines.join(" ")).toContain('already handling')
1154
1603
  })
1155
1604
  })
1605
+
1606
+ describe('question registration racing a Stop submission', () => {
1607
+ it('preserves a newer question when the older submit publishes its request id', async () => {
1608
+ const h = harness([], 900)
1609
+ writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
1610
+ registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
1611
+ h.recorder.beforeQuestionSubmit = () => {
1612
+ h.recorder.beforeQuestionSubmit = undefined
1613
+ registerQuestion('submit-race', h.env, { question: 'New question?' }, NOW + 1)
1614
+ }
1615
+
1616
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
1617
+
1618
+ const state = readSessionState('submit-race', h.env)
1619
+ expect(state.pending?.question).toBe('New question?')
1620
+ expect(state.retiring).toContainEqual(
1621
+ expect.objectContaining({ request_id: 'req_hook_1', state: 'superseded' }),
1622
+ )
1623
+ })
1624
+
1625
+ it('keeps the newer question when the older in-flight question receives an answer', async () => {
1626
+ const h = harness([reply({ text: 'Old answer' })], 900)
1627
+ writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
1628
+ registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
1629
+ h.recorder.beforeQuestionSubmit = () => {
1630
+ h.recorder.beforeQuestionSubmit = undefined
1631
+ registerQuestion('answer-race', h.env, { question: 'New question?' }, NOW + 1)
1632
+ }
1633
+
1634
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
1635
+
1636
+ const state = readSessionState('answer-race', h.env)
1637
+ expect(state.pending?.question).toBe('New question?')
1638
+ expect(
1639
+ h.recorder.submitted.find(
1640
+ (entry) =>
1641
+ entry.draft.event === 'question_retired' &&
1642
+ entry.draft.lifecycle?.retires_request_id === 'req_hook_1',
1643
+ )?.draft.lifecycle?.state,
1644
+ ).toBe('answered')
1645
+ })
1646
+ })