@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.
@@ -13,6 +13,8 @@ import { describe, expect, it } from 'vitest'
13
13
  import { ApiCallError, NetworkError, type ApiClient } from './client.js'
14
14
  import {
15
15
  askCommand,
16
+ accessStatusCommand,
17
+ buildQuestions,
16
18
  assessReadiness,
17
19
  capabilitiesCommand,
18
20
  configSetCommand,
@@ -34,10 +36,13 @@ import {
34
36
  type CommandSpinner,
35
37
  } from './commands.js'
36
38
  import { applyPlan, buildHookConfig } from './install-hooks.js'
39
+ import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
40
+ import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
37
41
 
38
42
  class CapturedIo implements CommandIo {
39
43
  outLines: string[] = []
40
44
  errLines: string[] = []
45
+ openedUrls: string[] = []
41
46
 
42
47
  out(line: string) {
43
48
  this.outLines.push(line)
@@ -51,12 +56,16 @@ class CapturedIo implements CommandIo {
51
56
  return false
52
57
  }
53
58
 
54
- openUrl() {}
59
+ openUrl(url: string) {
60
+ this.openedUrls.push(url)
61
+ }
55
62
  }
56
63
 
57
64
  class InteractiveIo extends CapturedIo {
58
65
  interactive = true
59
66
  selectAnswer: string | null = 'global'
67
+ /** When set, answers are consumed in order; falls back to confirmAnswer. */
68
+ confirmAnswers: boolean[] | null = null
60
69
  confirmAnswer = true
61
70
  prompts: string[] = []
62
71
  notes: { message: string; title?: string }[] = []
@@ -67,6 +76,9 @@ class InteractiveIo extends CapturedIo {
67
76
 
68
77
  override async confirm(question: string) {
69
78
  this.prompts.push(question)
79
+ if (this.confirmAnswers !== null && this.confirmAnswers.length > 0) {
80
+ return this.confirmAnswers.shift()!
81
+ }
70
82
  return this.confirmAnswer
71
83
  }
72
84
 
@@ -163,6 +175,23 @@ function replyResponse(replies: ReplyView[] = []): ListRepliesResponse {
163
175
  }
164
176
 
165
177
  describe('command contracts', () => {
178
+ it('shows an actionable no-plan access state', async () => {
179
+ const io = new CapturedIo()
180
+ const client = {
181
+ accessStatus: async () => ({
182
+ status: 'no_active_plan',
183
+ reason: 'no_active_grant',
184
+ expires_at: null,
185
+ }),
186
+ } as unknown as ApiClient
187
+
188
+ expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
189
+ expect(io.outLines).toEqual([
190
+ 'No active plan or temporary Alpha access for this account.',
191
+ 'next: Open https://test.notifai.invalid/support to request Alpha access, then retry.',
192
+ ])
193
+ })
194
+
166
195
  it('renders capability field paths instead of array indexes', async () => {
167
196
  const io = new CapturedIo()
168
197
  const document: CapabilityDocument = {
@@ -217,9 +246,91 @@ describe('command contracts', () => {
217
246
  },
218
247
  } as unknown as ApiClient
219
248
 
220
- expect(await sendCommand(makeDeps(io, client), { title: 'T', body: 'B', badge: -1 })).toBe(EXIT.usage)
249
+ expect(
250
+ await sendCommand(makeDeps(io, client), {
251
+ title: 'T',
252
+ body: 'B',
253
+ project: 'Invalid Project!',
254
+ }),
255
+ ).toBe(EXIT.usage)
256
+ expect(submitCalls).toBe(0)
257
+ expect(io.errLines.join('\n')).toContain('project')
258
+ })
259
+
260
+ it('rejects done plus reply before authentication or submission', async () => {
261
+ const io = new CapturedIo()
262
+ let submitCalls = 0
263
+ const client = {
264
+ submit: async () => {
265
+ submitCalls += 1
266
+ return receipt
267
+ },
268
+ } as unknown as ApiClient
269
+
270
+ expect(
271
+ await sendCommand(makeDeps(io, client), {
272
+ title: 'Done',
273
+ body: 'Answer?',
274
+ kind: 'done',
275
+ reply: true,
276
+ }),
277
+ ).toBe(EXIT.usage)
221
278
  expect(submitCalls).toBe(0)
222
- expect(io.errLines.join('\n')).toContain('platform.ios.badge')
279
+ expect(io.errLines.join('\n')).toContain('--kind done cannot be combined with --reply')
280
+ })
281
+
282
+ it.each([
283
+ {
284
+ flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
285
+ warning: /titles work best around 40/i,
286
+ },
287
+ {
288
+ flags: { title: 'Details', body: '**bold Markdown**' },
289
+ warning: /--body looks like Markdown.*--detail/i,
290
+ },
291
+ {
292
+ flags: { title: 'Done · build', body: 'All green.' },
293
+ warning: /Use --kind done/i,
294
+ },
295
+ {
296
+ flags: { title: 'Failed · build', body: 'One integration test failed.' },
297
+ warning: /Use --kind done/i,
298
+ },
299
+ {
300
+ flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
301
+ warning: /longer than 72 hours/i,
302
+ },
303
+ ])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
304
+ const io = new CapturedIo()
305
+ const client = { submit: async () => receipt } as unknown as ApiClient
306
+
307
+ expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
308
+ expect(io.errLines.join('\n')).toMatch(warning)
309
+ })
310
+
311
+ it('warns when a collapse key comes from machine-global config', async () => {
312
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-global-collapse-'))
313
+ const configDir = path.join(root, 'notifai')
314
+ mkdirSync(configDir, { recursive: true })
315
+ writeFileSync(path.join(configDir, 'config.toml'), 'collapse_key = "global-key"\n')
316
+ const io = new CapturedIo()
317
+ const client = { submit: async () => receipt } as unknown as ApiClient
318
+ const deps = {
319
+ ...makeDeps(io, client),
320
+ env: { XDG_CONFIG_HOME: root },
321
+ cwd: root,
322
+ }
323
+
324
+ expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
325
+ expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
326
+ })
327
+
328
+ it('removes unmanaged provider fields from the public send flags', () => {
329
+ const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
330
+ expect(source).not.toContain(".option('--badge")
331
+ expect(source).not.toContain(".option('--relevance")
332
+ expect(source).not.toContain(".option('--target-content-id")
333
+ expect(source).toContain('Kind profiles')
223
334
  })
224
335
 
225
336
  it('rejects a question nobody will wait for', async () => {
@@ -269,7 +380,10 @@ describe('command contracts', () => {
269
380
  replyTimeout: 30,
270
381
  }),
271
382
  ).toBe(EXIT.ok)
272
- expect(submitted?.draft.reply).toEqual({ expires_in_seconds: 3_600 })
383
+ expect(submitted?.draft.reply).toEqual({
384
+ expires_in_seconds: 3_600,
385
+ questions: [{ id: 'q1', text: 'Deploy?' }],
386
+ })
273
387
  })
274
388
 
275
389
  it('rejects --reply-timeout 0, the other spelling of nobody waiting', async () => {
@@ -431,12 +545,13 @@ describe('command contracts', () => {
431
545
  expect(sleeps).toEqual([250])
432
546
  })
433
547
 
434
- it('returns exit 3 with one JSON object when no reply arrives before the timeout', async () => {
548
+ it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
435
549
  const io = new CapturedIo()
436
550
  let now = 0
437
551
  const client = {
438
552
  submit: async () => receipt,
439
553
  replies: async (_requestId: string, options: { waitSeconds: number }) => {
554
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
440
555
  now += options.waitSeconds * 1_000
441
556
  return replyResponse()
442
557
  },
@@ -452,13 +567,20 @@ describe('command contracts', () => {
452
567
  json: true,
453
568
  }),
454
569
  ).toBe(EXIT.noReply)
455
- expect(io.outLines).toHaveLength(1)
570
+ expect(io.outLines).toHaveLength(2)
456
571
  // `degraded` is part of the shape on every reply wait, not only when it is
457
572
  // true: an agent must be able to read it without knowing it might be absent.
458
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ receipt, replies: [], degraded: false })
573
+ expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
574
+ type: 'reply_result',
575
+ request_id: receipt.request_id,
576
+ replies: [],
577
+ degraded: false,
578
+ })
579
+ expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
580
+ expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
459
581
  })
460
582
 
461
- it('prints the stable send JSON shape when a reply is received', async () => {
583
+ it('prints the receipt and reply result as two NDJSON records when answered', async () => {
462
584
  const io = new CapturedIo()
463
585
  const client = {
464
586
  submit: async () => receipt,
@@ -474,9 +596,11 @@ describe('command contracts', () => {
474
596
  json: true,
475
597
  }),
476
598
  ).toBe(EXIT.ok)
477
- expect(io.outLines).toHaveLength(1)
478
- expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
479
- receipt,
599
+ expect(io.outLines).toHaveLength(2)
600
+ expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({ type: 'receipt', receipt })
601
+ expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
602
+ type: 'reply_result',
603
+ request_id: receipt.request_id,
480
604
  replies: [reply],
481
605
  degraded: false,
482
606
  })
@@ -496,6 +620,30 @@ describe('command contracts', () => {
496
620
  expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
497
621
  expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
498
622
  })
623
+
624
+ it('resolves replies --pending through the project session pointer and prints its id', async () => {
625
+ const io = new CapturedIo()
626
+ const client = {
627
+ replies: async () => replyResponse([reply]),
628
+ } as unknown as ApiClient
629
+ const deps = makeDeps(io, client)
630
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-replies-'))
631
+ deps.cwd = root
632
+ deps.env = {
633
+ XDG_CONFIG_HOME: path.join(root, 'config'),
634
+ XDG_STATE_HOME: path.join(root, 'state'),
635
+ }
636
+ writeSessionState('pending-session', deps.env, {
637
+ pending: { question: 'Deploy?', request_id: receipt.request_id },
638
+ })
639
+ writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
640
+
641
+ expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
642
+ expect(io.outLines).toEqual([
643
+ `pending request ${receipt.request_id}`,
644
+ 'reply from iPhone: yes, after the migration',
645
+ ])
646
+ })
499
647
  })
500
648
 
501
649
  describe('delivery evidence status', () => {
@@ -549,7 +697,7 @@ describe('delivery evidence status', () => {
549
697
  expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
550
698
  const said = io.outLines.join('\n')
551
699
  expect(said).toContain('Provider Acceptance: accepted')
552
- expect(said).toContain('Companion Receipt: unknown')
700
+ expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
553
701
  expect(said).toContain('not a failure')
554
702
  expect(said).toContain('attempt_started')
555
703
  })
@@ -567,7 +715,7 @@ describe('delivery evidence status', () => {
567
715
 
568
716
  expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
569
717
  const said = io.outLines.join('\n')
570
- expect(said).toContain('Companion Receipt: observed')
718
+ expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
571
719
  expect(said).toContain('11m 27s after Provider Acceptance')
572
720
  })
573
721
  })
@@ -576,7 +724,7 @@ describe('Cursor hook commands', () => {
576
724
  const execPath = '/usr/local/bin/node'
577
725
  const scriptPath = '/opt/notifai/dist/main.js'
578
726
 
579
- it('installs native Cursor hooks with a single bounded answer continuation', () => {
727
+ it('installs native Cursor hooks with bounded chained answer continuations', () => {
580
728
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
581
729
  const io = new CapturedIo()
582
730
  const deps = { ...makeDeps(io, {} as ApiClient), cwd }
@@ -602,7 +750,7 @@ describe('Cursor hook commands', () => {
602
750
  )
603
751
  expect(installed.hooks['stop']?.[0]).toMatchObject({
604
752
  command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
605
- loop_limit: 1,
753
+ loop_limit: 3,
606
754
  })
607
755
  })
608
756
 
@@ -638,7 +786,7 @@ describe('Cursor hook commands', () => {
638
786
  expect(io.outLines.some((line) => line.includes('Cursor: send one prompt'))).toBe(true)
639
787
  })
640
788
 
641
- it('uninstalls only NotifAI Cursor hooks and preserves foreign hooks', () => {
789
+ it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
642
790
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-uninstall-'))
643
791
  const io = new CapturedIo()
644
792
  const deps = { ...makeDeps(io, {} as ApiClient), cwd }
@@ -714,7 +862,7 @@ describe('harness activation guidance', () => {
714
862
  )
715
863
 
716
864
  expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
717
- expect(io.outLines.join('\n')).toContain('cannot reliably resume an idle agent turn')
865
+ expect(io.outLines.join('\n')).toContain('next user prompt')
718
866
  const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
719
867
  const plugin = readFileSync(pluginFile, 'utf8')
720
868
  expect(plugin).toContain('const TIMEOUT_MS = 540000')
@@ -735,7 +883,7 @@ describe('harness activation guidance', () => {
735
883
  describe('projectSlugFrom', () => {
736
884
  it('canonicalizes directory names into contract-valid slugs', () => {
737
885
  expect(projectSlugFrom('My App')).toBe('my-app')
738
- expect(projectSlugFrom('NotifAI')).toBe('notifai')
886
+ expect(projectSlugFrom('Notifai')).toBe('notifai')
739
887
  expect(projectSlugFrom('--weird__Name.2')).toBe('weird__name.2')
740
888
  expect(projectSlugFrom('!!!')).toBe('project')
741
889
  })
@@ -777,7 +925,7 @@ describe('interactive command UX', () => {
777
925
  }
778
926
 
779
927
  expect(await loginCommand(deps, { name: 'workstation', open: false })).toBe(EXIT.ok)
780
- expect(io.intros).toEqual(['NotifAI sign in'])
928
+ expect(io.intros).toEqual(['Notifai sign in'])
781
929
  expect(io.notes).toEqual([
782
930
  {
783
931
  title: 'Approve this machine',
@@ -785,8 +933,8 @@ describe('interactive command UX', () => {
785
933
  },
786
934
  ])
787
935
  expect(io.spinnerEvents).toEqual([
788
- 'start:Waiting for approval…',
789
- 'message:Waiting for approval…',
936
+ 'start:Waiting for approval… code ABCD-EFGH · 10s left',
937
+ 'message:Waiting for approval… code ABCD-EFGH · 9s left',
790
938
  'stop:Machine "workstation" approved',
791
939
  ])
792
940
  expect(io.outLines).toEqual([])
@@ -899,7 +1047,7 @@ describe('interactive command UX', () => {
899
1047
  }
900
1048
 
901
1049
  expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
902
- expect(io.intros).toEqual(['NotifAI doctor'])
1050
+ expect(io.intros).toEqual(['Notifai doctor'])
903
1051
  expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
904
1052
  expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
905
1053
  expect(io.outLines).toEqual([])
@@ -995,6 +1143,47 @@ describe('init', () => {
995
1143
  }
996
1144
  }
997
1145
 
1146
+ function managedSkill(scope: SkillScope, cwd: string): NativeSkill {
1147
+ return {
1148
+ name: 'notifai',
1149
+ scope,
1150
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
1151
+ source: 'Raidiant-io/notifai',
1152
+ sourceType: 'github',
1153
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1154
+ ref: 'v0.2.0',
1155
+ }
1156
+ }
1157
+
1158
+ function setupReadyDeps(
1159
+ io: CapturedIo,
1160
+ cwd: string,
1161
+ nativeSkills: NativeSkills,
1162
+ calls: { submit: number },
1163
+ ): CommandDeps {
1164
+ const client = {
1165
+ health: async () => true,
1166
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1167
+ listDevices: async () => ({ devices: [readyIphone] }),
1168
+ submit: async () => {
1169
+ calls.submit += 1
1170
+ return setupReceipt()
1171
+ },
1172
+ evidence: async () =>
1173
+ setupEvidence('req_setup', {
1174
+ state: 'observed',
1175
+ observed_at: '2026-08-05T18:00:02.000Z',
1176
+ latency_ms: 1_000,
1177
+ }),
1178
+ } as unknown as ApiClient
1179
+ return {
1180
+ ...makeDeps(io, client),
1181
+ cwd,
1182
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1183
+ nativeSkills,
1184
+ }
1185
+ }
1186
+
998
1187
  it('writes the project identifier into .notifai/config.toml and is idempotent', async () => {
999
1188
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'My Project-'))
1000
1189
  const io = new CapturedIo()
@@ -1033,6 +1222,7 @@ describe('init', () => {
1033
1222
  const out = io.outLines.join('\n')
1034
1223
  expect(out).toContain('Next: This machine')
1035
1224
  expect(out).toContain('notifai login')
1225
+ expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
1036
1226
  // The device gap is real and downstream; it must stay hidden until the
1037
1227
  // sign-in that would let anyone actually check it has happened.
1038
1228
  expect(out).not.toContain('companion app')
@@ -1064,45 +1254,201 @@ describe('init', () => {
1064
1254
  })
1065
1255
 
1066
1256
  it('pins the skill installer to the tagged public release syntax', () => {
1067
- expect(SKILLS_SOURCE).toBe('RafaelVidaurre/notifai#v0.1.7')
1257
+ expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.2.0')
1068
1258
  expect(SKILLS_SOURCE).not.toContain('@v')
1069
1259
  })
1070
1260
 
1071
1261
  it('recognizes a skill installed from the exact immutable release', async () => {
1072
1262
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-pinned-skill-'))
1073
- mkdirSync(path.join(cwd, '.agents', 'skills', 'notifai'), { recursive: true })
1074
- writeFileSync(path.join(cwd, '.agents', 'skills', 'notifai', 'SKILL.md'), '# notifai\n')
1075
- writeFileSync(
1076
- path.join(cwd, 'skills-lock.json'),
1077
- `${JSON.stringify(
1078
- {
1079
- version: 1,
1080
- skills: {
1081
- notifai: {
1082
- source: 'RafaelVidaurre/notifai',
1083
- ref: 'v0.1.7',
1084
- sourceType: 'github',
1085
- skillPath: 'skills/notifai/SKILL.md',
1086
- },
1087
- },
1088
- },
1089
- null,
1090
- 2,
1091
- )}\n`,
1092
- )
1093
1263
  const io = new CapturedIo()
1094
1264
  const client = {
1095
1265
  health: async () => true,
1096
1266
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1097
1267
  listDevices: async () => ({ devices: [] }),
1098
1268
  } as unknown as ApiClient
1099
- const readiness = await assessReadiness({ ...makeDeps(io, client), cwd })
1269
+ const nativeSkills: NativeSkills = {
1270
+ add: async () => 0,
1271
+ list: async (scope) => ({
1272
+ skills: [
1273
+ {
1274
+ name: 'notifai',
1275
+ scope,
1276
+ path: path.join(cwd, '.agents', 'skills', 'notifai'),
1277
+ source: 'Raidiant-io/notifai',
1278
+ sourceType: 'github',
1279
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
1280
+ ref: 'v0.2.0',
1281
+ },
1282
+ ],
1283
+ }),
1284
+ }
1285
+ const readiness = await assessReadiness(
1286
+ { ...makeDeps(io, client), cwd, nativeSkills },
1287
+ { skillScope: 'project' },
1288
+ )
1100
1289
  expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
1101
1290
  status: 'ready',
1102
- detail: `installed from ${SKILLS_SOURCE} in this project`,
1291
+ detail: `installed from ${SKILLS_SOURCE} in the project scope`,
1103
1292
  })
1104
1293
  })
1105
1294
 
1295
+ it.each(['project', 'global'] as const)(
1296
+ 'recognizes native installer provenance in the selected %s scope',
1297
+ async (scope) => {
1298
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-managed-${scope}-`))
1299
+ const io = new CapturedIo()
1300
+ const client = {
1301
+ health: async () => true,
1302
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1303
+ listDevices: async () => ({ devices: [] }),
1304
+ } as unknown as ApiClient
1305
+ const calls: SkillScope[] = []
1306
+ const nativeSkills: NativeSkills = {
1307
+ add: async () => 0,
1308
+ list: async (selected) => {
1309
+ calls.push(selected)
1310
+ return { skills: [managedSkill(selected, cwd)] }
1311
+ },
1312
+ }
1313
+
1314
+ const readiness = await assessReadiness(
1315
+ { ...makeDeps(io, client), cwd, nativeSkills },
1316
+ { skillScope: scope },
1317
+ )
1318
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
1319
+ status: 'ready',
1320
+ detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
1321
+ })
1322
+ expect(calls).toEqual([scope])
1323
+ },
1324
+ )
1325
+
1326
+ it.each(['project', 'global'] as const)(
1327
+ 'does not trust unmanaged same-path content in the %s scope',
1328
+ async (scope) => {
1329
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-unmanaged-${scope}-`))
1330
+ const io = new CapturedIo()
1331
+ const client = {
1332
+ health: async () => true,
1333
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1334
+ listDevices: async () => ({ devices: [] }),
1335
+ } as unknown as ApiClient
1336
+ const nativeSkills: NativeSkills = {
1337
+ add: async () => 0,
1338
+ list: async (selected) => ({
1339
+ skills: [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }],
1340
+ }),
1341
+ }
1342
+
1343
+ const readiness = await assessReadiness(
1344
+ { ...makeDeps(io, client), cwd, nativeSkills },
1345
+ { skillScope: scope },
1346
+ )
1347
+ expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
1348
+ status: 'optional-gap',
1349
+ detail: `not installed from ${SKILLS_SOURCE} in ${scope} scope`,
1350
+ })
1351
+ },
1352
+ )
1353
+
1354
+ it('requires an explicit skill scope before unattended installation', async () => {
1355
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
1356
+ const io = new CapturedIo()
1357
+ let addCalls = 0
1358
+ const nativeSkills: NativeSkills = {
1359
+ add: async () => {
1360
+ addCalls += 1
1361
+ return 0
1362
+ },
1363
+ list: async () => ({ skills: [] }),
1364
+ }
1365
+
1366
+ expect(await initCommand({ ...makeDeps(io, {} as ApiClient), cwd, nativeSkills }, { skills: true })).toBe(
1367
+ EXIT.usage,
1368
+ )
1369
+ expect(addCalls).toBe(0)
1370
+ expect(io.errLines.join('\n')).toContain('--skills-scope project')
1371
+ })
1372
+
1373
+ it('rejects an invalid unattended skill scope instead of guessing', async () => {
1374
+ const io = new CapturedIo()
1375
+ expect(
1376
+ await initCommand(
1377
+ { ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-skill-invalid-')) },
1378
+ { skills: true, skillsScope: 'machine' as SkillScope },
1379
+ ),
1380
+ ).toBe(EXIT.usage)
1381
+ expect(io.errLines.join('\n')).toContain('Choose `project` or `global`')
1382
+ })
1383
+
1384
+ it.each(['project', 'global'] as const)(
1385
+ 'passes an unattended %s choice to the native installer and continues setup',
1386
+ async (scope) => {
1387
+ const cwd = mkdtempSync(path.join(os.tmpdir(), `init-skill-${scope}-`))
1388
+ const io = new CapturedIo()
1389
+ const calls: { submit: number } = { submit: 0 }
1390
+ let installed = false
1391
+ let receivedScope: SkillScope | undefined
1392
+ const nativeSkills: NativeSkills = {
1393
+ add: async (options) => {
1394
+ receivedScope = options.scope
1395
+ installed = true
1396
+ return 0
1397
+ },
1398
+ list: async (selected) => ({
1399
+ skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
1400
+ }),
1401
+ }
1402
+
1403
+ const result = await initCommand(
1404
+ setupReadyDeps(io, cwd, nativeSkills, calls),
1405
+ { skills: true, skillsScope: scope, hooks: false },
1406
+ )
1407
+ expect(result).toBe(EXIT.ok)
1408
+ expect(receivedScope).toBe(scope)
1409
+ expect(calls.submit).toBe(1)
1410
+ expect(io.outLines.join('\n')).toContain('All set.')
1411
+ },
1412
+ )
1413
+
1414
+ it('lets the native interactive flow choose scope and resumes after cancellation', async () => {
1415
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-cancelled-'))
1416
+ const io = new InteractiveIo()
1417
+ const calls: { submit: number } = { submit: 0 }
1418
+ let receivedScope: SkillScope | undefined = 'global'
1419
+ const nativeSkills: NativeSkills = {
1420
+ add: async (options) => {
1421
+ receivedScope = options.scope
1422
+ return 0
1423
+ },
1424
+ list: async () => ({ skills: [] }),
1425
+ }
1426
+
1427
+ expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
1428
+ EXIT.ok,
1429
+ )
1430
+ expect(receivedScope).toBeUndefined()
1431
+ expect(calls.submit).toBe(1)
1432
+ expect(io.outLines.join('\n')).toContain('All set.')
1433
+ })
1434
+
1435
+ it('reports an optional native installer failure without blocking remaining setup', async () => {
1436
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-failed-'))
1437
+ const io = new InteractiveIo()
1438
+ const calls: { submit: number } = { submit: 0 }
1439
+ const nativeSkills: NativeSkills = {
1440
+ add: async () => 1,
1441
+ list: async () => ({ skills: [] }),
1442
+ }
1443
+
1444
+ expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
1445
+ EXIT.failed,
1446
+ )
1447
+ expect(calls.submit).toBe(1)
1448
+ expect(io.errLines.join('\n')).toContain('Skill installation failed')
1449
+ expect(io.outLines.join('\n')).toContain('All set.')
1450
+ })
1451
+
1106
1452
  it('tells the user what only they can do when nothing is signed in', async () => {
1107
1453
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-nocred-'))
1108
1454
  const io = new CapturedIo()
@@ -1116,7 +1462,10 @@ describe('init', () => {
1116
1462
  expect(io.outLines.join('\n')).toContain('notifai login')
1117
1463
  })
1118
1464
 
1119
- it('offers a present human the sign-in and respects a refusal', async () => {
1465
+ it('announces sign-in for a present human without re-confirming', async () => {
1466
+ // Running `init` is the consent; re-asking "Sign in now?" is noise.
1467
+ // Ctrl-C is the escape hatch (announced). Here beginPairing fails so we
1468
+ // still end on the credential Next line with a re-run hint.
1120
1469
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-human-'))
1121
1470
  const asked: string[] = []
1122
1471
  const io = new (class extends CapturedIo {
@@ -1126,16 +1475,24 @@ describe('init', () => {
1126
1475
  return false
1127
1476
  }
1128
1477
  })()
1478
+ const client = {
1479
+ health: async () => true,
1480
+ beginPairing: async () => {
1481
+ throw new NetworkError('offline for test')
1482
+ },
1483
+ } as unknown as ApiClient
1129
1484
  const deps: CommandDeps = {
1130
- ...makeDeps(io, {} as ApiClient),
1485
+ ...makeDeps(io, client),
1131
1486
  cwd,
1132
1487
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
1133
1488
  }
1134
1489
 
1135
- expect(await initCommand(deps, {})).toBe(EXIT.ok)
1136
- expect(asked.some((q) => q.includes('Sign in'))).toBe(true)
1137
- // Refused, so it stays the next step rather than being treated as done.
1138
- expect(io.outLines.join('\n')).toContain('notifai login')
1490
+ expect(await initCommand(deps, {})).toBe(EXIT.failed)
1491
+ expect(asked.some((q) => q.includes('Sign in'))).toBe(false)
1492
+ const out = io.outLines.join('\n')
1493
+ expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
1494
+ expect(out).toContain('Next: This machine')
1495
+ expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
1139
1496
  })
1140
1497
 
1141
1498
  it('never prompts or opens a browser when an agent runs it unattended', async () => {
@@ -1167,6 +1524,12 @@ describe('init', () => {
1167
1524
  health: async () => true,
1168
1525
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1169
1526
  listDevices: async () => ({ devices: [] }),
1527
+ accessStatus: async () => ({
1528
+ status: 'active',
1529
+ reason: 'alpha_grant',
1530
+ expires_at: null,
1531
+ email: 'alpha@example.com',
1532
+ }),
1170
1533
  } as unknown as ApiClient
1171
1534
  const deps = {
1172
1535
  ...makeDeps(io, client),
@@ -1177,11 +1540,57 @@ describe('init', () => {
1177
1540
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1178
1541
  const out = io.outLines.join('\n')
1179
1542
  expect(out).toContain('Next: Your devices')
1180
- expect(out).toContain('no supported App Store/TestFlight URL')
1181
- expect(out).toContain('open it, sign in with the same account, and allow notifications')
1543
+ expect(out).toContain('https://test.notifai.invalid/support')
1544
+ expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
1545
+ expect(out).toContain('install Notifai on iPhone or Mac via https://test.notifai.invalid/support')
1182
1546
  expect(out.match(/^Next:/gm)).toHaveLength(1)
1183
1547
  })
1184
1548
 
1549
+ it('offers hooks and skill before stopping at a phone-less device gap', async () => {
1550
+ // P2.1: optionals that work without a phone must not be trapped behind the
1551
+ // user-elsewhere device blocker.
1552
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-optionals-first-'))
1553
+ const asked: string[] = []
1554
+ const io = new (class extends CapturedIo {
1555
+ interactive = true
1556
+ override async confirm(question: string) {
1557
+ asked.push(question)
1558
+ return false
1559
+ }
1560
+ })()
1561
+ const client = {
1562
+ health: async () => true,
1563
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1564
+ listDevices: async () => ({ devices: [] }),
1565
+ accessStatus: async () => ({
1566
+ status: 'active',
1567
+ reason: 'alpha_grant',
1568
+ expires_at: null,
1569
+ email: 'alpha@example.com',
1570
+ }),
1571
+ } as unknown as ApiClient
1572
+ const deps = {
1573
+ ...makeDeps(io, client),
1574
+ cwd,
1575
+ env: {
1576
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
1577
+ XDG_STATE_HOME: path.join(cwd, 'state'),
1578
+ CODEX_HOME: path.join(cwd, 'codex'),
1579
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
1580
+ },
1581
+ }
1582
+
1583
+ expect(await initCommand(deps, {})).toBe(EXIT.ok)
1584
+ expect(asked.some((q) => q.includes('hooks'))).toBe(true)
1585
+ expect(asked.some((q) => q.includes('skill'))).toBe(true)
1586
+ const out = io.outLines.join('\n')
1587
+ expect(out).toContain('Next: Your devices')
1588
+ // Device wait prompts only after optionals have been considered.
1589
+ expect(asked.indexOf(asked.find((q) => q.includes('hooks'))!)).toBeLessThan(
1590
+ asked.findIndex((q) => q.includes('Wait here') || q.includes('Open install')),
1591
+ )
1592
+ })
1593
+
1185
1594
  it.each([
1186
1595
  ['denied', 'system settings'],
1187
1596
  ['not_determined', 'allow its notification prompt'],
@@ -1219,6 +1628,12 @@ describe('init', () => {
1219
1628
  health: async () => true,
1220
1629
  capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1221
1630
  listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
1631
+ accessStatus: async () => ({
1632
+ status: 'active',
1633
+ reason: 'alpha_grant',
1634
+ expires_at: null,
1635
+ email: 'alpha@example.com',
1636
+ }),
1222
1637
  submit: async (draft: SubmitNotificationRequestT) => {
1223
1638
  submitCalls += 1
1224
1639
  submittedDraft = draft
@@ -1243,10 +1658,17 @@ describe('init', () => {
1243
1658
  }
1244
1659
 
1245
1660
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1246
- expect(io.prompts).toEqual(['Wait here while you finish that on your device?'])
1661
+ expect(io.prompts).toEqual([
1662
+ 'Open install instructions in your browser?',
1663
+ 'Wait here while you finish that on your device?',
1664
+ ])
1665
+ expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
1666
+ expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
1247
1667
  expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
1248
1668
  expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
1249
- expect(io.outLines.join('\n')).toContain('Companion Receipt observed from iPhone')
1669
+ expect(io.outLines.join('\n')).toContain(
1670
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone",
1671
+ )
1250
1672
  expect(io.outLines.join('\n')).toContain('All set.')
1251
1673
  expect(submitCalls).toBe(1)
1252
1674
  expect(submittedDraft?.draft.event).toBe('setup_verified')
@@ -1254,12 +1676,96 @@ describe('init', () => {
1254
1676
 
1255
1677
  io.outLines = []
1256
1678
  io.prompts = []
1679
+ io.openedUrls = []
1257
1680
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1258
1681
  expect(submitCalls).toBe(1)
1259
1682
  expect(io.prompts).toEqual([])
1260
1683
  expect(io.outLines.join('\n')).toContain('All set.')
1261
1684
  })
1262
1685
 
1686
+ it('says the wait timer expired and can keep waiting for a late device', async () => {
1687
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-keep-waiting-'))
1688
+ const io = new InteractiveIo()
1689
+ // Open browser, wait yes, keep-waiting yes on first expiry.
1690
+ io.confirmAnswers = [true, true, true]
1691
+ let now = 0
1692
+ // Match DEVICE_BRIDGE_TIMEOUT_MS (10 minutes): device appears only after
1693
+ // the first budget has fully elapsed and keep-waiting has restarted.
1694
+ const firstBudgetMs = 10 * 60 * 1000
1695
+ const client = {
1696
+ health: async () => true,
1697
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1698
+ listDevices: async () => ({
1699
+ devices: now >= firstBudgetMs ? [readyIphone] : [],
1700
+ }),
1701
+ accessStatus: async () => ({
1702
+ status: 'active',
1703
+ reason: 'alpha_grant',
1704
+ expires_at: null,
1705
+ email: 'late@example.com',
1706
+ }),
1707
+ submit: async () => setupReceipt(),
1708
+ evidence: async (requestId: string) =>
1709
+ setupEvidence(requestId, {
1710
+ state: 'observed',
1711
+ observed_at: '2026-08-05T18:00:02.000Z',
1712
+ latency_ms: 1_000,
1713
+ }),
1714
+ } as unknown as ApiClient
1715
+ const deps: CommandDeps = {
1716
+ ...makeDeps(io, client),
1717
+ cwd,
1718
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1719
+ now: () => now,
1720
+ sleep: async (milliseconds) => {
1721
+ now += milliseconds
1722
+ },
1723
+ }
1724
+
1725
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1726
+ expect(io.errLines.join('\n')).toContain('wait timer expired')
1727
+ expect(io.errLines.join('\n')).toMatch(/setup is not finished, only this wait/)
1728
+ expect(io.prompts.some((q) => q.includes('Keep waiting'))).toBe(true)
1729
+ expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
1730
+ expect(io.outLines.join('\n')).toContain('All set.')
1731
+ })
1732
+
1733
+ it('never hangs an agent on the device wait or keep-waiting prompt', async () => {
1734
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-agent-'))
1735
+ const io = new (class extends CapturedIo {
1736
+ override async confirm(): Promise<boolean> {
1737
+ throw new Error('an unattended init reached a device-wait prompt')
1738
+ }
1739
+
1740
+ override openUrl(): void {
1741
+ throw new Error('an unattended init opened a browser')
1742
+ }
1743
+ })()
1744
+ const client = {
1745
+ health: async () => true,
1746
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
1747
+ listDevices: async () => ({ devices: [] }),
1748
+ accessStatus: async () => ({
1749
+ status: 'active',
1750
+ reason: 'alpha_grant',
1751
+ expires_at: null,
1752
+ email: 'agent@example.com',
1753
+ }),
1754
+ } as unknown as ApiClient
1755
+ const deps = {
1756
+ ...makeDeps(io, client),
1757
+ cwd,
1758
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1759
+ }
1760
+
1761
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1762
+ const out = io.outLines.join('\n')
1763
+ expect(out).toContain('Next: Your devices')
1764
+ expect(out).toContain('https://test.notifai.invalid/support')
1765
+ expect(out).toContain('sign in with the same email as this account (agent@example.com)')
1766
+ expect(io.openedUrls).toEqual([])
1767
+ })
1768
+
1263
1769
  it('persists a partial proof and checks the same request instead of sending again', async () => {
1264
1770
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-partial-'))
1265
1771
  const io = new CapturedIo()
@@ -1303,7 +1809,8 @@ describe('init', () => {
1303
1809
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1304
1810
  expect(submitCalls).toBe(1)
1305
1811
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
1306
- expect(io.errLines.join('\n')).toContain('not proof of non-receipt')
1812
+ expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
1813
+ expect(io.outLines.join('\n')).toContain('Proof may still arrive')
1307
1814
 
1308
1815
  io.outLines = []
1309
1816
  io.errLines = []
@@ -1317,7 +1824,9 @@ describe('init', () => {
1317
1824
  expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1318
1825
  expect(submitCalls).toBe(2)
1319
1826
  expect(io.outLines.join('\n')).toContain('saved proof had expired; sent replacement req_replacement')
1320
- expect(io.outLines.join('\n')).toContain('Companion Receipt observed from iPhone')
1827
+ expect(io.outLines.join('\n')).toContain(
1828
+ "Companion Receipt (the app's delivery confirmation) observed from iPhone",
1829
+ )
1321
1830
  })
1322
1831
 
1323
1832
  it('reports a proof-state write failure instead of crashing or sending twice', async () => {
@@ -1345,7 +1854,7 @@ describe('init', () => {
1345
1854
  expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
1346
1855
  })
1347
1856
 
1348
- it('does not claim proof for a macOS-only setup whose receipt bridge is unavailable', async () => {
1857
+ it('treats macOS-only delivery proof as an honest non-blocking caveat', async () => {
1349
1858
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
1350
1859
  const io = new CapturedIo()
1351
1860
  let submitCalls = 0
@@ -1366,10 +1875,66 @@ describe('init', () => {
1366
1875
  env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1367
1876
  }
1368
1877
 
1369
- expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
1878
+ expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
1370
1879
  expect(submitCalls).toBe(0)
1371
- expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
1372
- expect(io.outLines.join('\n')).toContain('no supported macOS receipt bridge')
1880
+ const out = io.outLines.join('\n')
1881
+ expect(out).toContain('All set.')
1882
+ expect(out).toContain('receipt proof needs an iPhone in this release')
1883
+ expect(out).not.toContain('Next: Delivery proof')
1884
+ expect(out).not.toMatch(/Companion Receipt observed/i)
1885
+
1886
+ // doctor must render the same non-blocking state (not FAIL / not a Next:).
1887
+ const doctorIo = new CapturedIo()
1888
+ expect(
1889
+ await doctorCommand(
1890
+ {
1891
+ ...makeDeps(doctorIo, client),
1892
+ cwd,
1893
+ env: { XDG_CONFIG_HOME: path.join(cwd, 'config'), XDG_STATE_HOME: path.join(cwd, 'state') },
1894
+ },
1895
+ {},
1896
+ ),
1897
+ ).toBe(EXIT.ok)
1898
+ const doctorOut = doctorIo.outLines.join('\n')
1899
+ expect(doctorOut).toMatch(/--\s+Delivery proof:/)
1900
+ expect(doctorOut).toContain('receipt proof needs an iPhone')
1901
+ expect(doctorOut).not.toMatch(/FAIL\s+Delivery proof:/)
1902
+ })
1903
+
1904
+ it('stops login when the approval page reports no Alpha access', async () => {
1905
+ const io = new CapturedIo()
1906
+ let now = 0
1907
+ let polls = 0
1908
+ const client = {
1909
+ beginPairing: async () => ({
1910
+ pairing_id: 'pair_no_plan',
1911
+ code: 'NOPE-PLAN',
1912
+ approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
1913
+ expires_at: new Date(60_000).toISOString(),
1914
+ poll_interval_seconds: 1,
1915
+ }),
1916
+ pollPairing: async () => {
1917
+ polls += 1
1918
+ return {
1919
+ status: 'no_active_plan' as const,
1920
+ next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
1921
+ }
1922
+ },
1923
+ } as unknown as ApiClient
1924
+ const deps = {
1925
+ ...makeDeps(io, client),
1926
+ now: () => now,
1927
+ sleep: async (milliseconds: number) => {
1928
+ now += milliseconds
1929
+ },
1930
+ }
1931
+
1932
+ expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
1933
+ expect(polls).toBe(1)
1934
+ expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
1935
+ expect(io.errLines.join('\n')).toContain(
1936
+ 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
1937
+ )
1373
1938
  })
1374
1939
 
1375
1940
  it('treats a revoked credential as the one blocker and points back to pairing', async () => {
@@ -1486,7 +2051,7 @@ describe('an outage is not an answer', () => {
1486
2051
  json: true,
1487
2052
  })
1488
2053
 
1489
- const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
2054
+ const payload = JSON.parse(io.outLines[1] ?? '{}') as { degraded: boolean }
1490
2055
  expect(payload.degraded).toBe(true)
1491
2056
  })
1492
2057
 
@@ -1512,6 +2077,276 @@ describe('an outage is not an answer', () => {
1512
2077
  })
1513
2078
 
1514
2079
  describe('asking before the hooks have ever run', () => {
2080
+ const execPath = '/usr/local/bin/node'
2081
+ const scriptPath = '/opt/notifai/dist/main.js'
2082
+
2083
+ it('names the active Codex harness instead of unrelated installed adapters', () => {
2084
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
2085
+ const io = new CapturedIo()
2086
+ const env = {
2087
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2088
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2089
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2090
+ CODEX_THREAD_ID: 'codex-current-thread',
2091
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2092
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
2093
+ }
2094
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
2095
+
2096
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
2097
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
2098
+ io.outLines = []
2099
+
2100
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2101
+ const said = io.errLines.join(' ')
2102
+ expect(said).toMatch(/active Codex session/i)
2103
+ expect(said).toContain('notifai hooks install --harness codex')
2104
+ expect(said).not.toMatch(/Claude Code: send one new prompt|OpenCode: restart/)
2105
+ })
2106
+
2107
+ it('refuses a recent pointer owned by another Codex thread', () => {
2108
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-mismatch-'))
2109
+ const io = new CapturedIo()
2110
+ const env = {
2111
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2112
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2113
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2114
+ CODEX_THREAD_ID: 'codex-current-thread',
2115
+ }
2116
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2117
+
2118
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2119
+ writeSessionState('codex-other-thread', env, { last_prompt_at: 42 })
2120
+ writeProjectSession(cwd, env, 'codex-other-thread', 42, 'codex')
2121
+ io.outLines = []
2122
+
2123
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2124
+ expect(io.errLines.join(' ')).toMatch(/belongs to another Codex session/i)
2125
+ expect(io.outLines).not.toContain(
2126
+ 'Question registered. Ask it in the conversation as usual and end your turn.',
2127
+ )
2128
+ })
2129
+
2130
+ it('registers only when the active Codex thread owns the project pointer', () => {
2131
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-matching-'))
2132
+ const io = new CapturedIo()
2133
+ const env = {
2134
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2135
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2136
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2137
+ CODEX_THREAD_ID: 'codex-current-thread',
2138
+ }
2139
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2140
+
2141
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2142
+ writeSessionState('codex-current-thread', env, { last_prompt_at: 42 })
2143
+ writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
2144
+ io.outLines = []
2145
+
2146
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2147
+ expect(io.outLines).toContain(
2148
+ 'Question registered. Ask it in the conversation as usual and end your turn.',
2149
+ )
2150
+ })
2151
+
2152
+ it('gives doctor the same active-Codex diagnosis as ask', async () => {
2153
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
2154
+ const io = new CapturedIo()
2155
+ const env = {
2156
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2157
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2158
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2159
+ CODEX_THREAD_ID: 'codex-current-thread',
2160
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2161
+ }
2162
+ const client = {
2163
+ health: async () => true,
2164
+ capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
2165
+ listDevices: async () => ({ devices: [] }),
2166
+ } as unknown as ApiClient
2167
+ const nativeSkills: NativeSkills = {
2168
+ add: async () => 0,
2169
+ list: async (scope) => ({
2170
+ skills:
2171
+ scope === 'global'
2172
+ ? [
2173
+ {
2174
+ name: 'notifai',
2175
+ scope,
2176
+ path: path.join(cwd, 'global-skills', 'notifai'),
2177
+ source: 'Raidiant-io/notifai',
2178
+ sourceType: 'github',
2179
+ sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
2180
+ ref: 'v0.2.0',
2181
+ },
2182
+ ]
2183
+ : [],
2184
+ }),
2185
+ }
2186
+ const deps = { ...makeDeps(io, client), cwd, env, nativeSkills }
2187
+
2188
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
2189
+ io.outLines = []
2190
+
2191
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2192
+ const said = io.outLines.join(' ')
2193
+ expect(said).toContain(`installed from ${SKILLS_SOURCE} in the global scope`)
2194
+ expect(said).toMatch(/active Codex/i)
2195
+ expect(said).toContain('notifai hooks install --harness codex')
2196
+ })
2197
+
2198
+ it('refuses a Claude Code pointer owned by another live session', () => {
2199
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-mismatch-'))
2200
+ const io = new CapturedIo()
2201
+ const env = {
2202
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2203
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2204
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2205
+ CLAUDECODE: '1',
2206
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
2207
+ }
2208
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2209
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2210
+ EXIT.ok,
2211
+ )
2212
+ writeSessionState('claude-other', env, { last_prompt_at: 42 })
2213
+ writeProjectSession(cwd, env, 'claude-other', 42, 'claude-code')
2214
+ io.outLines = []
2215
+
2216
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2217
+ expect(io.errLines.join(' ')).toMatch(/another Claude Code session/i)
2218
+ })
2219
+
2220
+ it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
2221
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-child-'))
2222
+ const io = new CapturedIo()
2223
+ const env = {
2224
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2225
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2226
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2227
+ CLAUDECODE: '1',
2228
+ CLAUDE_CODE_CHILD_SESSION: '1',
2229
+ CLAUDE_CODE_SESSION_ID: 'claude-parent-loop',
2230
+ }
2231
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2232
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2233
+ EXIT.ok,
2234
+ )
2235
+ writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
2236
+ writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
2237
+ io.outLines = []
2238
+
2239
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2240
+ expect(readSessionState('claude-parent-loop', env).pending?.question).toBe('Ship it?')
2241
+ })
2242
+
2243
+ it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
2244
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
2245
+ const io = new CapturedIo()
2246
+ const env = {
2247
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2248
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2249
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
2250
+ NOTIFAI_ACTIVE_HARNESS: 'opencode',
2251
+ NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
2252
+ }
2253
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2254
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
2255
+ writeSessionState('opencode-other', env, { last_prompt_at: 42 })
2256
+ writeProjectSession(cwd, env, 'opencode-other', 42, 'opencode')
2257
+ io.outLines = []
2258
+
2259
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2260
+ expect(io.errLines.join(' ')).toMatch(/another OpenCode session/i)
2261
+ })
2262
+
2263
+ it('recognizes Cursor only from its active-agent marker', () => {
2264
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
2265
+ const io = new CapturedIo()
2266
+ const env = {
2267
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2268
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2269
+ CURSOR_AGENT: '1',
2270
+ }
2271
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2272
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2273
+ writeSessionState('cursor-live', env, { last_prompt_at: 42 })
2274
+ writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
2275
+ io.outLines = []
2276
+
2277
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
2278
+ expect(readSessionState('cursor-live', env).pending?.question).toBe('Ship it?')
2279
+ })
2280
+
2281
+ it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
2282
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
2283
+ const io = new CapturedIo()
2284
+ const env = {
2285
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
2286
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2287
+ CODEX_HOME: path.join(cwd, 'codex-home'),
2288
+ CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
2289
+ CLAUDECODE: '1',
2290
+ CLAUDE_CODE_SESSION_ID: 'claude-current',
2291
+ }
2292
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
2293
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
2294
+ writeSessionState('codex-live', env, { last_prompt_at: 42 })
2295
+ writeProjectSession(cwd, env, 'codex-live', 42, 'codex')
2296
+ io.outLines = []
2297
+
2298
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2299
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+Question routing:.*active Claude Code/is)
2300
+ })
2301
+
2302
+ it('shows resolved question-routing values with their winning config sources', async () => {
2303
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-routing-config-'))
2304
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
2305
+ writeFileSync(
2306
+ path.join(cwd, '.notifai', 'config.local.toml'),
2307
+ 'ask_notifications = false\nrequire_idle = false\naway_after_seconds = 45\nask_grace_seconds = 90\nhook_reply_timeout_seconds = 120\n',
2308
+ )
2309
+ const io = new CapturedIo()
2310
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
2311
+
2312
+ await doctorCommand(deps, {})
2313
+
2314
+ const said = io.outLines.join('\n')
2315
+ expect(said).toContain('Question routing settings:')
2316
+ expect(said).toContain('ask_notifications=false (project-local:')
2317
+ expect(said).toContain('require_idle=false (project-local:')
2318
+ expect(said).toContain('away_after_seconds=45 (project-local:')
2319
+ expect(said).toContain('ask_grace_seconds=90 (project-local:')
2320
+ expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
2321
+ })
2322
+
2323
+ it('fails doctor when runtime waits outgrow an installed Stop timeout', async () => {
2324
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
2325
+ const io = new CapturedIo()
2326
+ const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
2327
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
2328
+ expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
2329
+ EXIT.ok,
2330
+ )
2331
+ mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
2332
+ writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
2333
+ io.outLines = []
2334
+
2335
+ expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
2336
+ expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(timeout\)/)
2337
+ expect(io.outLines.join('\n')).toContain('notifai hooks install --harness claude-code')
2338
+ })
2339
+
2340
+ it('documents the failing exit contract in doctor JSON', async () => {
2341
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-exit-json-'))
2342
+ const io = new CapturedIo()
2343
+ const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
2344
+
2345
+ expect(await doctorCommand(deps, { json: true })).toBe(EXIT.failed)
2346
+ const payload = JSON.parse(io.outLines[0] ?? '{}') as { ok?: boolean; exit_code?: number }
2347
+ expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
2348
+ })
2349
+
1515
2350
  it('tells Claude Code to send a prompt without falsely requiring a restart', () => {
1516
2351
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
1517
2352
  mkdirSync(path.join(cwd, '.claude'), { recursive: true })
@@ -1533,6 +2368,43 @@ describe('asking before the hooks have ever run', () => {
1533
2368
  expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
1534
2369
  })
1535
2370
 
2371
+ it('gives Cursor its prompt-and-doctor activation path', () => {
2372
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
2373
+ const io = new CapturedIo()
2374
+ const deps = {
2375
+ ...makeDeps(io, {} as ApiClient),
2376
+ cwd,
2377
+ env: { XDG_STATE_HOME: path.join(cwd, 'state') },
2378
+ }
2379
+
2380
+ expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
2381
+ io.outLines = []
2382
+
2383
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2384
+ expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
2385
+ })
2386
+
2387
+ it('keeps OpenCode activation separate from answer continuation', () => {
2388
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
2389
+ const io = new CapturedIo()
2390
+ const deps = {
2391
+ ...makeDeps(io, {} as ApiClient),
2392
+ cwd,
2393
+ env: {
2394
+ XDG_STATE_HOME: path.join(cwd, 'state'),
2395
+ OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
2396
+ },
2397
+ }
2398
+
2399
+ expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
2400
+ io.outLines = []
2401
+
2402
+ expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
2403
+ const said = io.errLines.join(' ')
2404
+ expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
2405
+ expect(said).toContain('device answer is delivered on the next prompt')
2406
+ })
2407
+
1536
2408
  it('says to install when nothing is installed at all', () => {
1537
2409
  const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
1538
2410
  const io = new CapturedIo()
@@ -1572,7 +2444,7 @@ describe('a server behind this CLI', () => {
1572
2444
  expect(said).not.toMatch(/older than this CLI/)
1573
2445
  })
1574
2446
 
1575
- it('doctor says plainly that the server needs deploying', async () => {
2447
+ it('doctor uses alpha-user wording when the server is behind the CLI', async () => {
1576
2448
  const io = new CapturedIo()
1577
2449
  const client = {
1578
2450
  health: async () => true,
@@ -1593,7 +2465,8 @@ describe('a server behind this CLI', () => {
1593
2465
  const said = io.outLines.concat(io.errLines).join(' ')
1594
2466
  // The label is the user's word for it; the detail is what must survive.
1595
2467
  expect(said).toMatch(/Protocol version/)
1596
- expect(said).toMatch(/needs deploying/)
2468
+ expect(said).toMatch(/service is being updated/)
2469
+ expect(said).not.toMatch(/needs deploying/)
1597
2470
  })
1598
2471
 
1599
2472
  it('doctor is quiet when both sides agree', async () => {
@@ -1617,7 +2490,97 @@ describe('a server behind this CLI', () => {
1617
2490
  })
1618
2491
  })
1619
2492
 
1620
- /** First-reply-wins is the right default, silently is the wrong way. */
2493
+ describe('question sets', () => {
2494
+ it('maps --reply-multi into the single question', async () => {
2495
+ const io = new CapturedIo()
2496
+ let submitted: SubmitNotificationRequestT | undefined
2497
+ const client = {
2498
+ submit: async (body: SubmitNotificationRequestT) => {
2499
+ submitted = body
2500
+ return receipt
2501
+ },
2502
+ replies: async () => replyResponse([reply]),
2503
+ } as unknown as ApiClient
2504
+
2505
+ expect(
2506
+ await sendCommand(makeDeps(io, client), {
2507
+ title: 'Question',
2508
+ body: 'Which fronts?',
2509
+ reply: true,
2510
+ replyTimeout: 30,
2511
+ replyChoice: ['CLI', 'Server', 'Apps'],
2512
+ replyMulti: true,
2513
+ }),
2514
+ ).toBe(EXIT.ok)
2515
+ expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
2516
+ text: 'Which fronts?',
2517
+ multi: true,
2518
+ })
2519
+ expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
2520
+ })
2521
+
2522
+ it('rejects a question body too long for the answering surface', async () => {
2523
+ const io = new CapturedIo()
2524
+ let submitCalls = 0
2525
+ const client = {
2526
+ submit: async () => {
2527
+ submitCalls += 1
2528
+ return receipt
2529
+ },
2530
+ } as unknown as ApiClient
2531
+
2532
+ expect(
2533
+ await sendCommand(makeDeps(io, client), {
2534
+ title: 'Question',
2535
+ body: 'x'.repeat(501),
2536
+ reply: true,
2537
+ replyTimeout: 30,
2538
+ }),
2539
+ ).toBe(EXIT.usage)
2540
+ expect(submitCalls).toBe(0)
2541
+ expect(io.errLines.join(' ')).toContain('--detail')
2542
+ })
2543
+
2544
+ it('generates unique question ids when texts collide', () => {
2545
+ const built = buildQuestions(
2546
+ {
2547
+ form: JSON.stringify({
2548
+ questions: [{ text: 'Ready?' }, { text: 'Ready?' }],
2549
+ }),
2550
+ },
2551
+ undefined,
2552
+ )
2553
+ expect(built).toMatchObject({ ok: true })
2554
+ if (built.ok) {
2555
+ expect(built.questions.map((question) => question.id)).toEqual(['ready', 'q2'])
2556
+ }
2557
+ })
2558
+
2559
+ it('rejects forms outside the documented shape', () => {
2560
+ expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
2561
+ expect(
2562
+ buildQuestions({ form: JSON.stringify({ questions: [] }) }, undefined),
2563
+ ).toMatchObject({ ok: false })
2564
+ expect(
2565
+ buildQuestions(
2566
+ { form: JSON.stringify({ questions: Array.from({ length: 5 }, (_, i) => ({ text: `Q${i}?` })) }) },
2567
+ undefined,
2568
+ ),
2569
+ ).toMatchObject({ ok: false, error: expect.stringContaining('1-4') })
2570
+ expect(
2571
+ buildQuestions(
2572
+ { form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },
2573
+ undefined,
2574
+ ),
2575
+ ).toMatchObject({ ok: false, error: expect.stringContaining('--choice') })
2576
+ // --form replaces the flag surface; mixing them is a usage error.
2577
+ expect(
2578
+ buildQuestions({ form: JSON.stringify({ questions: [{ text: 'Q?' }] }) }, 'Also a question?'),
2579
+ ).toMatchObject({ ok: false })
2580
+ })
2581
+ })
2582
+
2583
+ /** Latest-reply-wins, and never silently: a correction is named as one. */
1621
2584
  describe('a second device that disagrees', () => {
1622
2585
  function view(overrides: Partial<ReplyView>): ReplyView {
1623
2586
  return {
@@ -1627,27 +2590,28 @@ describe('a second device that disagrees', () => {
1627
2590
  device_id: 'dev',
1628
2591
  device_name: 'iPhone',
1629
2592
  text: 'Yes',
1630
- choice_id: null,
2593
+ answers: [],
2594
+ source: null,
1631
2595
  created_at: new Date().toISOString(),
1632
2596
  ...overrides,
1633
2597
  }
1634
2598
  }
1635
2599
 
1636
- it('says which answer counted and which was discarded', () => {
2600
+ it('says the latest answer counted and which were superseded', () => {
1637
2601
  const said = contradictingAnswer([
1638
2602
  view({ seq: 1, device_name: 'iPhone', text: 'Yes' }),
1639
2603
  view({ seq: 2, device_name: 'FurankuMac', text: 'No' }),
1640
2604
  ])
1641
- expect(said).toContain('"Yes" from iPhone')
1642
- expect(said).toContain('FurankuMac')
1643
- expect(said).toMatch(/arrived first/)
2605
+ expect(said).toContain('"No" from FurankuMac')
2606
+ expect(said).toContain('iPhone')
2607
+ expect(said).toMatch(/corrects an earlier one/)
1644
2608
  })
1645
2609
 
1646
2610
  it('is silent when the second answer agrees', () => {
1647
2611
  expect(
1648
2612
  contradictingAnswer([
1649
- view({ seq: 1, device_name: 'iPhone', text: 'Ship it', choice_id: 'ship' }),
1650
- view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it', choice_id: 'ship' }),
2613
+ view({ seq: 1, device_name: 'iPhone', text: 'Ship it', answers: [{ question_id: 'q1', choice_ids: ['ship'], text: null }] }),
2614
+ view({ seq: 2, device_name: 'FurankuMac', text: 'Ship it', answers: [{ question_id: 'q1', choice_ids: ['ship'], text: null }] }),
1651
2615
  ]),
1652
2616
  ).toBeNull()
1653
2617
  })