@raidiant/notifai 3.0.1 → 5.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.
- package/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/commands.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
1
2
|
import {
|
|
2
3
|
existsSync,
|
|
3
4
|
mkdirSync,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
import os from 'node:os'
|
|
12
13
|
import path from 'node:path'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
14
16
|
import type {
|
|
15
17
|
CapabilityDocument,
|
|
16
18
|
EvidenceSnapshot,
|
|
@@ -261,6 +263,7 @@ const receipt: SubmissionReceipt = {
|
|
|
261
263
|
request_id: 'req_reply_test',
|
|
262
264
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
263
265
|
agent_acknowledgement_required: true,
|
|
266
|
+
agent_acknowledgement_text_required: true,
|
|
264
267
|
replayed: false,
|
|
265
268
|
overall: 'provider_accepted_all',
|
|
266
269
|
deliveries: [
|
|
@@ -293,6 +296,7 @@ function replyResponse(
|
|
|
293
296
|
replies: ReplyView[] = [],
|
|
294
297
|
options: {
|
|
295
298
|
required?: boolean
|
|
299
|
+
textRequired?: boolean
|
|
296
300
|
acknowledgement?: ListRepliesResponse['agent_acknowledgement']
|
|
297
301
|
} = {},
|
|
298
302
|
): ListRepliesResponse {
|
|
@@ -300,6 +304,7 @@ function replyResponse(
|
|
|
300
304
|
request_id: receipt.request_id,
|
|
301
305
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
302
306
|
agent_acknowledgement_required: options.required ?? true,
|
|
307
|
+
agent_acknowledgement_text_required: options.textRequired ?? true,
|
|
303
308
|
agent_acknowledgement: options.acknowledgement ?? null,
|
|
304
309
|
replies,
|
|
305
310
|
}
|
|
@@ -378,7 +383,7 @@ describe('command contracts', () => {
|
|
|
378
383
|
} as unknown as ApiClient
|
|
379
384
|
|
|
380
385
|
expect(
|
|
381
|
-
await sendCommand(makeDeps(io, client), {
|
|
386
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
382
387
|
title: 'T',
|
|
383
388
|
body: 'B',
|
|
384
389
|
project: 'Invalid Project!',
|
|
@@ -388,6 +393,117 @@ describe('command contracts', () => {
|
|
|
388
393
|
expect(io.errLines.join('\n')).toContain('project')
|
|
389
394
|
})
|
|
390
395
|
|
|
396
|
+
it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
|
|
397
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
|
|
398
|
+
const io = new CapturedIo()
|
|
399
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
400
|
+
const client = {
|
|
401
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
402
|
+
submitted = body
|
|
403
|
+
return receipt
|
|
404
|
+
},
|
|
405
|
+
} as unknown as ApiClient
|
|
406
|
+
const deps = {
|
|
407
|
+
...makeDeps(io, client),
|
|
408
|
+
cwd,
|
|
409
|
+
env: {
|
|
410
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
411
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
412
|
+
CLAUDECODE: '1',
|
|
413
|
+
CLAUDE_CODE_SESSION_ID: 'opaque-claude-session-42',
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
expect(
|
|
418
|
+
await sendCommand(deps, {
|
|
419
|
+
title: 'All checks passed',
|
|
420
|
+
body: '**42 checks** passed.',
|
|
421
|
+
kind: 'done',
|
|
422
|
+
}),
|
|
423
|
+
).toBe(EXIT.ok)
|
|
424
|
+
|
|
425
|
+
expect(submitted?.draft.project).toMatch(/^notifai-project-/)
|
|
426
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
427
|
+
session_id: 'opaque-claude-session-42',
|
|
428
|
+
harness: 'claude-code',
|
|
429
|
+
})
|
|
430
|
+
expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
431
|
+
expect(io.outLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
432
|
+
expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('uploads repeatable images in order and sends only canonical media references', async () => {
|
|
436
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
|
|
437
|
+
const first = path.join(root, 'first.png')
|
|
438
|
+
const second = path.join(root, 'second.gif')
|
|
439
|
+
writeFileSync(first, 'first image')
|
|
440
|
+
writeFileSync(second, 'second image')
|
|
441
|
+
const uploaded: string[] = []
|
|
442
|
+
let grants = 0
|
|
443
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
444
|
+
const client = {
|
|
445
|
+
createMediaUpload: async () => {
|
|
446
|
+
grants += 1
|
|
447
|
+
return {
|
|
448
|
+
media_id: `med_uploaded_${grants}`,
|
|
449
|
+
upload_url: `https://upload.invalid/${grants}`,
|
|
450
|
+
upload_headers: {},
|
|
451
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
uploadMedia: async (grant: { media_id: string }) => {
|
|
455
|
+
uploaded.push(grant.media_id)
|
|
456
|
+
},
|
|
457
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
458
|
+
submitted = body
|
|
459
|
+
return receipt
|
|
460
|
+
},
|
|
461
|
+
} as unknown as ApiClient
|
|
462
|
+
|
|
463
|
+
expect(
|
|
464
|
+
await sendCommand(makeDeps(new CapturedIo(), client), { kind: 'update',
|
|
465
|
+
title: 'Visual comparison is ready',
|
|
466
|
+
body:
|
|
467
|
+
'  ',
|
|
468
|
+
image: [first, second, 'med_attached_only'],
|
|
469
|
+
imageAlt: ['First state', 'Second state'],
|
|
470
|
+
}),
|
|
471
|
+
).toBe(EXIT.ok)
|
|
472
|
+
|
|
473
|
+
expect(uploaded).toEqual(['med_uploaded_1', 'med_uploaded_2'])
|
|
474
|
+
expect(submitted?.draft.presentation.media).toEqual([
|
|
475
|
+
{ media_id: 'med_uploaded_1', alt: 'First state' },
|
|
476
|
+
{ media_id: 'med_uploaded_2', alt: 'Second state' },
|
|
477
|
+
{ media_id: 'med_attached_only' },
|
|
478
|
+
])
|
|
479
|
+
expect(submitted?.draft.presentation.body).toBe(
|
|
480
|
+
'  ',
|
|
481
|
+
)
|
|
482
|
+
expect(submitted?.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('rejects media cardinality and alt pairing before any upload', async () => {
|
|
486
|
+
const io = new CapturedIo()
|
|
487
|
+
let uploads = 0
|
|
488
|
+
const client = {
|
|
489
|
+
createMediaUpload: async () => {
|
|
490
|
+
uploads += 1
|
|
491
|
+
throw new Error('must not upload')
|
|
492
|
+
},
|
|
493
|
+
} as unknown as ApiClient
|
|
494
|
+
|
|
495
|
+
expect(
|
|
496
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
497
|
+
title: 'Visual comparison',
|
|
498
|
+
body: 'See it.',
|
|
499
|
+
image: ['one.png'],
|
|
500
|
+
imageAlt: ['one', 'two'],
|
|
501
|
+
}),
|
|
502
|
+
).toBe(EXIT.usage)
|
|
503
|
+
expect(uploads).toBe(0)
|
|
504
|
+
expect(io.errLines.join(' ')).toContain('2 --image-alt')
|
|
505
|
+
})
|
|
506
|
+
|
|
391
507
|
it('rejects done plus reply before authentication or submission', async () => {
|
|
392
508
|
const io = new CapturedIo()
|
|
393
509
|
let submitCalls = 0
|
|
@@ -412,23 +528,19 @@ describe('command contracts', () => {
|
|
|
412
528
|
|
|
413
529
|
it.each([
|
|
414
530
|
{
|
|
415
|
-
flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
|
|
531
|
+
flags: { kind: 'update', title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
|
|
416
532
|
warning: /titles work best around 40/i,
|
|
417
533
|
},
|
|
418
534
|
{
|
|
419
|
-
flags: { title: '
|
|
420
|
-
warning:
|
|
535
|
+
flags: { kind: 'done', title: 'Done · build', body: 'All green.' },
|
|
536
|
+
warning: /keep the title to the specific substance/i,
|
|
421
537
|
},
|
|
422
538
|
{
|
|
423
|
-
flags: { title: '
|
|
424
|
-
warning: /
|
|
539
|
+
flags: { kind: 'failed', title: 'Failed · build', body: 'One integration test failed.' },
|
|
540
|
+
warning: /Put notification type in --kind/i,
|
|
425
541
|
},
|
|
426
542
|
{
|
|
427
|
-
flags: {
|
|
428
|
-
warning: /Use --kind done/i,
|
|
429
|
-
},
|
|
430
|
-
{
|
|
431
|
-
flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
543
|
+
flags: { kind: 'update', title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
432
544
|
warning: /longer than 72 hours/i,
|
|
433
545
|
},
|
|
434
546
|
])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
|
|
@@ -452,18 +564,31 @@ describe('command contracts', () => {
|
|
|
452
564
|
cwd: root,
|
|
453
565
|
}
|
|
454
566
|
|
|
455
|
-
expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
|
|
567
|
+
expect(await sendCommand(deps, { kind: 'update', title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
|
|
456
568
|
expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
|
|
457
569
|
})
|
|
458
570
|
|
|
459
|
-
it('
|
|
571
|
+
it('keeps only the current public notification-authoring flags', () => {
|
|
460
572
|
const source = readFileSync(new URL('./main.ts', import.meta.url), 'utf8')
|
|
461
573
|
expect(source).not.toContain(".option('--badge")
|
|
462
574
|
expect(source).not.toContain(".option('--relevance")
|
|
463
575
|
expect(source).not.toContain(".option('--target-content-id")
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
576
|
+
const sendGrammar = source.slice(source.indexOf(".command('send')"), source.indexOf('send.addHelpText'))
|
|
577
|
+
const askGrammar = source.slice(source.indexOf(".command('ask [question]')"), source.indexOf(".command('close"))
|
|
578
|
+
for (const grammar of [sendGrammar, askGrammar]) {
|
|
579
|
+
expect(grammar).not.toContain(".option('--detail")
|
|
580
|
+
expect(grammar).not.toContain(".option('--session <")
|
|
581
|
+
expect(grammar).toContain(".option('--body-file")
|
|
582
|
+
expect(grammar).toContain(".option('--session-id")
|
|
583
|
+
expect(grammar).toContain(".option('--session-label")
|
|
584
|
+
expect(grammar).toContain("'--image <path|url|media_id>'")
|
|
585
|
+
expect(grammar).toContain("'--image-alt <text>'")
|
|
586
|
+
}
|
|
587
|
+
// Kind now selects the sound, so the help must say so — and must not carry
|
|
588
|
+
// the retired separation it replaced.
|
|
589
|
+
expect(source).toContain('Kind is required, and it selects the sound')
|
|
590
|
+
expect(source).not.toContain('it never chooses banner sound or interruption level')
|
|
591
|
+
expect(source).not.toContain('Kind profiles apply automatically')
|
|
467
592
|
})
|
|
468
593
|
|
|
469
594
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -542,8 +667,8 @@ describe('command contracts', () => {
|
|
|
542
667
|
})
|
|
543
668
|
|
|
544
669
|
it.each([
|
|
545
|
-
{ title: 'Deploy? ', body: 'Ready.' },
|
|
546
|
-
{ title: 'Deployment', body: 'Should I deploy?\n' },
|
|
670
|
+
{ kind: 'update', title: 'Deploy? ', body: 'Ready.' },
|
|
671
|
+
{ kind: 'update', title: 'Deployment', body: 'Should I deploy?\n' },
|
|
547
672
|
])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
|
|
548
673
|
const io = new CapturedIo()
|
|
549
674
|
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
@@ -583,7 +708,7 @@ describe('command contracts', () => {
|
|
|
583
708
|
} as unknown as ApiClient
|
|
584
709
|
|
|
585
710
|
expect(
|
|
586
|
-
await sendCommand(makeDeps(io, client), {
|
|
711
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
587
712
|
title: 'Deploy?',
|
|
588
713
|
body: 'Choose when ready.',
|
|
589
714
|
replyChoice: ['Now', 'Later'],
|
|
@@ -600,7 +725,7 @@ describe('command contracts', () => {
|
|
|
600
725
|
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
601
726
|
|
|
602
727
|
expect(
|
|
603
|
-
await sendCommand(makeDeps(io, client), {
|
|
728
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
604
729
|
title: 'Deployment',
|
|
605
730
|
body: 'Should I deploy?',
|
|
606
731
|
json: true,
|
|
@@ -822,6 +947,7 @@ describe('command contracts', () => {
|
|
|
822
947
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
823
948
|
replies: [],
|
|
824
949
|
agent_acknowledgement_required: true,
|
|
950
|
+
agent_acknowledgement_text_required: true,
|
|
825
951
|
agent_acknowledgement: null,
|
|
826
952
|
acknowledgement_command: null,
|
|
827
953
|
degraded: false,
|
|
@@ -855,6 +981,7 @@ describe('command contracts', () => {
|
|
|
855
981
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
856
982
|
replies: [reply],
|
|
857
983
|
agent_acknowledgement_required: true,
|
|
984
|
+
agent_acknowledgement_text_required: true,
|
|
858
985
|
agent_acknowledgement: null,
|
|
859
986
|
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
860
987
|
degraded: false,
|
|
@@ -968,9 +1095,11 @@ describe('command contracts', () => {
|
|
|
968
1095
|
})
|
|
969
1096
|
|
|
970
1097
|
it.each([
|
|
971
|
-
{ text:
|
|
972
|
-
{
|
|
973
|
-
|
|
1098
|
+
{ text: ' ', message: 'must contain non-whitespace text' },
|
|
1099
|
+
{
|
|
1100
|
+
text: 'x'.repeat(AGENT_ACKNOWLEDGEMENT_MAX_LENGTH + 1),
|
|
1101
|
+
message: `at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters`,
|
|
1102
|
+
},
|
|
974
1103
|
])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
|
|
975
1104
|
const io = new CapturedIo()
|
|
976
1105
|
let calls = 0
|
|
@@ -988,6 +1117,60 @@ describe('command contracts', () => {
|
|
|
988
1117
|
expect(io.errLines.join('\n')).toContain(message)
|
|
989
1118
|
})
|
|
990
1119
|
|
|
1120
|
+
it('records an acknowledgement with no text at all', async () => {
|
|
1121
|
+
const io = new CapturedIo()
|
|
1122
|
+
let submitted: { requestId: string; body: { text?: string } } | undefined
|
|
1123
|
+
const client = {
|
|
1124
|
+
putAgentAcknowledgement: async (requestId: string, body: { text?: string }) => {
|
|
1125
|
+
submitted = { requestId, body }
|
|
1126
|
+
return {
|
|
1127
|
+
status: 'recorded' as const,
|
|
1128
|
+
agent_acknowledgement: { text: '', created_at: '2026-08-13T12:01:00.000Z' },
|
|
1129
|
+
}
|
|
1130
|
+
},
|
|
1131
|
+
} as unknown as ApiClient
|
|
1132
|
+
|
|
1133
|
+
expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
|
|
1134
|
+
// No empty string either: an omitted field is what "no text" means on the
|
|
1135
|
+
// wire, and the account's snapshot decides whether that was allowed.
|
|
1136
|
+
expect(submitted).toEqual({ requestId: receipt.request_id, body: {} })
|
|
1137
|
+
expect(io.outLines.join('\n')).toContain(`Agent Acknowledgement recorded for ${receipt.request_id}`)
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
it('names the missing text, not a contract mismatch, when the account requires it', async () => {
|
|
1141
|
+
const io = new CapturedIo()
|
|
1142
|
+
const client = {
|
|
1143
|
+
putAgentAcknowledgement: async () => {
|
|
1144
|
+
throw new ApiCallError(
|
|
1145
|
+
422,
|
|
1146
|
+
'acknowledgement_text_required',
|
|
1147
|
+
'This account requires Agent Acknowledgement text.',
|
|
1148
|
+
'Re-send the acknowledgement with text saying what you will do because of the reply.',
|
|
1149
|
+
)
|
|
1150
|
+
},
|
|
1151
|
+
} as unknown as ApiClient
|
|
1152
|
+
|
|
1153
|
+
expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.failed)
|
|
1154
|
+
const err = io.errLines.join('\n')
|
|
1155
|
+
expect(err).toContain('acknowledgement_text_required')
|
|
1156
|
+
expect(err).toContain('next: Re-send the acknowledgement with text')
|
|
1157
|
+
// The contract held; only this body fell short of the account's choice.
|
|
1158
|
+
expect(err).not.toContain('disagree about the contract')
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
it('asks for a text-free acknowledgement when the account turned text off', async () => {
|
|
1162
|
+
const io = new CapturedIo()
|
|
1163
|
+
const client = {
|
|
1164
|
+
replies: async () => replyResponse([reply], { textRequired: false }),
|
|
1165
|
+
} as unknown as ApiClient
|
|
1166
|
+
|
|
1167
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
|
|
1168
|
+
const out = io.outLines.join('\n')
|
|
1169
|
+
expect(out).toContain('Agent Acknowledgement required.')
|
|
1170
|
+
expect(out).toContain(`notifai acknowledge ${receipt.request_id}\``)
|
|
1171
|
+
expect(out).not.toContain('--text')
|
|
1172
|
+
})
|
|
1173
|
+
|
|
991
1174
|
it.each([
|
|
992
1175
|
[new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
|
|
993
1176
|
[new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
|
|
@@ -1596,7 +1779,13 @@ describe('Codex hook representation', () => {
|
|
|
1596
1779
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
1597
1780
|
})
|
|
1598
1781
|
|
|
1599
|
-
|
|
1782
|
+
/**
|
|
1783
|
+
* The foreign `hooks.json` stays byte-identical and Notifai lands inline.
|
|
1784
|
+
* Codex will now warn that the layer uses both representations; the install
|
|
1785
|
+
* output says why, because that warning is Codex reporting a merge, not a
|
|
1786
|
+
* fault, and a silent one would read as Notifai's bug.
|
|
1787
|
+
*/
|
|
1788
|
+
it('leaves a foreign hooks.json alone and installs inline instead', () => {
|
|
1600
1789
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
|
|
1601
1790
|
const layer = path.join(cwd, '.codex')
|
|
1602
1791
|
const json = path.join(layer, 'hooks.json')
|
|
@@ -1614,11 +1803,17 @@ describe('Codex hook representation', () => {
|
|
|
1614
1803
|
|
|
1615
1804
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1616
1805
|
|
|
1617
|
-
|
|
1618
|
-
expect(
|
|
1619
|
-
expect(
|
|
1620
|
-
|
|
1621
|
-
|
|
1806
|
+
const beforeJson = readFileSync(json, 'utf8')
|
|
1807
|
+
expect(beforeJson).toContain('gdh-stop')
|
|
1808
|
+
expect(beforeJson).not.toContain('--owner notifai')
|
|
1809
|
+
const after = readFileSync(toml, 'utf8')
|
|
1810
|
+
// The user's file arrives back as the file they wrote, plus our hooks.
|
|
1811
|
+
expect(after).toContain(beforeToml.trimEnd())
|
|
1812
|
+
expect(after).toContain('--owner notifai')
|
|
1813
|
+
expect(io.outLines.join('\n')).toContain(toml)
|
|
1814
|
+
expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
|
|
1815
|
+
expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
|
|
1816
|
+
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
1622
1817
|
})
|
|
1623
1818
|
|
|
1624
1819
|
it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
|
|
@@ -1635,8 +1830,14 @@ describe('Codex hook representation', () => {
|
|
|
1635
1830
|
expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
|
|
1636
1831
|
})
|
|
1637
1832
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1833
|
+
/**
|
|
1834
|
+
* Notifai in one file, someone else's hooks in the other: every handler
|
|
1835
|
+
* fires once, so doctor has nothing to report and the foreign file is not
|
|
1836
|
+
* ours to rewrite. Doctor used to fail here — the check read "two files" as
|
|
1837
|
+
* "two Notifai copies" and pointed at configuration another program owns.
|
|
1838
|
+
*/
|
|
1839
|
+
it('passes doctor when only the other representation is foreign', async () => {
|
|
1840
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-rep-'))
|
|
1640
1841
|
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1641
1842
|
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1642
1843
|
const env = isolatedEnv(cwd)
|
|
@@ -1656,12 +1857,12 @@ describe('Codex hook representation', () => {
|
|
|
1656
1857
|
})
|
|
1657
1858
|
|
|
1658
1859
|
io.outLines = []
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
expect(
|
|
1663
|
-
expect(
|
|
1664
|
-
expect(
|
|
1860
|
+
await doctorCommand(deps, {})
|
|
1861
|
+
// Their assertions, this branch's check title.
|
|
1862
|
+
const report = io.outLines.join('\n')
|
|
1863
|
+
expect(report).not.toMatch(/FAIL {2}Codex hook representation/)
|
|
1864
|
+
expect(report).toMatch(/ok {4}Codex hook representation/)
|
|
1865
|
+
expect(report).toMatch(/Notifai will not modify it/)
|
|
1665
1866
|
|
|
1666
1867
|
io.outLines = []
|
|
1667
1868
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
@@ -1669,7 +1870,7 @@ describe('Codex hook representation', () => {
|
|
|
1669
1870
|
expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
|
|
1670
1871
|
expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
|
|
1671
1872
|
expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
|
|
1672
|
-
expect(io.outLines.join('\n')).toMatch(/
|
|
1873
|
+
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
1673
1874
|
expect(io.outLines.join('\n')).not.toMatch(/collaps/i)
|
|
1674
1875
|
|
|
1675
1876
|
const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
@@ -1678,6 +1879,44 @@ describe('Codex hook representation', () => {
|
|
|
1678
1879
|
expect(installations).toHaveLength(1)
|
|
1679
1880
|
expect(installations[0]?.file).toBe(json)
|
|
1680
1881
|
})
|
|
1882
|
+
|
|
1883
|
+
it('fails doctor when Notifai itself is installed in both representations', async () => {
|
|
1884
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
|
|
1885
|
+
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1886
|
+
const env = isolatedEnv(cwd)
|
|
1887
|
+
const io = new CapturedIo()
|
|
1888
|
+
const client = {
|
|
1889
|
+
health: async () => false,
|
|
1890
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1891
|
+
listDevices: async () => ({ devices: [] }),
|
|
1892
|
+
} as unknown as ApiClient
|
|
1893
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
1894
|
+
|
|
1895
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1896
|
+
applyPlan(json, {
|
|
1897
|
+
hooks: buildHookConfig({
|
|
1898
|
+
adapterPath: hookAdapterPath(deps.hookAdapterHome),
|
|
1899
|
+
harness: 'codex',
|
|
1900
|
+
}),
|
|
1901
|
+
})
|
|
1902
|
+
|
|
1903
|
+
io.outLines = []
|
|
1904
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1905
|
+
const reported = io.outLines.join('\n')
|
|
1906
|
+
expect(reported).toMatch(/Codex hook representation/)
|
|
1907
|
+
expect(reported).toMatch(/installed in both/i)
|
|
1908
|
+
expect(reported).toMatch(/notifies twice per turn/)
|
|
1909
|
+
expect(reported).toMatch(/notifai hooks uninstall --harness codex/)
|
|
1910
|
+
|
|
1911
|
+
// The named remedy has to actually clear it, including the copy in the
|
|
1912
|
+
// file Notifai would not have chosen to write.
|
|
1913
|
+
io.outLines = []
|
|
1914
|
+
expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
|
|
1915
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1916
|
+
io.outLines = []
|
|
1917
|
+
await doctorCommand(deps, {})
|
|
1918
|
+
expect(io.outLines.join('\n')).not.toMatch(/Codex hook representation/)
|
|
1919
|
+
})
|
|
1681
1920
|
})
|
|
1682
1921
|
|
|
1683
1922
|
describe('harness activation guidance', () => {
|
|
@@ -1793,14 +2032,14 @@ describe('harness activation guidance', () => {
|
|
|
1793
2032
|
|
|
1794
2033
|
io.outLines = []
|
|
1795
2034
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1796
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2035
|
+
expect(io.outLines.join('\n')).toContain('How an answer returns')
|
|
1797
2036
|
expect(io.outLines.join('\n')).toContain('no proven answer continuation')
|
|
1798
|
-
expect(io.outLines.join('\n')).not.toContain('
|
|
2037
|
+
expect(io.outLines.join('\n')).not.toContain('Hook adapter')
|
|
1799
2038
|
|
|
1800
2039
|
writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
|
|
1801
2040
|
io.outLines = []
|
|
1802
2041
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1803
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2042
|
+
expect(io.outLines.join('\n')).toContain('Hook adapter')
|
|
1804
2043
|
expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
|
|
1805
2044
|
})
|
|
1806
2045
|
|
|
@@ -1899,7 +2138,7 @@ describe('stable hook installation', () => {
|
|
|
1899
2138
|
|
|
1900
2139
|
io.outLines = []
|
|
1901
2140
|
await doctorCommand(deps, {})
|
|
1902
|
-
expect(io.outLines.join('\n')).not.toMatch(/
|
|
2141
|
+
expect(io.outLines.join('\n')).not.toMatch(/Duplicate hook installs/)
|
|
1903
2142
|
|
|
1904
2143
|
expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
|
|
1905
2144
|
expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
|
|
@@ -3732,6 +3971,74 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3732
3971
|
expect(io.outLines).toContain(
|
|
3733
3972
|
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
3734
3973
|
)
|
|
3974
|
+
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
3975
|
+
session_id: 'codex-current-thread',
|
|
3976
|
+
harness: 'codex',
|
|
3977
|
+
})
|
|
3978
|
+
expect(
|
|
3979
|
+
readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
|
|
3980
|
+
).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
3981
|
+
})
|
|
3982
|
+
|
|
3983
|
+
it('uploads ask images before registration and freezes canonical body media', async () => {
|
|
3984
|
+
const cwd = scratchDir('notifai-ask-media-')
|
|
3985
|
+
const first = path.join(cwd, 'first.png')
|
|
3986
|
+
const second = path.join(cwd, 'second.gif')
|
|
3987
|
+
writeFileSync(first, 'first')
|
|
3988
|
+
writeFileSync(second, 'second')
|
|
3989
|
+
const io = new CapturedIo()
|
|
3990
|
+
const env = {
|
|
3991
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3992
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3993
|
+
HOME: path.join(cwd, 'home'),
|
|
3994
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3995
|
+
CODEX_THREAD_ID: 'codex-media-thread',
|
|
3996
|
+
}
|
|
3997
|
+
let grant = 0
|
|
3998
|
+
const uploaded: string[] = []
|
|
3999
|
+
const client = {
|
|
4000
|
+
createMediaUpload: async () => {
|
|
4001
|
+
grant += 1
|
|
4002
|
+
return {
|
|
4003
|
+
media_id: `med_ask_${grant}`,
|
|
4004
|
+
upload_url: `https://upload.invalid/${grant}`,
|
|
4005
|
+
upload_headers: {},
|
|
4006
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
4007
|
+
}
|
|
4008
|
+
},
|
|
4009
|
+
uploadMedia: async (value: { media_id: string }) => {
|
|
4010
|
+
uploaded.push(value.media_id)
|
|
4011
|
+
},
|
|
4012
|
+
} as unknown as ApiClient
|
|
4013
|
+
const deps = { ...makeDeps(io, client), cwd, env, now: () => 42 }
|
|
4014
|
+
|
|
4015
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
4016
|
+
trustInstalledCodexHooks(cwd, env)
|
|
4017
|
+
writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
4018
|
+
writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
|
|
4019
|
+
io.outLines = []
|
|
4020
|
+
|
|
4021
|
+
expect(
|
|
4022
|
+
await askCommand(deps, 'Which visual should I use?', {
|
|
4023
|
+
body: 'Compare  with .',
|
|
4024
|
+
image: [first, second],
|
|
4025
|
+
imageAlt: ['First option', 'Second option'],
|
|
4026
|
+
}),
|
|
4027
|
+
).toBe(EXIT.ok)
|
|
4028
|
+
|
|
4029
|
+
const pending = readSessionState('codex-media-thread', env).pending?.[0]
|
|
4030
|
+
expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
|
|
4031
|
+
expect(pending?.body).toBe(
|
|
4032
|
+
'Which visual should I use?\n\nCompare  with .',
|
|
4033
|
+
)
|
|
4034
|
+
expect(pending?.media).toEqual([
|
|
4035
|
+
{ media_id: 'med_ask_1', alt: 'First option' },
|
|
4036
|
+
{ media_id: 'med_ask_2', alt: 'Second option' },
|
|
4037
|
+
])
|
|
4038
|
+
expect(pending?.source).toMatchObject({
|
|
4039
|
+
session_id: 'codex-media-thread',
|
|
4040
|
+
harness: 'codex',
|
|
4041
|
+
})
|
|
3735
4042
|
})
|
|
3736
4043
|
|
|
3737
4044
|
// A harness exports its markers into everything it starts, so a nested
|
|
@@ -3763,7 +4070,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3763
4070
|
io.outLines = []
|
|
3764
4071
|
|
|
3765
4072
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3766
|
-
expect(readSessionState('codex-current-thread', env).pending?.[0]
|
|
4073
|
+
expect(readSessionState('codex-current-thread', env).pending?.[0]).toMatchObject({
|
|
4074
|
+
question: 'Ship it?',
|
|
4075
|
+
source: { session_id: 'codex-current-thread', harness: 'codex' },
|
|
4076
|
+
})
|
|
3767
4077
|
expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
|
|
3768
4078
|
})
|
|
3769
4079
|
|
|
@@ -3792,7 +4102,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3792
4102
|
io.outLines = []
|
|
3793
4103
|
|
|
3794
4104
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3795
|
-
expect(readSessionState('claude-current', env).pending?.[0]
|
|
4105
|
+
expect(readSessionState('claude-current', env).pending?.[0]).toMatchObject({
|
|
4106
|
+
question: 'Ship it?',
|
|
4107
|
+
source: { session_id: 'claude-current', harness: 'claude-code' },
|
|
4108
|
+
})
|
|
3796
4109
|
expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
|
|
3797
4110
|
})
|
|
3798
4111
|
|
|
@@ -3944,7 +4257,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3944
4257
|
|
|
3945
4258
|
io.outLines = []
|
|
3946
4259
|
await doctorCommand(deps, {})
|
|
3947
|
-
expect(io.outLines.join('\n')).toMatch(/FAIL\s+
|
|
4260
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
|
|
3948
4261
|
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
3949
4262
|
})
|
|
3950
4263
|
|
|
@@ -4197,7 +4510,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4197
4510
|
io.outLines = []
|
|
4198
4511
|
|
|
4199
4512
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
4200
|
-
expect(io.outLines.join('\n')).toMatch(/FAIL\s+
|
|
4513
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Routing for this harness.*active Claude Code/is)
|
|
4201
4514
|
})
|
|
4202
4515
|
|
|
4203
4516
|
it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
|
|
@@ -4266,7 +4579,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4266
4579
|
io.outLines = []
|
|
4267
4580
|
|
|
4268
4581
|
await doctorCommand(deps, {})
|
|
4269
|
-
expect(io.outLines.join('\n')).toMatch(/ok\s+
|
|
4582
|
+
expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
|
|
4270
4583
|
})
|
|
4271
4584
|
|
|
4272
4585
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
@@ -4295,7 +4608,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4295
4608
|
|
|
4296
4609
|
await doctorCommand(deps, {})
|
|
4297
4610
|
const out = io.outLines.join('\n')
|
|
4298
|
-
expect(out).toMatch(/FAIL\s+
|
|
4611
|
+
expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
|
|
4299
4612
|
expect(out).toContain('needs `async: true`')
|
|
4300
4613
|
expect(out).toContain('kills the backgrounded waiter at its 600s default')
|
|
4301
4614
|
})
|
|
@@ -4338,7 +4651,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4338
4651
|
|
|
4339
4652
|
await doctorCommand(deps, {})
|
|
4340
4653
|
const out = io.outLines.join('\n')
|
|
4341
|
-
expect(out).toMatch(/ok\s+
|
|
4654
|
+
expect(out).toMatch(/ok\s+Direct wake route/)
|
|
4342
4655
|
expect(out).toContain(socket)
|
|
4343
4656
|
expect(out).toContain('an answer can start a turn here without you')
|
|
4344
4657
|
// Doctor never widens the user's inbound policy to make Notifai work: the
|
|
@@ -4355,10 +4668,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4355
4668
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
|
|
4356
4669
|
const out = io.outLines.join('\n')
|
|
4357
4670
|
// Honest, and not a blocker: nothing is lost, it simply arrives later.
|
|
4358
|
-
expect(out).toMatch(/--\s+
|
|
4671
|
+
expect(out).toMatch(/--\s+Direct wake route/)
|
|
4359
4672
|
expect(out).toContain('`--bare` binds no inbox socket')
|
|
4360
4673
|
expect(out).toContain("at this session's next turn")
|
|
4361
|
-
expect(out).not.toMatch(/FAIL\s+
|
|
4674
|
+
expect(out).not.toMatch(/FAIL\s+Direct wake route/)
|
|
4362
4675
|
})
|
|
4363
4676
|
|
|
4364
4677
|
it('fails closed on an inbox protocol it does not recognise', async () => {
|
|
@@ -4406,7 +4719,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4406
4719
|
io.outLines = []
|
|
4407
4720
|
await doctorCommand(deps, {})
|
|
4408
4721
|
const out = io.outLines.join('\n')
|
|
4409
|
-
expect(out).toMatch(/ok\s+
|
|
4722
|
+
expect(out).toMatch(/ok\s+Direct wake route/)
|
|
4410
4723
|
expect(out).toContain('can prove a stopped thread unowned before resuming it')
|
|
4411
4724
|
})
|
|
4412
4725
|
|
|
@@ -4566,6 +4879,66 @@ describe('a server behind this CLI', () => {
|
|
|
4566
4879
|
})
|
|
4567
4880
|
})
|
|
4568
4881
|
|
|
4882
|
+
/**
|
|
4883
|
+
* The same class of failure through the command path. A rejected `send` printed
|
|
4884
|
+
* only "invalid_request: The draft was not accepted." while the server's
|
|
4885
|
+
* details — naming the exact offending field — went to the local log alone.
|
|
4886
|
+
* stderr is the one line an agent's next turn actually reads, so the diagnosis
|
|
4887
|
+
* has to be on it. Direction-neutral on purpose: the mismatch has been seen
|
|
4888
|
+
* with the server behind the CLI and with the CLI behind the server.
|
|
4889
|
+
*/
|
|
4890
|
+
describe('command failures carrying server details', () => {
|
|
4891
|
+
const throwingClient = (status: number, details: unknown = null): ApiClient =>
|
|
4892
|
+
({
|
|
4893
|
+
submit: async () => {
|
|
4894
|
+
throw new ApiCallError(status, 'invalid_request', 'The draft was not accepted.', null, details)
|
|
4895
|
+
},
|
|
4896
|
+
}) as unknown as ApiClient
|
|
4897
|
+
|
|
4898
|
+
it('names the rejected field and the contract mismatch on a 422', async () => {
|
|
4899
|
+
const io = new CapturedIo()
|
|
4900
|
+
const client = throwingClient(422, [
|
|
4901
|
+
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4902
|
+
])
|
|
4903
|
+
|
|
4904
|
+
expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4905
|
+
EXIT.failed,
|
|
4906
|
+
)
|
|
4907
|
+
|
|
4908
|
+
const said = io.errLines.join(' ')
|
|
4909
|
+
expect(said).toContain('the server rejected: presentation.detail')
|
|
4910
|
+
expect(said).toMatch(/sent a field the server did not accept/)
|
|
4911
|
+
expect(said).toContain('notifai doctor')
|
|
4912
|
+
expect(said).not.toMatch(/older than this CLI/)
|
|
4913
|
+
})
|
|
4914
|
+
|
|
4915
|
+
it('prints the rejected paths without the contract line when the status is not 422', async () => {
|
|
4916
|
+
const io = new CapturedIo()
|
|
4917
|
+
const client = throwingClient(400, [
|
|
4918
|
+
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4919
|
+
])
|
|
4920
|
+
|
|
4921
|
+
expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4922
|
+
EXIT.failed,
|
|
4923
|
+
)
|
|
4924
|
+
|
|
4925
|
+
const said = io.errLines.join(' ')
|
|
4926
|
+
expect(said).toContain('the server rejected: presentation.detail')
|
|
4927
|
+
expect(said).not.toContain('notifai doctor')
|
|
4928
|
+
expect(said).not.toMatch(/did not accept/)
|
|
4929
|
+
})
|
|
4930
|
+
|
|
4931
|
+
it('prints exactly the code and message when the error carries no details', async () => {
|
|
4932
|
+
const io = new CapturedIo()
|
|
4933
|
+
|
|
4934
|
+
expect(await sendCommand(makeDeps(io, throwingClient(409)), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4935
|
+
EXIT.failed,
|
|
4936
|
+
)
|
|
4937
|
+
|
|
4938
|
+
expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
|
|
4939
|
+
})
|
|
4940
|
+
})
|
|
4941
|
+
|
|
4569
4942
|
describe('question sets', () => {
|
|
4570
4943
|
it('maps --reply-multi into the single question', async () => {
|
|
4571
4944
|
const io = new CapturedIo()
|
|
@@ -4595,26 +4968,76 @@ describe('question sets', () => {
|
|
|
4595
4968
|
expect(submitted?.draft.reply?.questions?.[0]?.choices).toHaveLength(3)
|
|
4596
4969
|
})
|
|
4597
4970
|
|
|
4598
|
-
it('
|
|
4971
|
+
it('derives the answerable question from the first banner block', async () => {
|
|
4599
4972
|
const io = new CapturedIo()
|
|
4600
|
-
let
|
|
4973
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
4601
4974
|
const client = {
|
|
4602
|
-
submit: async () => {
|
|
4603
|
-
|
|
4975
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
4976
|
+
submitted = body
|
|
4604
4977
|
return receipt
|
|
4605
4978
|
},
|
|
4979
|
+
replies: async () => replyResponse([reply]),
|
|
4606
4980
|
} as unknown as ApiClient
|
|
4607
4981
|
|
|
4982
|
+
const body = `Which environment?\n\n${'Long Markdown context. '.repeat(30)}`
|
|
4608
4983
|
expect(
|
|
4609
4984
|
await sendCommand(makeDeps(io, client), {
|
|
4610
|
-
title: '
|
|
4611
|
-
body
|
|
4985
|
+
title: 'Choose the deployment environment',
|
|
4986
|
+
body,
|
|
4612
4987
|
reply: true,
|
|
4613
4988
|
replyTimeout: 30,
|
|
4614
4989
|
}),
|
|
4615
|
-
).toBe(EXIT.
|
|
4616
|
-
expect(
|
|
4617
|
-
expect(
|
|
4990
|
+
).toBe(EXIT.ok)
|
|
4991
|
+
expect(submitted?.draft.presentation.body).toBe(body)
|
|
4992
|
+
expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
|
|
4993
|
+
})
|
|
4994
|
+
|
|
4995
|
+
it('composes one question before optional Markdown context', () => {
|
|
4996
|
+
expect(
|
|
4997
|
+
buildQuestions(
|
|
4998
|
+
{ body: '## Why\nThe release window closes today.' },
|
|
4999
|
+
'Which environment?',
|
|
5000
|
+
),
|
|
5001
|
+
).toMatchObject({
|
|
5002
|
+
ok: true,
|
|
5003
|
+
body: 'Which environment?\n\n## Why\nThe release window closes today.',
|
|
5004
|
+
questions: [{ text: 'Which environment?' }],
|
|
5005
|
+
})
|
|
5006
|
+
})
|
|
5007
|
+
|
|
5008
|
+
it('composes numbered form questions before form context', () => {
|
|
5009
|
+
expect(
|
|
5010
|
+
buildQuestions(
|
|
5011
|
+
{
|
|
5012
|
+
form: JSON.stringify({
|
|
5013
|
+
questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }],
|
|
5014
|
+
body: '## Context\nTraffic is elevated.',
|
|
5015
|
+
}),
|
|
5016
|
+
},
|
|
5017
|
+
undefined,
|
|
5018
|
+
),
|
|
5019
|
+
).toMatchObject({
|
|
5020
|
+
ok: true,
|
|
5021
|
+
body:
|
|
5022
|
+
'1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
|
|
5023
|
+
})
|
|
5024
|
+
})
|
|
5025
|
+
|
|
5026
|
+
it('rejects the deleted form detail key instead of treating it as context', () => {
|
|
5027
|
+
expect(
|
|
5028
|
+
buildQuestions(
|
|
5029
|
+
{
|
|
5030
|
+
form: JSON.stringify({
|
|
5031
|
+
questions: [{ text: 'Deploy?' }],
|
|
5032
|
+
detail: 'legacy context',
|
|
5033
|
+
}),
|
|
5034
|
+
},
|
|
5035
|
+
undefined,
|
|
5036
|
+
),
|
|
5037
|
+
).toMatchObject({
|
|
5038
|
+
ok: false,
|
|
5039
|
+
error: expect.stringContaining('Unknown --form key: detail'),
|
|
5040
|
+
})
|
|
4618
5041
|
})
|
|
4619
5042
|
|
|
4620
5043
|
it('generates unique question ids when texts collide', () => {
|
|
@@ -4632,6 +5055,17 @@ describe('question sets', () => {
|
|
|
4632
5055
|
}
|
|
4633
5056
|
})
|
|
4634
5057
|
|
|
5058
|
+
it('reports a registration as data, with the ids an agent must branch on', () => {
|
|
5059
|
+
// The prose block is the densest guidance this CLI prints, but a choice id
|
|
5060
|
+
// cannot be read back out of prose.
|
|
5061
|
+
const built = buildQuestions(
|
|
5062
|
+
{ choice: ['Deploy now', 'Hold'] },
|
|
5063
|
+
'Deploy migration 0007 to production?',
|
|
5064
|
+
)
|
|
5065
|
+
if (!built.ok) throw new Error(built.error)
|
|
5066
|
+
expect(built.questions[0]?.choices?.map((choice) => choice.id)).toEqual(['deploy-now', 'hold'])
|
|
5067
|
+
})
|
|
5068
|
+
|
|
4635
5069
|
it('rejects forms outside the documented shape', () => {
|
|
4636
5070
|
expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
|
|
4637
5071
|
expect(
|
|
@@ -4639,10 +5073,14 @@ describe('question sets', () => {
|
|
|
4639
5073
|
).toMatchObject({ ok: false })
|
|
4640
5074
|
expect(
|
|
4641
5075
|
buildQuestions(
|
|
4642
|
-
{
|
|
5076
|
+
{
|
|
5077
|
+
form: JSON.stringify({
|
|
5078
|
+
questions: Array.from({ length: REPLY_MAX_QUESTIONS + 1 }, (_, i) => ({ text: `Q${i}?` })),
|
|
5079
|
+
}),
|
|
5080
|
+
},
|
|
4643
5081
|
undefined,
|
|
4644
5082
|
),
|
|
4645
|
-
).toMatchObject({ ok: false, error: expect.stringContaining(
|
|
5083
|
+
).toMatchObject({ ok: false, error: expect.stringContaining(`1-${REPLY_MAX_QUESTIONS}`) })
|
|
4646
5084
|
expect(
|
|
4647
5085
|
buildQuestions(
|
|
4648
5086
|
{ form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },
|