@raidiant/notifai 4.0.0 → 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 +46 -0
- package/README.md +5 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +174 -58
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +14 -5
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -0
- 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 +72 -5
- package/dist/hooks.js +407 -79
- 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/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +44 -17
- 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 +6 -0
- package/dist/send.js +25 -14
- 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 +185 -51
- package/src/commands.ts +198 -56
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +16 -5
- package/src/config.test.ts +41 -9
- package/src/config.ts +13 -0
- 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 +358 -21
- package/src/hooks.ts +457 -97
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- 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 +57 -23
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- 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!',
|
|
@@ -456,7 +461,7 @@ describe('command contracts', () => {
|
|
|
456
461
|
} as unknown as ApiClient
|
|
457
462
|
|
|
458
463
|
expect(
|
|
459
|
-
await sendCommand(makeDeps(new CapturedIo(), client), {
|
|
464
|
+
await sendCommand(makeDeps(new CapturedIo(), client), { kind: 'update',
|
|
460
465
|
title: 'Visual comparison is ready',
|
|
461
466
|
body:
|
|
462
467
|
'  ',
|
|
@@ -488,7 +493,7 @@ describe('command contracts', () => {
|
|
|
488
493
|
} as unknown as ApiClient
|
|
489
494
|
|
|
490
495
|
expect(
|
|
491
|
-
await sendCommand(makeDeps(io, client), {
|
|
496
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
492
497
|
title: 'Visual comparison',
|
|
493
498
|
body: 'See it.',
|
|
494
499
|
image: ['one.png'],
|
|
@@ -523,19 +528,19 @@ describe('command contracts', () => {
|
|
|
523
528
|
|
|
524
529
|
it.each([
|
|
525
530
|
{
|
|
526
|
-
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.' },
|
|
527
532
|
warning: /titles work best around 40/i,
|
|
528
533
|
},
|
|
529
534
|
{
|
|
530
|
-
flags: { title: 'Done · build', body: 'All green.' },
|
|
535
|
+
flags: { kind: 'done', title: 'Done · build', body: 'All green.' },
|
|
531
536
|
warning: /keep the title to the specific substance/i,
|
|
532
537
|
},
|
|
533
538
|
{
|
|
534
|
-
flags: { title: 'Failed · build', body: 'One integration test failed.' },
|
|
539
|
+
flags: { kind: 'failed', title: 'Failed · build', body: 'One integration test failed.' },
|
|
535
540
|
warning: /Put notification type in --kind/i,
|
|
536
541
|
},
|
|
537
542
|
{
|
|
538
|
-
flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
543
|
+
flags: { kind: 'update', title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
539
544
|
warning: /longer than 72 hours/i,
|
|
540
545
|
},
|
|
541
546
|
])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
|
|
@@ -559,7 +564,7 @@ describe('command contracts', () => {
|
|
|
559
564
|
cwd: root,
|
|
560
565
|
}
|
|
561
566
|
|
|
562
|
-
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)
|
|
563
568
|
expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
|
|
564
569
|
})
|
|
565
570
|
|
|
@@ -579,11 +584,11 @@ describe('command contracts', () => {
|
|
|
579
584
|
expect(grammar).toContain("'--image <path|url|media_id>'")
|
|
580
585
|
expect(grammar).toContain("'--image-alt <text>'")
|
|
581
586
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
expect(source).toContain('
|
|
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')
|
|
585
591
|
expect(source).not.toContain('Kind profiles apply automatically')
|
|
586
|
-
expect(source).not.toContain('sound alert level active')
|
|
587
592
|
})
|
|
588
593
|
|
|
589
594
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -662,8 +667,8 @@ describe('command contracts', () => {
|
|
|
662
667
|
})
|
|
663
668
|
|
|
664
669
|
it.each([
|
|
665
|
-
{ title: 'Deploy? ', body: 'Ready.' },
|
|
666
|
-
{ title: 'Deployment', body: 'Should I deploy?\n' },
|
|
670
|
+
{ kind: 'update', title: 'Deploy? ', body: 'Ready.' },
|
|
671
|
+
{ kind: 'update', title: 'Deployment', body: 'Should I deploy?\n' },
|
|
667
672
|
])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
|
|
668
673
|
const io = new CapturedIo()
|
|
669
674
|
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
@@ -703,7 +708,7 @@ describe('command contracts', () => {
|
|
|
703
708
|
} as unknown as ApiClient
|
|
704
709
|
|
|
705
710
|
expect(
|
|
706
|
-
await sendCommand(makeDeps(io, client), {
|
|
711
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
707
712
|
title: 'Deploy?',
|
|
708
713
|
body: 'Choose when ready.',
|
|
709
714
|
replyChoice: ['Now', 'Later'],
|
|
@@ -720,7 +725,7 @@ describe('command contracts', () => {
|
|
|
720
725
|
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
721
726
|
|
|
722
727
|
expect(
|
|
723
|
-
await sendCommand(makeDeps(io, client), {
|
|
728
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
724
729
|
title: 'Deployment',
|
|
725
730
|
body: 'Should I deploy?',
|
|
726
731
|
json: true,
|
|
@@ -942,6 +947,7 @@ describe('command contracts', () => {
|
|
|
942
947
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
943
948
|
replies: [],
|
|
944
949
|
agent_acknowledgement_required: true,
|
|
950
|
+
agent_acknowledgement_text_required: true,
|
|
945
951
|
agent_acknowledgement: null,
|
|
946
952
|
acknowledgement_command: null,
|
|
947
953
|
degraded: false,
|
|
@@ -975,6 +981,7 @@ describe('command contracts', () => {
|
|
|
975
981
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
976
982
|
replies: [reply],
|
|
977
983
|
agent_acknowledgement_required: true,
|
|
984
|
+
agent_acknowledgement_text_required: true,
|
|
978
985
|
agent_acknowledgement: null,
|
|
979
986
|
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
980
987
|
degraded: false,
|
|
@@ -1088,9 +1095,11 @@ describe('command contracts', () => {
|
|
|
1088
1095
|
})
|
|
1089
1096
|
|
|
1090
1097
|
it.each([
|
|
1091
|
-
{ text:
|
|
1092
|
-
{
|
|
1093
|
-
|
|
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
|
+
},
|
|
1094
1103
|
])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
|
|
1095
1104
|
const io = new CapturedIo()
|
|
1096
1105
|
let calls = 0
|
|
@@ -1108,6 +1117,60 @@ describe('command contracts', () => {
|
|
|
1108
1117
|
expect(io.errLines.join('\n')).toContain(message)
|
|
1109
1118
|
})
|
|
1110
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
|
+
|
|
1111
1174
|
it.each([
|
|
1112
1175
|
[new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
|
|
1113
1176
|
[new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
|
|
@@ -1716,7 +1779,13 @@ describe('Codex hook representation', () => {
|
|
|
1716
1779
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
1717
1780
|
})
|
|
1718
1781
|
|
|
1719
|
-
|
|
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', () => {
|
|
1720
1789
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
|
|
1721
1790
|
const layer = path.join(cwd, '.codex')
|
|
1722
1791
|
const json = path.join(layer, 'hooks.json')
|
|
@@ -1734,11 +1803,17 @@ describe('Codex hook representation', () => {
|
|
|
1734
1803
|
|
|
1735
1804
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1736
1805
|
|
|
1737
|
-
|
|
1738
|
-
expect(
|
|
1739
|
-
expect(
|
|
1740
|
-
|
|
1741
|
-
|
|
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)
|
|
1742
1817
|
})
|
|
1743
1818
|
|
|
1744
1819
|
it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
|
|
@@ -1755,8 +1830,14 @@ describe('Codex hook representation', () => {
|
|
|
1755
1830
|
expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
|
|
1756
1831
|
})
|
|
1757
1832
|
|
|
1758
|
-
|
|
1759
|
-
|
|
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-'))
|
|
1760
1841
|
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1761
1842
|
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1762
1843
|
const env = isolatedEnv(cwd)
|
|
@@ -1776,12 +1857,12 @@ describe('Codex hook representation', () => {
|
|
|
1776
1857
|
})
|
|
1777
1858
|
|
|
1778
1859
|
io.outLines = []
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
expect(
|
|
1783
|
-
expect(
|
|
1784
|
-
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/)
|
|
1785
1866
|
|
|
1786
1867
|
io.outLines = []
|
|
1787
1868
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
@@ -1789,7 +1870,7 @@ describe('Codex hook representation', () => {
|
|
|
1789
1870
|
expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
|
|
1790
1871
|
expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
|
|
1791
1872
|
expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
|
|
1792
|
-
expect(io.outLines.join('\n')).toMatch(/
|
|
1873
|
+
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
1793
1874
|
expect(io.outLines.join('\n')).not.toMatch(/collaps/i)
|
|
1794
1875
|
|
|
1795
1876
|
const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
@@ -1798,6 +1879,44 @@ describe('Codex hook representation', () => {
|
|
|
1798
1879
|
expect(installations).toHaveLength(1)
|
|
1799
1880
|
expect(installations[0]?.file).toBe(json)
|
|
1800
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
|
+
})
|
|
1801
1920
|
})
|
|
1802
1921
|
|
|
1803
1922
|
describe('harness activation guidance', () => {
|
|
@@ -1913,14 +2032,14 @@ describe('harness activation guidance', () => {
|
|
|
1913
2032
|
|
|
1914
2033
|
io.outLines = []
|
|
1915
2034
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1916
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2035
|
+
expect(io.outLines.join('\n')).toContain('How an answer returns')
|
|
1917
2036
|
expect(io.outLines.join('\n')).toContain('no proven answer continuation')
|
|
1918
|
-
expect(io.outLines.join('\n')).not.toContain('
|
|
2037
|
+
expect(io.outLines.join('\n')).not.toContain('Hook adapter')
|
|
1919
2038
|
|
|
1920
2039
|
writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
|
|
1921
2040
|
io.outLines = []
|
|
1922
2041
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1923
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2042
|
+
expect(io.outLines.join('\n')).toContain('Hook adapter')
|
|
1924
2043
|
expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
|
|
1925
2044
|
})
|
|
1926
2045
|
|
|
@@ -2019,7 +2138,7 @@ describe('stable hook installation', () => {
|
|
|
2019
2138
|
|
|
2020
2139
|
io.outLines = []
|
|
2021
2140
|
await doctorCommand(deps, {})
|
|
2022
|
-
expect(io.outLines.join('\n')).not.toMatch(/
|
|
2141
|
+
expect(io.outLines.join('\n')).not.toMatch(/Duplicate hook installs/)
|
|
2023
2142
|
|
|
2024
2143
|
expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
|
|
2025
2144
|
expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
|
|
@@ -4138,7 +4257,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4138
4257
|
|
|
4139
4258
|
io.outLines = []
|
|
4140
4259
|
await doctorCommand(deps, {})
|
|
4141
|
-
expect(io.outLines.join('\n')).toMatch(/FAIL\s+
|
|
4260
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
|
|
4142
4261
|
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
4143
4262
|
})
|
|
4144
4263
|
|
|
@@ -4391,7 +4510,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4391
4510
|
io.outLines = []
|
|
4392
4511
|
|
|
4393
4512
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
4394
|
-
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)
|
|
4395
4514
|
})
|
|
4396
4515
|
|
|
4397
4516
|
it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
|
|
@@ -4460,7 +4579,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4460
4579
|
io.outLines = []
|
|
4461
4580
|
|
|
4462
4581
|
await doctorCommand(deps, {})
|
|
4463
|
-
expect(io.outLines.join('\n')).toMatch(/ok\s+
|
|
4582
|
+
expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
|
|
4464
4583
|
})
|
|
4465
4584
|
|
|
4466
4585
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
@@ -4489,7 +4608,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4489
4608
|
|
|
4490
4609
|
await doctorCommand(deps, {})
|
|
4491
4610
|
const out = io.outLines.join('\n')
|
|
4492
|
-
expect(out).toMatch(/FAIL\s+
|
|
4611
|
+
expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
|
|
4493
4612
|
expect(out).toContain('needs `async: true`')
|
|
4494
4613
|
expect(out).toContain('kills the backgrounded waiter at its 600s default')
|
|
4495
4614
|
})
|
|
@@ -4532,7 +4651,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4532
4651
|
|
|
4533
4652
|
await doctorCommand(deps, {})
|
|
4534
4653
|
const out = io.outLines.join('\n')
|
|
4535
|
-
expect(out).toMatch(/ok\s+
|
|
4654
|
+
expect(out).toMatch(/ok\s+Direct wake route/)
|
|
4536
4655
|
expect(out).toContain(socket)
|
|
4537
4656
|
expect(out).toContain('an answer can start a turn here without you')
|
|
4538
4657
|
// Doctor never widens the user's inbound policy to make Notifai work: the
|
|
@@ -4549,10 +4668,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4549
4668
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
|
|
4550
4669
|
const out = io.outLines.join('\n')
|
|
4551
4670
|
// Honest, and not a blocker: nothing is lost, it simply arrives later.
|
|
4552
|
-
expect(out).toMatch(/--\s+
|
|
4671
|
+
expect(out).toMatch(/--\s+Direct wake route/)
|
|
4553
4672
|
expect(out).toContain('`--bare` binds no inbox socket')
|
|
4554
4673
|
expect(out).toContain("at this session's next turn")
|
|
4555
|
-
expect(out).not.toMatch(/FAIL\s+
|
|
4674
|
+
expect(out).not.toMatch(/FAIL\s+Direct wake route/)
|
|
4556
4675
|
})
|
|
4557
4676
|
|
|
4558
4677
|
it('fails closed on an inbox protocol it does not recognise', async () => {
|
|
@@ -4600,7 +4719,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4600
4719
|
io.outLines = []
|
|
4601
4720
|
await doctorCommand(deps, {})
|
|
4602
4721
|
const out = io.outLines.join('\n')
|
|
4603
|
-
expect(out).toMatch(/ok\s+
|
|
4722
|
+
expect(out).toMatch(/ok\s+Direct wake route/)
|
|
4604
4723
|
expect(out).toContain('can prove a stopped thread unowned before resuming it')
|
|
4605
4724
|
})
|
|
4606
4725
|
|
|
@@ -4782,7 +4901,7 @@ describe('command failures carrying server details', () => {
|
|
|
4782
4901
|
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4783
4902
|
])
|
|
4784
4903
|
|
|
4785
|
-
expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4904
|
+
expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4786
4905
|
EXIT.failed,
|
|
4787
4906
|
)
|
|
4788
4907
|
|
|
@@ -4799,7 +4918,7 @@ describe('command failures carrying server details', () => {
|
|
|
4799
4918
|
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4800
4919
|
])
|
|
4801
4920
|
|
|
4802
|
-
expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4921
|
+
expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4803
4922
|
EXIT.failed,
|
|
4804
4923
|
)
|
|
4805
4924
|
|
|
@@ -4812,7 +4931,7 @@ describe('command failures carrying server details', () => {
|
|
|
4812
4931
|
it('prints exactly the code and message when the error carries no details', async () => {
|
|
4813
4932
|
const io = new CapturedIo()
|
|
4814
4933
|
|
|
4815
|
-
expect(await sendCommand(makeDeps(io, throwingClient(409)), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4934
|
+
expect(await sendCommand(makeDeps(io, throwingClient(409)), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4816
4935
|
EXIT.failed,
|
|
4817
4936
|
)
|
|
4818
4937
|
|
|
@@ -4936,6 +5055,17 @@ describe('question sets', () => {
|
|
|
4936
5055
|
}
|
|
4937
5056
|
})
|
|
4938
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
|
+
|
|
4939
5069
|
it('rejects forms outside the documented shape', () => {
|
|
4940
5070
|
expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
|
|
4941
5071
|
expect(
|
|
@@ -4943,10 +5073,14 @@ describe('question sets', () => {
|
|
|
4943
5073
|
).toMatchObject({ ok: false })
|
|
4944
5074
|
expect(
|
|
4945
5075
|
buildQuestions(
|
|
4946
|
-
{
|
|
5076
|
+
{
|
|
5077
|
+
form: JSON.stringify({
|
|
5078
|
+
questions: Array.from({ length: REPLY_MAX_QUESTIONS + 1 }, (_, i) => ({ text: `Q${i}?` })),
|
|
5079
|
+
}),
|
|
5080
|
+
},
|
|
4947
5081
|
undefined,
|
|
4948
5082
|
),
|
|
4949
|
-
).toMatchObject({ ok: false, error: expect.stringContaining(
|
|
5083
|
+
).toMatchObject({ ok: false, error: expect.stringContaining(`1-${REPLY_MAX_QUESTIONS}`) })
|
|
4950
5084
|
expect(
|
|
4951
5085
|
buildQuestions(
|
|
4952
5086
|
{ form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },
|