@raidiant/notifai 2.0.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -388,6 +388,117 @@ describe('command contracts', () => {
388
388
  expect(io.errLines.join('\n')).toContain('project')
389
389
  })
390
390
 
391
+ it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
392
+ const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
393
+ const io = new CapturedIo()
394
+ let submitted: SubmitNotificationRequestT | undefined
395
+ const client = {
396
+ submit: async (body: SubmitNotificationRequestT) => {
397
+ submitted = body
398
+ return receipt
399
+ },
400
+ } as unknown as ApiClient
401
+ const deps = {
402
+ ...makeDeps(io, client),
403
+ cwd,
404
+ env: {
405
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
406
+ XDG_STATE_HOME: path.join(cwd, 'state'),
407
+ CLAUDECODE: '1',
408
+ CLAUDE_CODE_SESSION_ID: 'opaque-claude-session-42',
409
+ },
410
+ }
411
+
412
+ expect(
413
+ await sendCommand(deps, {
414
+ title: 'All checks passed',
415
+ body: '**42 checks** passed.',
416
+ kind: 'done',
417
+ }),
418
+ ).toBe(EXIT.ok)
419
+
420
+ expect(submitted?.draft.project).toMatch(/^notifai-project-/)
421
+ expect(submitted?.draft.source).toMatchObject({
422
+ session_id: 'opaque-claude-session-42',
423
+ harness: 'claude-code',
424
+ })
425
+ expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
426
+ expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
427
+ expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
428
+ })
429
+
430
+ it('uploads repeatable images in order and sends only canonical media references', async () => {
431
+ const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
432
+ const first = path.join(root, 'first.png')
433
+ const second = path.join(root, 'second.gif')
434
+ writeFileSync(first, 'first image')
435
+ writeFileSync(second, 'second image')
436
+ const uploaded: string[] = []
437
+ let grants = 0
438
+ let submitted: SubmitNotificationRequestT | undefined
439
+ const client = {
440
+ createMediaUpload: async () => {
441
+ grants += 1
442
+ return {
443
+ media_id: `med_uploaded_${grants}`,
444
+ upload_url: `https://upload.invalid/${grants}`,
445
+ upload_headers: {},
446
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
447
+ }
448
+ },
449
+ uploadMedia: async (grant: { media_id: string }) => {
450
+ uploaded.push(grant.media_id)
451
+ },
452
+ submit: async (body: SubmitNotificationRequestT) => {
453
+ submitted = body
454
+ return receipt
455
+ },
456
+ } as unknown as ApiClient
457
+
458
+ expect(
459
+ await sendCommand(makeDeps(new CapturedIo(), client), {
460
+ title: 'Visual comparison is ready',
461
+ body:
462
+ '![first](media:1) ![second](media:2) ![ready](media:med_existing)',
463
+ image: [first, second, 'med_attached_only'],
464
+ imageAlt: ['First state', 'Second state'],
465
+ }),
466
+ ).toBe(EXIT.ok)
467
+
468
+ expect(uploaded).toEqual(['med_uploaded_1', 'med_uploaded_2'])
469
+ expect(submitted?.draft.presentation.media).toEqual([
470
+ { media_id: 'med_uploaded_1', alt: 'First state' },
471
+ { media_id: 'med_uploaded_2', alt: 'Second state' },
472
+ { media_id: 'med_attached_only' },
473
+ ])
474
+ expect(submitted?.draft.presentation.body).toBe(
475
+ '![first](media:med_uploaded_1) ![second](media:med_uploaded_2) ![ready](media:med_existing)',
476
+ )
477
+ expect(submitted?.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
478
+ })
479
+
480
+ it('rejects media cardinality and alt pairing before any upload', async () => {
481
+ const io = new CapturedIo()
482
+ let uploads = 0
483
+ const client = {
484
+ createMediaUpload: async () => {
485
+ uploads += 1
486
+ throw new Error('must not upload')
487
+ },
488
+ } as unknown as ApiClient
489
+
490
+ expect(
491
+ await sendCommand(makeDeps(io, client), {
492
+ title: 'Visual comparison',
493
+ body: 'See it.',
494
+ image: ['one.png'],
495
+ imageAlt: ['one', 'two'],
496
+ }),
497
+ ).toBe(EXIT.usage)
498
+ expect(uploads).toBe(0)
499
+ expect(io.errLines.join(' ')).toContain('2 --image-alt')
500
+ })
501
+
391
502
  it('rejects done plus reply before authentication or submission', async () => {
392
503
  const io = new CapturedIo()
393
504
  let submitCalls = 0
@@ -415,17 +526,13 @@ describe('command contracts', () => {
415
526
  flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
416
527
  warning: /titles work best around 40/i,
417
528
  },
418
- {
419
- flags: { title: 'Details', body: '**bold Markdown**' },
420
- warning: /--body looks like Markdown.*--detail/i,
421
- },
422
529
  {
423
530
  flags: { title: 'Done · build', body: 'All green.' },
424
- warning: /Use --kind done/i,
531
+ warning: /keep the title to the specific substance/i,
425
532
  },
426
533
  {
427
534
  flags: { title: 'Failed · build', body: 'One integration test failed.' },
428
- warning: /Use --kind done/i,
535
+ warning: /Put notification type in --kind/i,
429
536
  },
430
537
  {
431
538
  flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
@@ -456,14 +563,27 @@ describe('command contracts', () => {
456
563
  expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
457
564
  })
458
565
 
459
- it('removes unmanaged provider fields from the public send flags', () => {
566
+ it('keeps only the current public notification-authoring flags', () => {
460
567
  const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
461
568
  expect(source).not.toContain(".option('--badge")
462
569
  expect(source).not.toContain(".option('--relevance")
463
570
  expect(source).not.toContain(".option('--target-content-id")
464
- expect(source).toContain('Kind profiles apply automatically')
465
- expect(source).toContain('Pass --sound or --level only to override')
466
- expect(source).toContain('override kind profile / saved config')
571
+ const sendGrammar = source.slice(source.indexOf(".command('send')"), source.indexOf('send.addHelpText'))
572
+ const askGrammar = source.slice(source.indexOf(".command('ask [question]')"), source.indexOf(".command('close"))
573
+ for (const grammar of [sendGrammar, askGrammar]) {
574
+ expect(grammar).not.toContain(".option('--detail")
575
+ expect(grammar).not.toContain(".option('--session <")
576
+ expect(grammar).toContain(".option('--body-file")
577
+ expect(grammar).toContain(".option('--session-id")
578
+ expect(grammar).toContain(".option('--session-label")
579
+ expect(grammar).toContain("'--image <path|url|media_id>'")
580
+ expect(grammar).toContain("'--image-alt <text>'")
581
+ }
582
+ expect(source).toContain('Kind describes status in the Companion Apps')
583
+ expect(source).toContain('it never chooses banner sound or interruption level')
584
+ expect(source).toContain('override saved sound')
585
+ expect(source).not.toContain('Kind profiles apply automatically')
586
+ expect(source).not.toContain('sound alert level active')
467
587
  })
468
588
 
469
589
  it('rejects a question nobody will wait for', async () => {
@@ -2392,12 +2512,7 @@ describe('interactive command UX', () => {
2392
2512
  const deps: CommandDeps = {
2393
2513
  ...makeDeps(io, client),
2394
2514
  cwd,
2395
- env: {
2396
- XDG_CONFIG_HOME: path.join(cwd, 'config'),
2397
- XDG_STATE_HOME: path.join(cwd, 'state'),
2398
- CODEX_HOME: path.join(cwd, 'codex'),
2399
- CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
2400
- },
2515
+ env: isolatedEnv(cwd),
2401
2516
  store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
2402
2517
  }
2403
2518
 
@@ -2993,19 +3108,16 @@ describe('init', () => {
2993
3108
  const deps = {
2994
3109
  ...makeDeps(io, client),
2995
3110
  cwd,
2996
- env: {
2997
- HOME: path.join(cwd, 'home'),
2998
- XDG_CONFIG_HOME: path.join(cwd, 'config'),
2999
- XDG_STATE_HOME: path.join(cwd, 'state'),
3000
- CODEX_HOME: path.join(cwd, 'codex'),
3001
- CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
3002
- OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode'),
3003
- },
3111
+ env: isolatedEnv(cwd),
3112
+ nativeSkills: {
3113
+ list: async () => ({ skills: [] }),
3114
+ add: async () => 0,
3115
+ } satisfies NativeSkills,
3004
3116
  }
3005
3117
 
3006
3118
  expect(await initCommand(deps, {})).toBe(EXIT.ok)
3007
- expect(asked.some((q) => q.includes('hooks'))).toBe(true)
3008
- expect(asked.some((q) => q.includes('skill'))).toBe(true)
3119
+ expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/hooks/)]))
3120
+ expect(asked).toEqual(expect.arrayContaining([expect.stringMatching(/skill/)]))
3009
3121
  const out = io.outLines.join('\n')
3010
3122
  expect(out).toContain('Next: Your devices')
3011
3123
  // Device wait prompts only after optionals have been considered.
@@ -3740,6 +3852,74 @@ describe('asking before the hooks have ever run', () => {
3740
3852
  expect(io.outLines).toContain(
3741
3853
  'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
3742
3854
  )
3855
+ expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
3856
+ session_id: 'codex-current-thread',
3857
+ harness: 'codex',
3858
+ })
3859
+ expect(
3860
+ readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
3861
+ ).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
3862
+ })
3863
+
3864
+ it('uploads ask images before registration and freezes canonical body media', async () => {
3865
+ const cwd = scratchDir('notifai-ask-media-')
3866
+ const first = path.join(cwd, 'first.png')
3867
+ const second = path.join(cwd, 'second.gif')
3868
+ writeFileSync(first, 'first')
3869
+ writeFileSync(second, 'second')
3870
+ const io = new CapturedIo()
3871
+ const env = {
3872
+ XDG_CONFIG_HOME: path.join(cwd, 'config'),
3873
+ XDG_STATE_HOME: path.join(cwd, 'state'),
3874
+ HOME: path.join(cwd, 'home'),
3875
+ CODEX_HOME: path.join(cwd, 'codex-home'),
3876
+ CODEX_THREAD_ID: 'codex-media-thread',
3877
+ }
3878
+ let grant = 0
3879
+ const uploaded: string[] = []
3880
+ const client = {
3881
+ createMediaUpload: async () => {
3882
+ grant += 1
3883
+ return {
3884
+ media_id: `med_ask_${grant}`,
3885
+ upload_url: `https://upload.invalid/${grant}`,
3886
+ upload_headers: {},
3887
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
3888
+ }
3889
+ },
3890
+ uploadMedia: async (value: { media_id: string }) => {
3891
+ uploaded.push(value.media_id)
3892
+ },
3893
+ } as unknown as ApiClient
3894
+ const deps = { ...makeDeps(io, client), cwd, env, now: () => 42 }
3895
+
3896
+ expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
3897
+ trustInstalledCodexHooks(cwd, env)
3898
+ writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
3899
+ writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
3900
+ io.outLines = []
3901
+
3902
+ expect(
3903
+ await askCommand(deps, 'Which visual should I use?', {
3904
+ body: 'Compare ![first](media:1) with ![second](media:2).',
3905
+ image: [first, second],
3906
+ imageAlt: ['First option', 'Second option'],
3907
+ }),
3908
+ ).toBe(EXIT.ok)
3909
+
3910
+ const pending = readSessionState('codex-media-thread', env).pending?.[0]
3911
+ expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
3912
+ expect(pending?.body).toBe(
3913
+ 'Which visual should I use?\n\nCompare ![first](media:med_ask_1) with ![second](media:med_ask_2).',
3914
+ )
3915
+ expect(pending?.media).toEqual([
3916
+ { media_id: 'med_ask_1', alt: 'First option' },
3917
+ { media_id: 'med_ask_2', alt: 'Second option' },
3918
+ ])
3919
+ expect(pending?.source).toMatchObject({
3920
+ session_id: 'codex-media-thread',
3921
+ harness: 'codex',
3922
+ })
3743
3923
  })
3744
3924
 
3745
3925
  // A harness exports its markers into everything it starts, so a nested
@@ -3771,7 +3951,10 @@ describe('asking before the hooks have ever run', () => {
3771
3951
  io.outLines = []
3772
3952
 
3773
3953
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3774
- expect(readSessionState('codex-current-thread', env).pending?.[0]?.question).toBe('Ship it?')
3954
+ expect(readSessionState('codex-current-thread', env).pending?.[0]).toMatchObject({
3955
+ question: 'Ship it?',
3956
+ source: { session_id: 'codex-current-thread', harness: 'codex' },
3957
+ })
3775
3958
  expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
3776
3959
  })
3777
3960
 
@@ -3800,7 +3983,10 @@ describe('asking before the hooks have ever run', () => {
3800
3983
  io.outLines = []
3801
3984
 
3802
3985
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3803
- expect(readSessionState('claude-current', env).pending?.[0]?.question).toBe('Ship it?')
3986
+ expect(readSessionState('claude-current', env).pending?.[0]).toMatchObject({
3987
+ question: 'Ship it?',
3988
+ source: { session_id: 'claude-current', harness: 'claude-code' },
3989
+ })
3804
3990
  expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
3805
3991
  })
3806
3992
 
@@ -4574,6 +4760,66 @@ describe('a server behind this CLI', () => {
4574
4760
  })
4575
4761
  })
4576
4762
 
4763
+ /**
4764
+ * The same class of failure through the command path. A rejected `send` printed
4765
+ * only "invalid_request: The draft was not accepted." while the server's
4766
+ * details — naming the exact offending field — went to the local log alone.
4767
+ * stderr is the one line an agent's next turn actually reads, so the diagnosis
4768
+ * has to be on it. Direction-neutral on purpose: the mismatch has been seen
4769
+ * with the server behind the CLI and with the CLI behind the server.
4770
+ */
4771
+ describe('command failures carrying server details', () => {
4772
+ const throwingClient = (status: number, details: unknown = null): ApiClient =>
4773
+ ({
4774
+ submit: async () => {
4775
+ throw new ApiCallError(status, 'invalid_request', 'The draft was not accepted.', null, details)
4776
+ },
4777
+ }) as unknown as ApiClient
4778
+
4779
+ it('names the rejected field and the contract mismatch on a 422', async () => {
4780
+ const io = new CapturedIo()
4781
+ const client = throwingClient(422, [
4782
+ { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4783
+ ])
4784
+
4785
+ expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
4786
+ EXIT.failed,
4787
+ )
4788
+
4789
+ const said = io.errLines.join(' ')
4790
+ expect(said).toContain('the server rejected: presentation.detail')
4791
+ expect(said).toMatch(/sent a field the server did not accept/)
4792
+ expect(said).toContain('notifai doctor')
4793
+ expect(said).not.toMatch(/older than this CLI/)
4794
+ })
4795
+
4796
+ it('prints the rejected paths without the contract line when the status is not 422', async () => {
4797
+ const io = new CapturedIo()
4798
+ const client = throwingClient(400, [
4799
+ { code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
4800
+ ])
4801
+
4802
+ expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
4803
+ EXIT.failed,
4804
+ )
4805
+
4806
+ const said = io.errLines.join(' ')
4807
+ expect(said).toContain('the server rejected: presentation.detail')
4808
+ expect(said).not.toContain('notifai doctor')
4809
+ expect(said).not.toMatch(/did not accept/)
4810
+ })
4811
+
4812
+ it('prints exactly the code and message when the error carries no details', async () => {
4813
+ const io = new CapturedIo()
4814
+
4815
+ expect(await sendCommand(makeDeps(io, throwingClient(409)), { title: 'Deploy finished', body: 'All green.' })).toBe(
4816
+ EXIT.failed,
4817
+ )
4818
+
4819
+ expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
4820
+ })
4821
+ })
4822
+
4577
4823
  describe('question sets', () => {
4578
4824
  it('maps --reply-multi into the single question', async () => {
4579
4825
  const io = new CapturedIo()
@@ -4603,26 +4849,76 @@ describe('question sets', () => {
4603
4849
  expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
4604
4850
  })
4605
4851
 
4606
- it('rejects a question body too long for the answering surface', async () => {
4852
+ it('derives the answerable question from the first banner block', async () => {
4607
4853
  const io = new CapturedIo()
4608
- let submitCalls = 0
4854
+ let submitted: SubmitNotificationRequestT | undefined
4609
4855
  const client = {
4610
- submit: async () => {
4611
- submitCalls += 1
4856
+ submit: async (body: SubmitNotificationRequestT) => {
4857
+ submitted = body
4612
4858
  return receipt
4613
4859
  },
4860
+ replies: async () => replyResponse([reply]),
4614
4861
  } as unknown as ApiClient
4615
4862
 
4863
+ const body = `Which environment?\n\n${'Long Markdown context. '.repeat(30)}`
4616
4864
  expect(
4617
4865
  await sendCommand(makeDeps(io, client), {
4618
- title: 'Question',
4619
- body: 'x'.repeat(501),
4866
+ title: 'Choose the deployment environment',
4867
+ body,
4620
4868
  reply: true,
4621
4869
  replyTimeout: 30,
4622
4870
  }),
4623
- ).toBe(EXIT.usage)
4624
- expect(submitCalls).toBe(0)
4625
- expect(io.errLines.join(' ')).toContain('--detail')
4871
+ ).toBe(EXIT.ok)
4872
+ expect(submitted?.draft.presentation.body).toBe(body)
4873
+ expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
4874
+ })
4875
+
4876
+ it('composes one question before optional Markdown context', () => {
4877
+ expect(
4878
+ buildQuestions(
4879
+ { body: '## Why\nThe release window closes today.' },
4880
+ 'Which environment?',
4881
+ ),
4882
+ ).toMatchObject({
4883
+ ok: true,
4884
+ body: 'Which environment?\n\n## Why\nThe release window closes today.',
4885
+ questions: [{ text: 'Which environment?' }],
4886
+ })
4887
+ })
4888
+
4889
+ it('composes numbered form questions before form context', () => {
4890
+ expect(
4891
+ buildQuestions(
4892
+ {
4893
+ form: JSON.stringify({
4894
+ questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }],
4895
+ body: '## Context\nTraffic is elevated.',
4896
+ }),
4897
+ },
4898
+ undefined,
4899
+ ),
4900
+ ).toMatchObject({
4901
+ ok: true,
4902
+ body:
4903
+ '1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
4904
+ })
4905
+ })
4906
+
4907
+ it('rejects the deleted form detail key instead of treating it as context', () => {
4908
+ expect(
4909
+ buildQuestions(
4910
+ {
4911
+ form: JSON.stringify({
4912
+ questions: [{ text: 'Deploy?' }],
4913
+ detail: 'legacy context',
4914
+ }),
4915
+ },
4916
+ undefined,
4917
+ ),
4918
+ ).toMatchObject({
4919
+ ok: false,
4920
+ error: expect.stringContaining('Unknown --form key: detail'),
4921
+ })
4626
4922
  })
4627
4923
 
4628
4924
  it('generates unique question ids when texts collide', () => {