@raidiant/notifai 3.0.1 → 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 () => {
@@ -3732,6 +3852,74 @@ describe('asking before the hooks have ever run', () => {
3732
3852
  expect(io.outLines).toContain(
3733
3853
  'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
3734
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
+ })
3735
3923
  })
3736
3924
 
3737
3925
  // A harness exports its markers into everything it starts, so a nested
@@ -3763,7 +3951,10 @@ describe('asking before the hooks have ever run', () => {
3763
3951
  io.outLines = []
3764
3952
 
3765
3953
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3766
- 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
+ })
3767
3958
  expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
3768
3959
  })
3769
3960
 
@@ -3792,7 +3983,10 @@ describe('asking before the hooks have ever run', () => {
3792
3983
  io.outLines = []
3793
3984
 
3794
3985
  expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
3795
- 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
+ })
3796
3990
  expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
3797
3991
  })
3798
3992
 
@@ -4566,6 +4760,66 @@ describe('a server behind this CLI', () => {
4566
4760
  })
4567
4761
  })
4568
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
+
4569
4823
  describe('question sets', () => {
4570
4824
  it('maps --reply-multi into the single question', async () => {
4571
4825
  const io = new CapturedIo()
@@ -4595,26 +4849,76 @@ describe('question sets', () => {
4595
4849
  expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
4596
4850
  })
4597
4851
 
4598
- it('rejects a question body too long for the answering surface', async () => {
4852
+ it('derives the answerable question from the first banner block', async () => {
4599
4853
  const io = new CapturedIo()
4600
- let submitCalls = 0
4854
+ let submitted: SubmitNotificationRequestT | undefined
4601
4855
  const client = {
4602
- submit: async () => {
4603
- submitCalls += 1
4856
+ submit: async (body: SubmitNotificationRequestT) => {
4857
+ submitted = body
4604
4858
  return receipt
4605
4859
  },
4860
+ replies: async () => replyResponse([reply]),
4606
4861
  } as unknown as ApiClient
4607
4862
 
4863
+ const body = `Which environment?\n\n${'Long Markdown context. '.repeat(30)}`
4608
4864
  expect(
4609
4865
  await sendCommand(makeDeps(io, client), {
4610
- title: 'Question',
4611
- body: 'x'.repeat(501),
4866
+ title: 'Choose the deployment environment',
4867
+ body,
4612
4868
  reply: true,
4613
4869
  replyTimeout: 30,
4614
4870
  }),
4615
- ).toBe(EXIT.usage)
4616
- expect(submitCalls).toBe(0)
4617
- 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
+ })
4618
4922
  })
4619
4923
 
4620
4924
  it('generates unique question ids when texts collide', () => {