@raidiant/notifai 4.0.0 → 6.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 +7 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -378
- package/dist/commands.js +12 -4420
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +15 -6
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.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 +74 -7
- package/dist/hooks.js +522 -166
- 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 +5 -5
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +9 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +45 -18
- 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/readiness.d.ts +3 -1
- package/dist/readiness.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/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +677 -89
- package/src/commands.ts +58 -5472
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +17 -6
- package/src/config.test.ts +50 -9
- package/src/config.ts +19 -1
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- 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 +703 -26
- package/src/hooks.ts +590 -182
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +5 -5
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +48 -20
- package/src/logging.ts +12 -0
- package/src/main.ts +58 -24
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/readiness.ts +3 -1
- 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 { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
1
2
|
import {
|
|
2
3
|
existsSync,
|
|
3
4
|
mkdirSync,
|
|
@@ -11,14 +12,19 @@ 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,
|
|
18
|
+
CompatibilityResponse,
|
|
16
19
|
EvidenceSnapshot,
|
|
17
20
|
ListRepliesResponse,
|
|
21
|
+
Platform,
|
|
18
22
|
ReplyView,
|
|
19
23
|
SubmissionReceipt,
|
|
20
24
|
SubmitNotificationRequestT,
|
|
25
|
+
SupportAssessment,
|
|
21
26
|
} from '@raidiant/notifai-protocol'
|
|
27
|
+
import { parse as parseToml } from 'smol-toml'
|
|
22
28
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
23
29
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
24
30
|
import type { ClaudeWakeAdapters } from './claude-wake.js'
|
|
@@ -83,6 +89,71 @@ const RELEASE_REF = `v${
|
|
|
83
89
|
).version
|
|
84
90
|
}`
|
|
85
91
|
|
|
92
|
+
const currentSupport: SupportAssessment = {
|
|
93
|
+
state: 'current',
|
|
94
|
+
reason: 'current',
|
|
95
|
+
affected_operation: null,
|
|
96
|
+
recovery_action: null,
|
|
97
|
+
current_version: '5.0.0',
|
|
98
|
+
current_build: null,
|
|
99
|
+
recommended_version: '5.0.0',
|
|
100
|
+
recommended_build: null,
|
|
101
|
+
minimum_version: null,
|
|
102
|
+
minimum_build: null,
|
|
103
|
+
deprecation: null,
|
|
104
|
+
sunset: null,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const currentCompatibility: CompatibilityResponse = {
|
|
108
|
+
cli: currentSupport,
|
|
109
|
+
platforms: [
|
|
110
|
+
{
|
|
111
|
+
platform: 'ios',
|
|
112
|
+
recommended_version: null,
|
|
113
|
+
recommended_build: null,
|
|
114
|
+
minimum_receive_build: null,
|
|
115
|
+
minimum_answer_build: null,
|
|
116
|
+
deprecation: null,
|
|
117
|
+
sunset: null,
|
|
118
|
+
replacement_available: false,
|
|
119
|
+
rollout_complete: false,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
platform: 'macos',
|
|
123
|
+
recommended_version: null,
|
|
124
|
+
recommended_build: null,
|
|
125
|
+
minimum_receive_build: null,
|
|
126
|
+
minimum_answer_build: null,
|
|
127
|
+
deprecation: null,
|
|
128
|
+
sunset: null,
|
|
129
|
+
replacement_available: false,
|
|
130
|
+
rollout_complete: false,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function compatibilityWithCli(
|
|
137
|
+
overrides: Partial<SupportAssessment>,
|
|
138
|
+
): CompatibilityResponse {
|
|
139
|
+
return {
|
|
140
|
+
...currentCompatibility,
|
|
141
|
+
cli: { ...currentSupport, ...overrides },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function withCompatibilityDefaults(client: ApiClient): ApiClient {
|
|
146
|
+
return {
|
|
147
|
+
capabilities: async (platform: Platform = 'ios') => {
|
|
148
|
+
const document = CAPABILITIES_V1.describe(platform)
|
|
149
|
+
if (document === null) throw new Error(`missing ${platform} capability document`)
|
|
150
|
+
return document
|
|
151
|
+
},
|
|
152
|
+
compatibility: async () => currentCompatibility,
|
|
153
|
+
...client,
|
|
154
|
+
} as ApiClient
|
|
155
|
+
}
|
|
156
|
+
|
|
86
157
|
class CapturedIo implements CommandIo {
|
|
87
158
|
outLines: string[] = []
|
|
88
159
|
errLines: string[] = []
|
|
@@ -105,6 +176,10 @@ class CapturedIo implements CommandIo {
|
|
|
105
176
|
}
|
|
106
177
|
}
|
|
107
178
|
|
|
179
|
+
class PlainInteractiveIo extends CapturedIo {
|
|
180
|
+
interactive = true
|
|
181
|
+
}
|
|
182
|
+
|
|
108
183
|
class InteractiveIo extends CapturedIo {
|
|
109
184
|
interactive = true
|
|
110
185
|
selectAnswer: string | null = 'global'
|
|
@@ -232,7 +307,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
232
307
|
},
|
|
233
308
|
hookAdapterHome: path.join(testRoot, 'home'),
|
|
234
309
|
cwd: os.tmpdir(),
|
|
235
|
-
clientFactory: () => client,
|
|
310
|
+
clientFactory: () => withCompatibilityDefaults(client),
|
|
236
311
|
}
|
|
237
312
|
}
|
|
238
313
|
|
|
@@ -261,6 +336,7 @@ const receipt: SubmissionReceipt = {
|
|
|
261
336
|
request_id: 'req_reply_test',
|
|
262
337
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
263
338
|
agent_acknowledgement_required: true,
|
|
339
|
+
agent_acknowledgement_text_required: true,
|
|
264
340
|
replayed: false,
|
|
265
341
|
overall: 'provider_accepted_all',
|
|
266
342
|
deliveries: [
|
|
@@ -293,6 +369,7 @@ function replyResponse(
|
|
|
293
369
|
replies: ReplyView[] = [],
|
|
294
370
|
options: {
|
|
295
371
|
required?: boolean
|
|
372
|
+
textRequired?: boolean
|
|
296
373
|
acknowledgement?: ListRepliesResponse['agent_acknowledgement']
|
|
297
374
|
} = {},
|
|
298
375
|
): ListRepliesResponse {
|
|
@@ -300,6 +377,7 @@ function replyResponse(
|
|
|
300
377
|
request_id: receipt.request_id,
|
|
301
378
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
302
379
|
agent_acknowledgement_required: options.required ?? true,
|
|
380
|
+
agent_acknowledgement_text_required: options.textRequired ?? true,
|
|
303
381
|
agent_acknowledgement: options.acknowledgement ?? null,
|
|
304
382
|
replies,
|
|
305
383
|
}
|
|
@@ -378,7 +456,7 @@ describe('command contracts', () => {
|
|
|
378
456
|
} as unknown as ApiClient
|
|
379
457
|
|
|
380
458
|
expect(
|
|
381
|
-
await sendCommand(makeDeps(io, client), {
|
|
459
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
382
460
|
title: 'T',
|
|
383
461
|
body: 'B',
|
|
384
462
|
project: 'Invalid Project!',
|
|
@@ -456,7 +534,7 @@ describe('command contracts', () => {
|
|
|
456
534
|
} as unknown as ApiClient
|
|
457
535
|
|
|
458
536
|
expect(
|
|
459
|
-
await sendCommand(makeDeps(new CapturedIo(), client), {
|
|
537
|
+
await sendCommand(makeDeps(new CapturedIo(), client), { kind: 'update',
|
|
460
538
|
title: 'Visual comparison is ready',
|
|
461
539
|
body:
|
|
462
540
|
'  ',
|
|
@@ -488,7 +566,7 @@ describe('command contracts', () => {
|
|
|
488
566
|
} as unknown as ApiClient
|
|
489
567
|
|
|
490
568
|
expect(
|
|
491
|
-
await sendCommand(makeDeps(io, client), {
|
|
569
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
492
570
|
title: 'Visual comparison',
|
|
493
571
|
body: 'See it.',
|
|
494
572
|
image: ['one.png'],
|
|
@@ -523,19 +601,19 @@ describe('command contracts', () => {
|
|
|
523
601
|
|
|
524
602
|
it.each([
|
|
525
603
|
{
|
|
526
|
-
flags: { title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
|
|
604
|
+
flags: { kind: 'update', title: 'A title that is deliberately longer than forty characters', body: 'Plain.' },
|
|
527
605
|
warning: /titles work best around 40/i,
|
|
528
606
|
},
|
|
529
607
|
{
|
|
530
|
-
flags: { title: 'Done · build', body: 'All green.' },
|
|
608
|
+
flags: { kind: 'done', title: 'Done · build', body: 'All green.' },
|
|
531
609
|
warning: /keep the title to the specific substance/i,
|
|
532
610
|
},
|
|
533
611
|
{
|
|
534
|
-
flags: { title: 'Failed · build', body: 'One integration test failed.' },
|
|
612
|
+
flags: { kind: 'failed', title: 'Failed · build', body: 'One integration test failed.' },
|
|
535
613
|
warning: /Put notification type in --kind/i,
|
|
536
614
|
},
|
|
537
615
|
{
|
|
538
|
-
flags: { title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
616
|
+
flags: { kind: 'update', title: 'Update', body: 'Still relevant.', ttl: 259_201 },
|
|
539
617
|
warning: /longer than 72 hours/i,
|
|
540
618
|
},
|
|
541
619
|
])('warns without rejecting a send: $warning', async ({ flags, warning }) => {
|
|
@@ -559,7 +637,7 @@ describe('command contracts', () => {
|
|
|
559
637
|
cwd: root,
|
|
560
638
|
}
|
|
561
639
|
|
|
562
|
-
expect(await sendCommand(deps, { title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
|
|
640
|
+
expect(await sendCommand(deps, { kind: 'update', title: 'Update', body: 'Still relevant.' })).toBe(EXIT.ok)
|
|
563
641
|
expect(io.errLines.join('\n')).toMatch(/machine-global config/i)
|
|
564
642
|
})
|
|
565
643
|
|
|
@@ -579,11 +657,11 @@ describe('command contracts', () => {
|
|
|
579
657
|
expect(grammar).toContain("'--image <path|url|media_id>'")
|
|
580
658
|
expect(grammar).toContain("'--image-alt <text>'")
|
|
581
659
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
expect(source).toContain('
|
|
660
|
+
// Kind now selects the sound, so the help must say so — and must not carry
|
|
661
|
+
// the retired separation it replaced.
|
|
662
|
+
expect(source).toContain('Kind is required, and it selects the sound')
|
|
663
|
+
expect(source).not.toContain('it never chooses banner sound or interruption level')
|
|
585
664
|
expect(source).not.toContain('Kind profiles apply automatically')
|
|
586
|
-
expect(source).not.toContain('sound alert level active')
|
|
587
665
|
})
|
|
588
666
|
|
|
589
667
|
it('rejects a question nobody will wait for', async () => {
|
|
@@ -662,8 +740,8 @@ describe('command contracts', () => {
|
|
|
662
740
|
})
|
|
663
741
|
|
|
664
742
|
it.each([
|
|
665
|
-
{ title: 'Deploy? ', body: 'Ready.' },
|
|
666
|
-
{ title: 'Deployment', body: 'Should I deploy?\n' },
|
|
743
|
+
{ kind: 'update', title: 'Deploy? ', body: 'Ready.' },
|
|
744
|
+
{ kind: 'update', title: 'Deployment', body: 'Should I deploy?\n' },
|
|
667
745
|
])('warns on stderr when $title / $body ends in a question after trimming', async (flags) => {
|
|
668
746
|
const io = new CapturedIo()
|
|
669
747
|
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
@@ -703,7 +781,7 @@ describe('command contracts', () => {
|
|
|
703
781
|
} as unknown as ApiClient
|
|
704
782
|
|
|
705
783
|
expect(
|
|
706
|
-
await sendCommand(makeDeps(io, client), {
|
|
784
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
707
785
|
title: 'Deploy?',
|
|
708
786
|
body: 'Choose when ready.',
|
|
709
787
|
replyChoice: ['Now', 'Later'],
|
|
@@ -720,7 +798,7 @@ describe('command contracts', () => {
|
|
|
720
798
|
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
721
799
|
|
|
722
800
|
expect(
|
|
723
|
-
await sendCommand(makeDeps(io, client), {
|
|
801
|
+
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
724
802
|
title: 'Deployment',
|
|
725
803
|
body: 'Should I deploy?',
|
|
726
804
|
json: true,
|
|
@@ -942,6 +1020,7 @@ describe('command contracts', () => {
|
|
|
942
1020
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
943
1021
|
replies: [],
|
|
944
1022
|
agent_acknowledgement_required: true,
|
|
1023
|
+
agent_acknowledgement_text_required: true,
|
|
945
1024
|
agent_acknowledgement: null,
|
|
946
1025
|
acknowledgement_command: null,
|
|
947
1026
|
degraded: false,
|
|
@@ -975,6 +1054,7 @@ describe('command contracts', () => {
|
|
|
975
1054
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
976
1055
|
replies: [reply],
|
|
977
1056
|
agent_acknowledgement_required: true,
|
|
1057
|
+
agent_acknowledgement_text_required: true,
|
|
978
1058
|
agent_acknowledgement: null,
|
|
979
1059
|
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
980
1060
|
degraded: false,
|
|
@@ -1088,9 +1168,11 @@ describe('command contracts', () => {
|
|
|
1088
1168
|
})
|
|
1089
1169
|
|
|
1090
1170
|
it.each([
|
|
1091
|
-
{ text:
|
|
1092
|
-
{
|
|
1093
|
-
|
|
1171
|
+
{ text: ' ', message: 'must contain non-whitespace text' },
|
|
1172
|
+
{
|
|
1173
|
+
text: 'x'.repeat(AGENT_ACKNOWLEDGEMENT_MAX_LENGTH + 1),
|
|
1174
|
+
message: `at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters`,
|
|
1175
|
+
},
|
|
1094
1176
|
])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
|
|
1095
1177
|
const io = new CapturedIo()
|
|
1096
1178
|
let calls = 0
|
|
@@ -1108,6 +1190,60 @@ describe('command contracts', () => {
|
|
|
1108
1190
|
expect(io.errLines.join('\n')).toContain(message)
|
|
1109
1191
|
})
|
|
1110
1192
|
|
|
1193
|
+
it('records an acknowledgement with no text at all', async () => {
|
|
1194
|
+
const io = new CapturedIo()
|
|
1195
|
+
let submitted: { requestId: string; body: { text?: string } } | undefined
|
|
1196
|
+
const client = {
|
|
1197
|
+
putAgentAcknowledgement: async (requestId: string, body: { text?: string }) => {
|
|
1198
|
+
submitted = { requestId, body }
|
|
1199
|
+
return {
|
|
1200
|
+
status: 'recorded' as const,
|
|
1201
|
+
agent_acknowledgement: { text: '', created_at: '2026-08-13T12:01:00.000Z' },
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
} as unknown as ApiClient
|
|
1205
|
+
|
|
1206
|
+
expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
|
|
1207
|
+
// No empty string either: an omitted field is what "no text" means on the
|
|
1208
|
+
// wire, and the account's snapshot decides whether that was allowed.
|
|
1209
|
+
expect(submitted).toEqual({ requestId: receipt.request_id, body: {} })
|
|
1210
|
+
expect(io.outLines.join('\n')).toContain(`Agent Acknowledgement recorded for ${receipt.request_id}`)
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
it('names the missing text, not a contract mismatch, when the account requires it', async () => {
|
|
1214
|
+
const io = new CapturedIo()
|
|
1215
|
+
const client = {
|
|
1216
|
+
putAgentAcknowledgement: async () => {
|
|
1217
|
+
throw new ApiCallError(
|
|
1218
|
+
422,
|
|
1219
|
+
'acknowledgement_text_required',
|
|
1220
|
+
'This account requires Agent Acknowledgement text.',
|
|
1221
|
+
'Re-send the acknowledgement with text saying what you will do because of the reply.',
|
|
1222
|
+
)
|
|
1223
|
+
},
|
|
1224
|
+
} as unknown as ApiClient
|
|
1225
|
+
|
|
1226
|
+
expect(await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.failed)
|
|
1227
|
+
const err = io.errLines.join('\n')
|
|
1228
|
+
expect(err).toContain('acknowledgement_text_required')
|
|
1229
|
+
expect(err).toContain('next: Re-send the acknowledgement with text')
|
|
1230
|
+
// The contract held; only this body fell short of the account's choice.
|
|
1231
|
+
expect(err).not.toContain('disagree about the contract')
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it('asks for a text-free acknowledgement when the account turned text off', async () => {
|
|
1235
|
+
const io = new CapturedIo()
|
|
1236
|
+
const client = {
|
|
1237
|
+
replies: async () => replyResponse([reply], { textRequired: false }),
|
|
1238
|
+
} as unknown as ApiClient
|
|
1239
|
+
|
|
1240
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
|
|
1241
|
+
const out = io.outLines.join('\n')
|
|
1242
|
+
expect(out).toContain('Agent Acknowledgement required.')
|
|
1243
|
+
expect(out).toContain(`notifai acknowledge ${receipt.request_id}\``)
|
|
1244
|
+
expect(out).not.toContain('--text')
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1111
1247
|
it.each([
|
|
1112
1248
|
[new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
|
|
1113
1249
|
[new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
|
|
@@ -1609,7 +1745,7 @@ describe('Cursor hook commands', () => {
|
|
|
1609
1745
|
|
|
1610
1746
|
it('reports a native Cursor installation through doctor', async () => {
|
|
1611
1747
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
|
|
1612
|
-
const io = new
|
|
1748
|
+
const io = new PlainInteractiveIo()
|
|
1613
1749
|
const client = {
|
|
1614
1750
|
health: async () => true,
|
|
1615
1751
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
@@ -1716,7 +1852,13 @@ describe('Codex hook representation', () => {
|
|
|
1716
1852
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
1717
1853
|
})
|
|
1718
1854
|
|
|
1719
|
-
|
|
1855
|
+
/**
|
|
1856
|
+
* The foreign `hooks.json` stays byte-identical and Notifai lands inline.
|
|
1857
|
+
* Codex will now warn that the layer uses both representations; the install
|
|
1858
|
+
* output says why, because that warning is Codex reporting a merge, not a
|
|
1859
|
+
* fault, and a silent one would read as Notifai's bug.
|
|
1860
|
+
*/
|
|
1861
|
+
it('leaves a foreign hooks.json alone and installs inline instead', () => {
|
|
1720
1862
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
|
|
1721
1863
|
const layer = path.join(cwd, '.codex')
|
|
1722
1864
|
const json = path.join(layer, 'hooks.json')
|
|
@@ -1734,11 +1876,17 @@ describe('Codex hook representation', () => {
|
|
|
1734
1876
|
|
|
1735
1877
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1736
1878
|
|
|
1737
|
-
|
|
1738
|
-
expect(
|
|
1739
|
-
expect(
|
|
1740
|
-
|
|
1741
|
-
|
|
1879
|
+
const beforeJson = readFileSync(json, 'utf8')
|
|
1880
|
+
expect(beforeJson).toContain('gdh-stop')
|
|
1881
|
+
expect(beforeJson).not.toContain('--owner notifai')
|
|
1882
|
+
const after = readFileSync(toml, 'utf8')
|
|
1883
|
+
// The user's file arrives back as the file they wrote, plus our hooks.
|
|
1884
|
+
expect(after).toContain(beforeToml.trimEnd())
|
|
1885
|
+
expect(after).toContain('--owner notifai')
|
|
1886
|
+
expect(io.outLines.join('\n')).toContain(toml)
|
|
1887
|
+
expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
|
|
1888
|
+
expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
|
|
1889
|
+
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
1742
1890
|
})
|
|
1743
1891
|
|
|
1744
1892
|
it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
|
|
@@ -1755,12 +1903,18 @@ describe('Codex hook representation', () => {
|
|
|
1755
1903
|
expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
|
|
1756
1904
|
})
|
|
1757
1905
|
|
|
1758
|
-
|
|
1759
|
-
|
|
1906
|
+
/**
|
|
1907
|
+
* Notifai in one file, someone else's hooks in the other: every handler
|
|
1908
|
+
* fires once, so doctor has nothing to report and the foreign file is not
|
|
1909
|
+
* ours to rewrite. Doctor used to fail here — the check read "two files" as
|
|
1910
|
+
* "two Notifai copies" and pointed at configuration another program owns.
|
|
1911
|
+
*/
|
|
1912
|
+
it('passes doctor when only the other representation is foreign', async () => {
|
|
1913
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-foreign-rep-'))
|
|
1760
1914
|
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1761
1915
|
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1762
1916
|
const env = isolatedEnv(cwd)
|
|
1763
|
-
const io = new
|
|
1917
|
+
const io = new PlainInteractiveIo()
|
|
1764
1918
|
const client = {
|
|
1765
1919
|
health: async () => false,
|
|
1766
1920
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
@@ -1776,12 +1930,12 @@ describe('Codex hook representation', () => {
|
|
|
1776
1930
|
})
|
|
1777
1931
|
|
|
1778
1932
|
io.outLines = []
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
expect(
|
|
1783
|
-
expect(
|
|
1784
|
-
expect(
|
|
1933
|
+
await doctorCommand(deps, {})
|
|
1934
|
+
// Their assertions, this branch's check title.
|
|
1935
|
+
const report = io.outLines.join('\n')
|
|
1936
|
+
expect(report).not.toMatch(/FAIL {2}Codex hook representation/)
|
|
1937
|
+
expect(report).toMatch(/ok {4}Codex hook representation/)
|
|
1938
|
+
expect(report).toMatch(/Notifai will not modify it/)
|
|
1785
1939
|
|
|
1786
1940
|
io.outLines = []
|
|
1787
1941
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
@@ -1789,7 +1943,7 @@ describe('Codex hook representation', () => {
|
|
|
1789
1943
|
expect(readFileSync(json, 'utf8')).toContain('--owner notifai')
|
|
1790
1944
|
expect(readFileSync(toml, 'utf8')).toContain('gdh-stop')
|
|
1791
1945
|
expect(readFileSync(toml, 'utf8')).not.toContain('--owner notifai')
|
|
1792
|
-
expect(io.outLines.join('\n')).toMatch(/
|
|
1946
|
+
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
1793
1947
|
expect(io.outLines.join('\n')).not.toMatch(/collaps/i)
|
|
1794
1948
|
|
|
1795
1949
|
const installations = findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
@@ -1798,6 +1952,44 @@ describe('Codex hook representation', () => {
|
|
|
1798
1952
|
expect(installations).toHaveLength(1)
|
|
1799
1953
|
expect(installations[0]?.file).toBe(json)
|
|
1800
1954
|
})
|
|
1955
|
+
|
|
1956
|
+
it('fails doctor when Notifai itself is installed in both representations', async () => {
|
|
1957
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-dual-'))
|
|
1958
|
+
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1959
|
+
const env = isolatedEnv(cwd)
|
|
1960
|
+
const io = new CapturedIo()
|
|
1961
|
+
const client = {
|
|
1962
|
+
health: async () => false,
|
|
1963
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1964
|
+
listDevices: async () => ({ devices: [] }),
|
|
1965
|
+
} as unknown as ApiClient
|
|
1966
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
1967
|
+
|
|
1968
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1969
|
+
applyPlan(json, {
|
|
1970
|
+
hooks: buildHookConfig({
|
|
1971
|
+
adapterPath: hookAdapterPath(deps.hookAdapterHome),
|
|
1972
|
+
harness: 'codex',
|
|
1973
|
+
}),
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
io.outLines = []
|
|
1977
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1978
|
+
const reported = io.outLines.join('\n')
|
|
1979
|
+
expect(reported).toMatch(/Codex hook representation/)
|
|
1980
|
+
expect(reported).toMatch(/installed in both/i)
|
|
1981
|
+
expect(reported).toMatch(/notifies twice per turn/)
|
|
1982
|
+
expect(reported).toMatch(/notifai hooks uninstall --harness codex/)
|
|
1983
|
+
|
|
1984
|
+
// The named remedy has to actually clear it, including the copy in the
|
|
1985
|
+
// file Notifai would not have chosen to write.
|
|
1986
|
+
io.outLines = []
|
|
1987
|
+
expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
|
|
1988
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
1989
|
+
io.outLines = []
|
|
1990
|
+
await doctorCommand(deps, {})
|
|
1991
|
+
expect(io.outLines.join('\n')).not.toMatch(/Codex hook representation/)
|
|
1992
|
+
})
|
|
1801
1993
|
})
|
|
1802
1994
|
|
|
1803
1995
|
describe('harness activation guidance', () => {
|
|
@@ -1913,14 +2105,14 @@ describe('harness activation guidance', () => {
|
|
|
1913
2105
|
|
|
1914
2106
|
io.outLines = []
|
|
1915
2107
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1916
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2108
|
+
expect(io.outLines.join('\n')).toContain('How an answer returns')
|
|
1917
2109
|
expect(io.outLines.join('\n')).toContain('no proven answer continuation')
|
|
1918
|
-
expect(io.outLines.join('\n')).not.toContain('
|
|
2110
|
+
expect(io.outLines.join('\n')).not.toContain('Hook adapter')
|
|
1919
2111
|
|
|
1920
2112
|
writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
|
|
1921
2113
|
io.outLines = []
|
|
1922
2114
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
1923
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2115
|
+
expect(io.outLines.join('\n')).toContain('Hook adapter')
|
|
1924
2116
|
expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
|
|
1925
2117
|
})
|
|
1926
2118
|
|
|
@@ -2019,7 +2211,7 @@ describe('stable hook installation', () => {
|
|
|
2019
2211
|
|
|
2020
2212
|
io.outLines = []
|
|
2021
2213
|
await doctorCommand(deps, {})
|
|
2022
|
-
expect(io.outLines.join('\n')).not.toMatch(/
|
|
2214
|
+
expect(io.outLines.join('\n')).not.toMatch(/Duplicate hook installs/)
|
|
2023
2215
|
|
|
2024
2216
|
expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
|
|
2025
2217
|
expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
|
|
@@ -2315,6 +2507,39 @@ describe('config surfaces', () => {
|
|
|
2315
2507
|
expect(resolved.sound.source).toMatch(/^global:/)
|
|
2316
2508
|
})
|
|
2317
2509
|
|
|
2510
|
+
it('round-trips unknown root and table keys through config set and unset', async () => {
|
|
2511
|
+
const io = new CapturedIo()
|
|
2512
|
+
const deps = configDeps(io)
|
|
2513
|
+
const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
2514
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
2515
|
+
writeFileSync(
|
|
2516
|
+
configFile,
|
|
2517
|
+
[
|
|
2518
|
+
'future_root = "keep-root"',
|
|
2519
|
+
'wait_seconds = 20',
|
|
2520
|
+
'',
|
|
2521
|
+
'[future_table]',
|
|
2522
|
+
'future_key = "keep-table"',
|
|
2523
|
+
'',
|
|
2524
|
+
].join('\n'),
|
|
2525
|
+
)
|
|
2526
|
+
|
|
2527
|
+
expect(await configSetCommand(deps, 'sound', 'done', { yes: true })).toBe(EXIT.ok)
|
|
2528
|
+
expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
|
|
2529
|
+
future_root: 'keep-root',
|
|
2530
|
+
wait_seconds: 20,
|
|
2531
|
+
sound: 'done',
|
|
2532
|
+
future_table: { future_key: 'keep-table' },
|
|
2533
|
+
})
|
|
2534
|
+
|
|
2535
|
+
expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
|
|
2536
|
+
expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
|
|
2537
|
+
future_root: 'keep-root',
|
|
2538
|
+
sound: 'done',
|
|
2539
|
+
future_table: { future_key: 'keep-table' },
|
|
2540
|
+
})
|
|
2541
|
+
})
|
|
2542
|
+
|
|
2318
2543
|
it('refuses to create a redundant machine override equal to the shipped default', async () => {
|
|
2319
2544
|
const io = new CapturedIo()
|
|
2320
2545
|
const deps = configDeps(io)
|
|
@@ -2519,7 +2744,7 @@ describe('interactive command UX', () => {
|
|
|
2519
2744
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
2520
2745
|
expect(io.intros).toEqual(['Notifai doctor'])
|
|
2521
2746
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
2522
|
-
expect(io.checks.some((check) => check.
|
|
2747
|
+
expect(io.checks.some((check) => check.message.startsWith('Notifai update:'))).toBe(false)
|
|
2523
2748
|
expect(io.outLines).toEqual([])
|
|
2524
2749
|
|
|
2525
2750
|
// Four tones, not two. A boolean has to round `optional-gap` and `unknown`
|
|
@@ -2528,7 +2753,6 @@ describe('interactive command UX', () => {
|
|
|
2528
2753
|
const tone = (prefix: string): Tone | undefined =>
|
|
2529
2754
|
io.checks.find((check) => check.message.startsWith(prefix))?.tone
|
|
2530
2755
|
expect(tone('This machine:')).toBe('bad')
|
|
2531
|
-
expect(tone('Protocol version:')).toBe('ok')
|
|
2532
2756
|
// Never evaluated: the account cannot be checked without a credential.
|
|
2533
2757
|
expect(tone('Account:')).toBe('pending')
|
|
2534
2758
|
// Legitimately declined rather than broken.
|
|
@@ -2562,6 +2786,210 @@ describe('interactive command UX', () => {
|
|
|
2562
2786
|
})
|
|
2563
2787
|
})
|
|
2564
2788
|
|
|
2789
|
+
describe('compatibility-first update guidance', () => {
|
|
2790
|
+
const cases: Array<{
|
|
2791
|
+
name: string
|
|
2792
|
+
support: Partial<SupportAssessment>
|
|
2793
|
+
status: 'ready' | 'optional-gap' | 'gap'
|
|
2794
|
+
detail: string
|
|
2795
|
+
exit: number
|
|
2796
|
+
humanLines: string[]
|
|
2797
|
+
}> = [
|
|
2798
|
+
{
|
|
2799
|
+
name: 'current',
|
|
2800
|
+
support: {},
|
|
2801
|
+
status: 'ready',
|
|
2802
|
+
detail: 'Notifai can send notifications.',
|
|
2803
|
+
exit: EXIT.ok,
|
|
2804
|
+
humanLines: [],
|
|
2805
|
+
},
|
|
2806
|
+
{
|
|
2807
|
+
name: 'optional newer release',
|
|
2808
|
+
support: {
|
|
2809
|
+
state: 'update_available',
|
|
2810
|
+
reason: 'newer_release',
|
|
2811
|
+
recovery_action: 'update_cli',
|
|
2812
|
+
recommended_version: '6.0.0',
|
|
2813
|
+
},
|
|
2814
|
+
status: 'optional-gap',
|
|
2815
|
+
detail: 'A newer Notifai is available.',
|
|
2816
|
+
exit: EXIT.ok,
|
|
2817
|
+
humanLines: [
|
|
2818
|
+
'A newer Notifai is available.',
|
|
2819
|
+
'npm install -g @raidiant/notifai',
|
|
2820
|
+
],
|
|
2821
|
+
},
|
|
2822
|
+
{
|
|
2823
|
+
name: 'scheduled Sunset',
|
|
2824
|
+
support: {
|
|
2825
|
+
state: 'update_available',
|
|
2826
|
+
reason: 'sunset_scheduled',
|
|
2827
|
+
recovery_action: 'update_cli',
|
|
2828
|
+
recommended_version: '6.0.0',
|
|
2829
|
+
deprecation: 'Tue, 18 Aug 2026 00:00:00 GMT',
|
|
2830
|
+
sunset: 'Tue, 15 Sep 2026 00:00:00 GMT',
|
|
2831
|
+
},
|
|
2832
|
+
status: 'optional-gap',
|
|
2833
|
+
detail: 'Update Notifai soon to keep sending notifications.',
|
|
2834
|
+
exit: EXIT.ok,
|
|
2835
|
+
humanLines: [
|
|
2836
|
+
'Update Notifai soon to keep sending notifications.',
|
|
2837
|
+
'npm install -g @raidiant/notifai',
|
|
2838
|
+
],
|
|
2839
|
+
},
|
|
2840
|
+
{
|
|
2841
|
+
name: 'required update',
|
|
2842
|
+
support: {
|
|
2843
|
+
state: 'must_update',
|
|
2844
|
+
reason: 'minimum_not_met',
|
|
2845
|
+
affected_operation: 'send_notifications',
|
|
2846
|
+
recovery_action: 'update_cli',
|
|
2847
|
+
recommended_version: '6.0.0',
|
|
2848
|
+
minimum_version: '6.0.0',
|
|
2849
|
+
},
|
|
2850
|
+
status: 'gap',
|
|
2851
|
+
detail: "Notifai can't send notifications until you update.",
|
|
2852
|
+
exit: EXIT.failed,
|
|
2853
|
+
humanLines: [
|
|
2854
|
+
"Notifai can't send notifications until you update.",
|
|
2855
|
+
'npm install -g @raidiant/notifai',
|
|
2856
|
+
],
|
|
2857
|
+
},
|
|
2858
|
+
]
|
|
2859
|
+
|
|
2860
|
+
it.each(cases)(
|
|
2861
|
+
'maps $name policy to closed human copy and structured JSON',
|
|
2862
|
+
async ({ support, status, detail, exit, humanLines }) => {
|
|
2863
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
|
|
2864
|
+
const client = {
|
|
2865
|
+
health: async () => true,
|
|
2866
|
+
compatibility: async () => compatibilityWithCli(support),
|
|
2867
|
+
listDevices: async () => ({ devices: [] }),
|
|
2868
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
2869
|
+
} as unknown as ApiClient
|
|
2870
|
+
const humanIo = new PlainInteractiveIo()
|
|
2871
|
+
const deps = {
|
|
2872
|
+
...makeDeps(humanIo, client),
|
|
2873
|
+
cwd,
|
|
2874
|
+
env: isolatedEnv(cwd),
|
|
2875
|
+
}
|
|
2876
|
+
const readiness = await assessReadiness(deps)
|
|
2877
|
+
const contract = readiness.states.find((state) => state.id === 'contract')
|
|
2878
|
+
expect(contract).toMatchObject({ status, detail })
|
|
2879
|
+
|
|
2880
|
+
expect(
|
|
2881
|
+
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
2882
|
+
).toBe(exit)
|
|
2883
|
+
expect(humanIo.outLines).toEqual(humanLines)
|
|
2884
|
+
expect(humanIo.errLines).toEqual([])
|
|
2885
|
+
|
|
2886
|
+
const jsonIo = new CapturedIo()
|
|
2887
|
+
expect(
|
|
2888
|
+
await doctorCommand(
|
|
2889
|
+
{ ...deps, io: jsonIo },
|
|
2890
|
+
{ json: true },
|
|
2891
|
+
{ readiness: { states: [contract!] } },
|
|
2892
|
+
),
|
|
2893
|
+
).toBe(exit)
|
|
2894
|
+
expect(jsonIo.outLines).toHaveLength(1)
|
|
2895
|
+
const payload = JSON.parse(jsonIo.outLines[0] ?? '{}') as {
|
|
2896
|
+
ok: boolean
|
|
2897
|
+
exit_code: number
|
|
2898
|
+
states: Array<{
|
|
2899
|
+
status: string
|
|
2900
|
+
detail: string
|
|
2901
|
+
remedy?: { command?: string }
|
|
2902
|
+
}>
|
|
2903
|
+
}
|
|
2904
|
+
expect(payload).toMatchObject({
|
|
2905
|
+
ok: exit === EXIT.ok,
|
|
2906
|
+
exit_code: exit,
|
|
2907
|
+
states: [{ status, detail }],
|
|
2908
|
+
})
|
|
2909
|
+
expect(payload.states[0]?.remedy?.command ?? null).toBe(
|
|
2910
|
+
status === 'ready' ? null : 'npm install -g @raidiant/notifai',
|
|
2911
|
+
)
|
|
2912
|
+
expect(jsonIo.errLines).toEqual([])
|
|
2913
|
+
|
|
2914
|
+
const nonTtyIo = new CapturedIo()
|
|
2915
|
+
expect(
|
|
2916
|
+
await doctorCommand(
|
|
2917
|
+
{ ...deps, io: nonTtyIo },
|
|
2918
|
+
{},
|
|
2919
|
+
{ readiness: { states: [contract!] } },
|
|
2920
|
+
),
|
|
2921
|
+
).toBe(exit)
|
|
2922
|
+
expect(nonTtyIo.outLines).toHaveLength(1)
|
|
2923
|
+
expect(JSON.parse(nonTtyIo.outLines[0] ?? '{}')).toEqual(payload)
|
|
2924
|
+
expect(nonTtyIo.errLines).toEqual([])
|
|
2925
|
+
},
|
|
2926
|
+
)
|
|
2927
|
+
|
|
2928
|
+
it('reports a missing named CLI capability without blocking ordinary sends', async () => {
|
|
2929
|
+
const requestedPlatforms: Platform[] = []
|
|
2930
|
+
let submissions = 0
|
|
2931
|
+
const client = {
|
|
2932
|
+
health: async () => true,
|
|
2933
|
+
compatibility: async (): Promise<CompatibilityResponse> => ({
|
|
2934
|
+
...currentCompatibility,
|
|
2935
|
+
// This service can still accept baseline Notification Requests, but it
|
|
2936
|
+
// cannot honor this CLI's Agent Acknowledgement feature yet.
|
|
2937
|
+
server_capabilities: ['answer'],
|
|
2938
|
+
}),
|
|
2939
|
+
capabilities: async (platform: Platform = 'ios') => {
|
|
2940
|
+
requestedPlatforms.push(platform)
|
|
2941
|
+
const document = CAPABILITIES_V1.describe(platform)
|
|
2942
|
+
if (document === null) throw new Error(`missing ${platform} capability document`)
|
|
2943
|
+
return document
|
|
2944
|
+
},
|
|
2945
|
+
listDevices: async () => ({ devices: [] }),
|
|
2946
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
2947
|
+
submit: async () => {
|
|
2948
|
+
submissions += 1
|
|
2949
|
+
return receipt
|
|
2950
|
+
},
|
|
2951
|
+
} as unknown as ApiClient
|
|
2952
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-capability-guidance-'))
|
|
2953
|
+
const io = new PlainInteractiveIo()
|
|
2954
|
+
const deps = {
|
|
2955
|
+
...makeDeps(io, client),
|
|
2956
|
+
cwd,
|
|
2957
|
+
env: isolatedEnv(cwd),
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
const readiness = await assessReadiness(deps)
|
|
2961
|
+
const contract = readiness.states.find((state) => state.id === 'contract')
|
|
2962
|
+
expect(requestedPlatforms).toEqual([...PLATFORMS])
|
|
2963
|
+
expect(contract).toMatchObject({
|
|
2964
|
+
status: 'optional-gap',
|
|
2965
|
+
detail: 'The service is being updated; try again later.',
|
|
2966
|
+
technical: {
|
|
2967
|
+
capability_documents: PLATFORMS.map((platform) => ({ platform, schema_version: 1 })),
|
|
2968
|
+
cli_capability_intersection: {
|
|
2969
|
+
available: [],
|
|
2970
|
+
missing_on_server: ['agent_acknowledgement'],
|
|
2971
|
+
},
|
|
2972
|
+
},
|
|
2973
|
+
})
|
|
2974
|
+
|
|
2975
|
+
expect(
|
|
2976
|
+
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
2977
|
+
).toBe(EXIT.ok)
|
|
2978
|
+
expect(io.outLines).toEqual(['The service is being updated; try again later.'])
|
|
2979
|
+
expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
|
|
2980
|
+
|
|
2981
|
+
const sendIo = new CapturedIo()
|
|
2982
|
+
expect(
|
|
2983
|
+
await sendCommand(
|
|
2984
|
+
{ ...deps, io: sendIo },
|
|
2985
|
+
{ kind: 'update', title: 'Build finished', body: 'All checks passed.' },
|
|
2986
|
+
),
|
|
2987
|
+
).toBe(EXIT.ok)
|
|
2988
|
+
expect(submissions).toBe(1)
|
|
2989
|
+
expect(sendIo.errLines).toEqual([])
|
|
2990
|
+
})
|
|
2991
|
+
})
|
|
2992
|
+
|
|
2565
2993
|
describe('init', () => {
|
|
2566
2994
|
const readyIphone = {
|
|
2567
2995
|
device_id: 'dev_iphone',
|
|
@@ -2569,9 +2997,22 @@ describe('init', () => {
|
|
|
2569
2997
|
platform: 'ios' as const,
|
|
2570
2998
|
permission_status: 'authorized',
|
|
2571
2999
|
registration_healthy: true,
|
|
2572
|
-
|
|
3000
|
+
app_version: '0.1.0',
|
|
3001
|
+
app_build: '42',
|
|
3002
|
+
os_version: '19.0',
|
|
3003
|
+
capabilities: ['answer'] as const,
|
|
3004
|
+
support: currentSupport,
|
|
3005
|
+
support_state: 'current' as const,
|
|
3006
|
+
derived_status: 'working' as const,
|
|
3007
|
+
status_message: null,
|
|
2573
3008
|
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2574
3009
|
}
|
|
3010
|
+
const readyMac = {
|
|
3011
|
+
...readyIphone,
|
|
3012
|
+
device_id: 'dev_mac',
|
|
3013
|
+
display_name: 'Mac',
|
|
3014
|
+
platform: 'macos' as const,
|
|
3015
|
+
}
|
|
2575
3016
|
|
|
2576
3017
|
function setupEvidence(
|
|
2577
3018
|
requestId: string,
|
|
@@ -2697,6 +3138,58 @@ describe('init', () => {
|
|
|
2697
3138
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
2698
3139
|
})
|
|
2699
3140
|
|
|
3141
|
+
it('resumes the same init after a required CLI update becomes current', async () => {
|
|
3142
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-resume-'))
|
|
3143
|
+
const io = new CapturedIo()
|
|
3144
|
+
let cliSupport = compatibilityWithCli({
|
|
3145
|
+
state: 'must_update',
|
|
3146
|
+
reason: 'minimum_not_met',
|
|
3147
|
+
affected_operation: 'send_notifications',
|
|
3148
|
+
recovery_action: 'update_cli',
|
|
3149
|
+
recommended_version: '6.0.0',
|
|
3150
|
+
minimum_version: '6.0.0',
|
|
3151
|
+
})
|
|
3152
|
+
let submissions = 0
|
|
3153
|
+
const client = {
|
|
3154
|
+
health: async () => true,
|
|
3155
|
+
compatibility: async () => cliSupport,
|
|
3156
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
3157
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
3158
|
+
submit: async () => {
|
|
3159
|
+
submissions += 1
|
|
3160
|
+
return setupReceipt('req_after_update')
|
|
3161
|
+
},
|
|
3162
|
+
evidence: async () =>
|
|
3163
|
+
setupEvidence('req_after_update', {
|
|
3164
|
+
state: 'observed',
|
|
3165
|
+
observed_at: '2026-08-18T18:00:02.000Z',
|
|
3166
|
+
latency_ms: 1_000,
|
|
3167
|
+
}),
|
|
3168
|
+
} as unknown as ApiClient
|
|
3169
|
+
const deps = {
|
|
3170
|
+
...makeDeps(io, client),
|
|
3171
|
+
cwd,
|
|
3172
|
+
env: isolatedEnv(cwd),
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3176
|
+
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
3177
|
+
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
3178
|
+
expect(submissions).toBe(0)
|
|
3179
|
+
|
|
3180
|
+
cliSupport = currentCompatibility
|
|
3181
|
+
io.outLines = []
|
|
3182
|
+
io.errLines = []
|
|
3183
|
+
|
|
3184
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
3185
|
+
expect(submissions).toBe(1)
|
|
3186
|
+
expect(io.outLines).toContain(
|
|
3187
|
+
'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
|
|
3188
|
+
)
|
|
3189
|
+
expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
|
|
3190
|
+
expect(io.errLines).toEqual([])
|
|
3191
|
+
})
|
|
3192
|
+
|
|
2700
3193
|
it('surfaces one next step, not the whole remaining list', async () => {
|
|
2701
3194
|
// The behavioural core of the design: someone handed five things to do
|
|
2702
3195
|
// does none of them. Signing in gates the device check, so naming both
|
|
@@ -3078,7 +3571,7 @@ describe('init', () => {
|
|
|
3078
3571
|
expect(out).toContain('Next: Your devices')
|
|
3079
3572
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
3080
3573
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
3081
|
-
expect(out).toContain('install Notifai on iPhone
|
|
3574
|
+
expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
|
|
3082
3575
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3083
3576
|
})
|
|
3084
3577
|
|
|
@@ -3127,7 +3620,7 @@ describe('init', () => {
|
|
|
3127
3620
|
})
|
|
3128
3621
|
|
|
3129
3622
|
it.each([
|
|
3130
|
-
['denied', '
|
|
3623
|
+
['denied', 'iPhone Settings'],
|
|
3131
3624
|
['not_determined', 'allow its notification prompt'],
|
|
3132
3625
|
])('gives one permission-specific next action for %s', async (permission, expected) => {
|
|
3133
3626
|
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
|
|
@@ -3152,7 +3645,7 @@ describe('init', () => {
|
|
|
3152
3645
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3153
3646
|
})
|
|
3154
3647
|
|
|
3155
|
-
it('
|
|
3648
|
+
it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
|
|
3156
3649
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
|
|
3157
3650
|
const io = new InteractiveIo()
|
|
3158
3651
|
let now = 0
|
|
@@ -3162,7 +3655,7 @@ describe('init', () => {
|
|
|
3162
3655
|
const client = {
|
|
3163
3656
|
health: async () => true,
|
|
3164
3657
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3165
|
-
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
|
|
3658
|
+
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [readyMac] }),
|
|
3166
3659
|
accessStatus: async () => ({
|
|
3167
3660
|
status: 'active',
|
|
3168
3661
|
reason: 'alpha_grant',
|
|
@@ -3199,6 +3692,7 @@ describe('init', () => {
|
|
|
3199
3692
|
])
|
|
3200
3693
|
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
|
|
3201
3694
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
3695
|
+
expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
|
|
3202
3696
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
3203
3697
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
3204
3698
|
expect(io.outLines.join('\n')).toContain(
|
|
@@ -3389,7 +3883,7 @@ describe('init', () => {
|
|
|
3389
3883
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
3390
3884
|
})
|
|
3391
3885
|
|
|
3392
|
-
it('
|
|
3886
|
+
it('does not treat a macOS-only installation as active-release readiness', async () => {
|
|
3393
3887
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
3394
3888
|
const io = new CapturedIo()
|
|
3395
3889
|
let submitCalls = 0
|
|
@@ -3397,7 +3891,7 @@ describe('init', () => {
|
|
|
3397
3891
|
health: async () => true,
|
|
3398
3892
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3399
3893
|
listDevices: async () => ({
|
|
3400
|
-
devices: [
|
|
3894
|
+
devices: [readyMac],
|
|
3401
3895
|
}),
|
|
3402
3896
|
submit: async () => {
|
|
3403
3897
|
submitCalls += 1
|
|
@@ -3410,16 +3904,16 @@ describe('init', () => {
|
|
|
3410
3904
|
env: isolatedEnv(cwd),
|
|
3411
3905
|
}
|
|
3412
3906
|
|
|
3413
|
-
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.
|
|
3907
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3414
3908
|
expect(submitCalls).toBe(0)
|
|
3415
3909
|
const out = io.outLines.join('\n')
|
|
3416
|
-
expect(out).toContain('
|
|
3417
|
-
expect(out).toContain('
|
|
3418
|
-
expect(out).not.toContain('
|
|
3910
|
+
expect(out).toContain('Next: Your devices')
|
|
3911
|
+
expect(out).toContain('no iPhone registered yet')
|
|
3912
|
+
expect(out).not.toContain('All set.')
|
|
3419
3913
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
3420
3914
|
|
|
3421
|
-
// doctor must render the same
|
|
3422
|
-
const doctorIo = new
|
|
3915
|
+
// A human doctor must render the same iPhone-readiness failure.
|
|
3916
|
+
const doctorIo = new PlainInteractiveIo()
|
|
3423
3917
|
expect(
|
|
3424
3918
|
await doctorCommand(
|
|
3425
3919
|
{
|
|
@@ -3429,11 +3923,11 @@ describe('init', () => {
|
|
|
3429
3923
|
},
|
|
3430
3924
|
{},
|
|
3431
3925
|
),
|
|
3432
|
-
).toBe(EXIT.
|
|
3926
|
+
).toBe(EXIT.failed)
|
|
3433
3927
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
3434
|
-
expect(doctorOut).toMatch(
|
|
3435
|
-
expect(doctorOut).toContain('
|
|
3436
|
-
expect(doctorOut).
|
|
3928
|
+
expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
|
|
3929
|
+
expect(doctorOut).toContain('no iPhone registered yet')
|
|
3930
|
+
expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
|
|
3437
3931
|
})
|
|
3438
3932
|
|
|
3439
3933
|
it('stops login when the approval page reports no Alpha access', async () => {
|
|
@@ -4109,7 +4603,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4109
4603
|
|
|
4110
4604
|
it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
|
|
4111
4605
|
const cwd = scratchDir('notifai-codex-stale-trust-')
|
|
4112
|
-
const io = new
|
|
4606
|
+
const io = new PlainInteractiveIo()
|
|
4113
4607
|
const env = {
|
|
4114
4608
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
4115
4609
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
@@ -4138,7 +4632,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4138
4632
|
|
|
4139
4633
|
io.outLines = []
|
|
4140
4634
|
await doctorCommand(deps, {})
|
|
4141
|
-
expect(io.outLines.join('\n')).toMatch(/FAIL\s+
|
|
4635
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
|
|
4142
4636
|
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
4143
4637
|
})
|
|
4144
4638
|
|
|
@@ -4374,7 +4868,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4374
4868
|
|
|
4375
4869
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
4376
4870
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
|
|
4377
|
-
const io = new
|
|
4871
|
+
const io = new PlainInteractiveIo()
|
|
4378
4872
|
const env = {
|
|
4379
4873
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
4380
4874
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
@@ -4391,7 +4885,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4391
4885
|
io.outLines = []
|
|
4392
4886
|
|
|
4393
4887
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
4394
|
-
expect(io.outLines.join('\n')).toMatch(/FAIL\s+
|
|
4888
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Routing for this harness.*active Claude Code/is)
|
|
4395
4889
|
})
|
|
4396
4890
|
|
|
4397
4891
|
it('treats an unfired pointer as informational, with a prompt as the remedy', async () => {
|
|
@@ -4434,7 +4928,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4434
4928
|
const localFile = personalProjectConfigPath(cwd, env)
|
|
4435
4929
|
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
4436
4930
|
writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
|
|
4437
|
-
const io = new
|
|
4931
|
+
const io = new PlainInteractiveIo()
|
|
4438
4932
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
4439
4933
|
|
|
4440
4934
|
await doctorCommand(deps, {})
|
|
@@ -4450,7 +4944,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4450
4944
|
|
|
4451
4945
|
it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
|
|
4452
4946
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
|
|
4453
|
-
const io = new
|
|
4947
|
+
const io = new PlainInteractiveIo()
|
|
4454
4948
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
4455
4949
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
4456
4950
|
EXIT.ok,
|
|
@@ -4460,12 +4954,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4460
4954
|
io.outLines = []
|
|
4461
4955
|
|
|
4462
4956
|
await doctorCommand(deps, {})
|
|
4463
|
-
expect(io.outLines.join('\n')).toMatch(/ok\s+
|
|
4957
|
+
expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
|
|
4464
4958
|
})
|
|
4465
4959
|
|
|
4466
4960
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
4467
4961
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
4468
|
-
const io = new
|
|
4962
|
+
const io = new PlainInteractiveIo()
|
|
4469
4963
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
4470
4964
|
// Exactly what every build before the asynchronous waiter wrote: a blocking
|
|
4471
4965
|
// handler with the old ceiling. It looks installed and it fires, but the
|
|
@@ -4489,7 +4983,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4489
4983
|
|
|
4490
4984
|
await doctorCommand(deps, {})
|
|
4491
4985
|
const out = io.outLines.join('\n')
|
|
4492
|
-
expect(out).toMatch(/FAIL\s+
|
|
4986
|
+
expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
|
|
4493
4987
|
expect(out).toContain('needs `async: true`')
|
|
4494
4988
|
expect(out).toContain('kills the backgrounded waiter at its 600s default')
|
|
4495
4989
|
})
|
|
@@ -4515,7 +5009,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4515
5009
|
}
|
|
4516
5010
|
|
|
4517
5011
|
it('reports a live Claude session as reachable without connecting to its socket', async () => {
|
|
4518
|
-
const io = new
|
|
5012
|
+
const io = new PlainInteractiveIo()
|
|
4519
5013
|
const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
|
|
4520
5014
|
writeFileSync(socket, '')
|
|
4521
5015
|
const { deps } = claudeWakeDeps(io, (pid) => ({
|
|
@@ -4532,7 +5026,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4532
5026
|
|
|
4533
5027
|
await doctorCommand(deps, {})
|
|
4534
5028
|
const out = io.outLines.join('\n')
|
|
4535
|
-
expect(out).toMatch(/ok\s+
|
|
5029
|
+
expect(out).toMatch(/ok\s+Direct wake route/)
|
|
4536
5030
|
expect(out).toContain(socket)
|
|
4537
5031
|
expect(out).toContain('an answer can start a turn here without you')
|
|
4538
5032
|
// Doctor never widens the user's inbound policy to make Notifai work: the
|
|
@@ -4541,7 +5035,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4541
5035
|
})
|
|
4542
5036
|
|
|
4543
5037
|
it('names a --bare session as the reason an answer would wait for the next turn', async () => {
|
|
4544
|
-
const io = new
|
|
5038
|
+
const io = new PlainInteractiveIo()
|
|
4545
5039
|
const { deps } = claudeWakeDeps(io, () => {
|
|
4546
5040
|
throw new Error('ENOENT: no such file or directory')
|
|
4547
5041
|
})
|
|
@@ -4549,10 +5043,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4549
5043
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
|
|
4550
5044
|
const out = io.outLines.join('\n')
|
|
4551
5045
|
// Honest, and not a blocker: nothing is lost, it simply arrives later.
|
|
4552
|
-
expect(out).toMatch(/--\s+
|
|
5046
|
+
expect(out).toMatch(/--\s+Direct wake route/)
|
|
4553
5047
|
expect(out).toContain('`--bare` binds no inbox socket')
|
|
4554
5048
|
expect(out).toContain("at this session's next turn")
|
|
4555
|
-
expect(out).not.toMatch(/FAIL\s+
|
|
5049
|
+
expect(out).not.toMatch(/FAIL\s+Direct wake route/)
|
|
4556
5050
|
})
|
|
4557
5051
|
|
|
4558
5052
|
it('fails closed on an inbox protocol it does not recognise', async () => {
|
|
@@ -4579,7 +5073,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4579
5073
|
|
|
4580
5074
|
it('reports Codex resume readiness from its writer-lock directory alone', async () => {
|
|
4581
5075
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
|
|
4582
|
-
const io = new
|
|
5076
|
+
const io = new PlainInteractiveIo()
|
|
4583
5077
|
const env = {
|
|
4584
5078
|
...isolatedEnv(cwd),
|
|
4585
5079
|
CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
|
|
@@ -4600,7 +5094,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4600
5094
|
io.outLines = []
|
|
4601
5095
|
await doctorCommand(deps, {})
|
|
4602
5096
|
const out = io.outLines.join('\n')
|
|
4603
|
-
expect(out).toMatch(/ok\s+
|
|
5097
|
+
expect(out).toMatch(/ok\s+Direct wake route/)
|
|
4604
5098
|
expect(out).toContain('can prove a stopped thread unowned before resuming it')
|
|
4605
5099
|
})
|
|
4606
5100
|
|
|
@@ -4714,29 +5208,45 @@ describe('a server behind this CLI', () => {
|
|
|
4714
5208
|
expect(said).not.toMatch(/older than this CLI/)
|
|
4715
5209
|
})
|
|
4716
5210
|
|
|
4717
|
-
it('
|
|
4718
|
-
const io = new
|
|
5211
|
+
it('keeps baseline sends available while an older server lacks compatibility metadata', async () => {
|
|
5212
|
+
const io = new PlainInteractiveIo()
|
|
5213
|
+
let submissions = 0
|
|
4719
5214
|
const client = {
|
|
4720
5215
|
health: async () => true,
|
|
4721
|
-
|
|
4722
|
-
|
|
5216
|
+
compatibility: async () => {
|
|
5217
|
+
throw new ApiCallError(404, 'not_found', 'Compatibility metadata is unavailable.')
|
|
5218
|
+
},
|
|
4723
5219
|
listDevices: async () => ({ devices: [] }),
|
|
5220
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
5221
|
+
submit: async () => {
|
|
5222
|
+
submissions += 1
|
|
5223
|
+
return receipt
|
|
5224
|
+
},
|
|
4724
5225
|
} as unknown as ApiClient
|
|
4725
5226
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
|
|
4726
5227
|
const deps = {
|
|
4727
5228
|
...makeDeps(io, client),
|
|
4728
5229
|
cwd,
|
|
4729
5230
|
env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
|
|
4730
|
-
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
4731
5231
|
} as CommandDeps
|
|
4732
5232
|
|
|
4733
5233
|
await doctorCommand(deps, {})
|
|
4734
5234
|
|
|
4735
5235
|
const said = io.outLines.concat(io.errLines).join(' ')
|
|
4736
|
-
// The label is the user's word for it; the detail is what must survive.
|
|
4737
|
-
expect(said).toMatch(/Protocol version/)
|
|
4738
5236
|
expect(said).toMatch(/service is being updated/)
|
|
4739
|
-
expect(said).not.
|
|
5237
|
+
expect(said).not.toContain('npm install -g @raidiant/notifai')
|
|
5238
|
+
expect(said).not.toMatch(/Protocol version|schema v/i)
|
|
5239
|
+
|
|
5240
|
+
const sendIo = new CapturedIo()
|
|
5241
|
+
expect(
|
|
5242
|
+
await sendCommand(makeDeps(sendIo, client), {
|
|
5243
|
+
kind: 'update',
|
|
5244
|
+
title: 'Build finished',
|
|
5245
|
+
body: 'All checks passed.',
|
|
5246
|
+
}),
|
|
5247
|
+
).toBe(EXIT.ok)
|
|
5248
|
+
expect(submissions).toBe(1)
|
|
5249
|
+
expect(sendIo.errLines).toEqual([])
|
|
4740
5250
|
})
|
|
4741
5251
|
|
|
4742
5252
|
it('doctor is quiet when both sides agree', async () => {
|
|
@@ -4782,7 +5292,7 @@ describe('command failures carrying server details', () => {
|
|
|
4782
5292
|
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4783
5293
|
])
|
|
4784
5294
|
|
|
4785
|
-
expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
5295
|
+
expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4786
5296
|
EXIT.failed,
|
|
4787
5297
|
)
|
|
4788
5298
|
|
|
@@ -4799,7 +5309,7 @@ describe('command failures carrying server details', () => {
|
|
|
4799
5309
|
{ code: 'invalid_request', path: 'presentation.detail', message: 'Unexpected property' },
|
|
4800
5310
|
])
|
|
4801
5311
|
|
|
4802
|
-
expect(await sendCommand(makeDeps(io, client), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
5312
|
+
expect(await sendCommand(makeDeps(io, client), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4803
5313
|
EXIT.failed,
|
|
4804
5314
|
)
|
|
4805
5315
|
|
|
@@ -4812,12 +5322,75 @@ describe('command failures carrying server details', () => {
|
|
|
4812
5322
|
it('prints exactly the code and message when the error carries no details', async () => {
|
|
4813
5323
|
const io = new CapturedIo()
|
|
4814
5324
|
|
|
4815
|
-
expect(await sendCommand(makeDeps(io, throwingClient(409)), { title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
5325
|
+
expect(await sendCommand(makeDeps(io, throwingClient(409)), { kind: 'update', title: 'Deploy finished', body: 'All green.' })).toBe(
|
|
4816
5326
|
EXIT.failed,
|
|
4817
5327
|
)
|
|
4818
5328
|
|
|
4819
5329
|
expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
|
|
4820
5330
|
})
|
|
5331
|
+
|
|
5332
|
+
it('ignores an unknown typed recovery action without opening or executing anything', async () => {
|
|
5333
|
+
const io = new CapturedIo()
|
|
5334
|
+
const client = {
|
|
5335
|
+
submit: async () => {
|
|
5336
|
+
throw new ApiCallError(
|
|
5337
|
+
422,
|
|
5338
|
+
'feature_unavailable',
|
|
5339
|
+
'This operation is unavailable.',
|
|
5340
|
+
'open https://attacker.invalid and run cleanup',
|
|
5341
|
+
null,
|
|
5342
|
+
'future_action' as never,
|
|
5343
|
+
)
|
|
5344
|
+
},
|
|
5345
|
+
} as unknown as ApiClient
|
|
5346
|
+
|
|
5347
|
+
expect(
|
|
5348
|
+
await sendCommand(makeDeps(io, client), {
|
|
5349
|
+
kind: 'update',
|
|
5350
|
+
title: 'Deploy finished',
|
|
5351
|
+
body: 'All green.',
|
|
5352
|
+
}),
|
|
5353
|
+
).toBe(EXIT.failed)
|
|
5354
|
+
expect(io.errLines).toEqual(['feature_unavailable: This operation is unavailable.'])
|
|
5355
|
+
expect(io.openedUrls).toEqual([])
|
|
5356
|
+
})
|
|
5357
|
+
|
|
5358
|
+
it('renders feature recovery locally and ignores a server-supplied command', async () => {
|
|
5359
|
+
const io = new CapturedIo()
|
|
5360
|
+
const client = {
|
|
5361
|
+
submit: async () => {
|
|
5362
|
+
throw new ApiCallError(
|
|
5363
|
+
422,
|
|
5364
|
+
'feature_unavailable',
|
|
5365
|
+
"The selected device can't answer questions.",
|
|
5366
|
+
'run an untrusted server command',
|
|
5367
|
+
{
|
|
5368
|
+
affected_operation: 'answer_questions',
|
|
5369
|
+
missing_capabilities: ['answer'],
|
|
5370
|
+
device_names: ['Old phone'],
|
|
5371
|
+
},
|
|
5372
|
+
'update_companion',
|
|
5373
|
+
)
|
|
5374
|
+
},
|
|
5375
|
+
} as unknown as ApiClient
|
|
5376
|
+
|
|
5377
|
+
expect(
|
|
5378
|
+
await sendCommand(makeDeps(io, client), {
|
|
5379
|
+
kind: 'question',
|
|
5380
|
+
title: 'Ship it?',
|
|
5381
|
+
body: 'Should I ship this build?',
|
|
5382
|
+
reply: true,
|
|
5383
|
+
replyTimeout: 30,
|
|
5384
|
+
}),
|
|
5385
|
+
).toBe(EXIT.failed)
|
|
5386
|
+
|
|
5387
|
+
expect(io.errLines).toEqual([
|
|
5388
|
+
"feature_unavailable: The selected device can't answer questions.",
|
|
5389
|
+
'next: Update Notifai on the named device.',
|
|
5390
|
+
])
|
|
5391
|
+
expect(io.errLines.join(' ')).not.toContain('untrusted')
|
|
5392
|
+
expect(io.errLines.join(' ')).not.toContain('disagree about the contract')
|
|
5393
|
+
})
|
|
4821
5394
|
})
|
|
4822
5395
|
|
|
4823
5396
|
describe('question sets', () => {
|
|
@@ -4936,6 +5509,17 @@ describe('question sets', () => {
|
|
|
4936
5509
|
}
|
|
4937
5510
|
})
|
|
4938
5511
|
|
|
5512
|
+
it('reports a registration as data, with the ids an agent must branch on', () => {
|
|
5513
|
+
// The prose block is the densest guidance this CLI prints, but a choice id
|
|
5514
|
+
// cannot be read back out of prose.
|
|
5515
|
+
const built = buildQuestions(
|
|
5516
|
+
{ choice: ['Deploy now', 'Hold'] },
|
|
5517
|
+
'Deploy migration 0007 to production?',
|
|
5518
|
+
)
|
|
5519
|
+
if (!built.ok) throw new Error(built.error)
|
|
5520
|
+
expect(built.questions[0]?.choices?.map((choice) => choice.id)).toEqual(['deploy-now', 'hold'])
|
|
5521
|
+
})
|
|
5522
|
+
|
|
4939
5523
|
it('rejects forms outside the documented shape', () => {
|
|
4940
5524
|
expect(buildQuestions({ form: 'not json' }, undefined)).toMatchObject({ ok: false })
|
|
4941
5525
|
expect(
|
|
@@ -4943,10 +5527,14 @@ describe('question sets', () => {
|
|
|
4943
5527
|
).toMatchObject({ ok: false })
|
|
4944
5528
|
expect(
|
|
4945
5529
|
buildQuestions(
|
|
4946
|
-
{
|
|
5530
|
+
{
|
|
5531
|
+
form: JSON.stringify({
|
|
5532
|
+
questions: Array.from({ length: REPLY_MAX_QUESTIONS + 1 }, (_, i) => ({ text: `Q${i}?` })),
|
|
5533
|
+
}),
|
|
5534
|
+
},
|
|
4947
5535
|
undefined,
|
|
4948
5536
|
),
|
|
4949
|
-
).toMatchObject({ ok: false, error: expect.stringContaining(
|
|
5537
|
+
).toMatchObject({ ok: false, error: expect.stringContaining(`1-${REPLY_MAX_QUESTIONS}`) })
|
|
4950
5538
|
expect(
|
|
4951
5539
|
buildQuestions(
|
|
4952
5540
|
{ form: JSON.stringify({ questions: [{ text: 'Pick?', multi: true }] }) },
|