@raidiant/notifai 10.0.0 → 10.1.1
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 +31 -0
- package/README.md +4 -1
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +32 -14
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +10 -1
- package/dist/commands-core.js +13 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +158 -71
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.js +8 -0
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.js +169 -15
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -91
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/harnesses.d.ts +2 -2
- package/dist/harnesses.js +22 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.js +15 -9
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.js +20 -23
- package/dist/interactive.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/node-floor.d.ts +18 -0
- package/dist/node-floor.js +35 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/openclaw-plugin.d.ts +49 -0
- package/dist/openclaw-plugin.js +350 -0
- package/dist/openclaw-plugin.js.map +1 -0
- package/dist/program.d.ts +2 -1
- package/dist/program.js +31 -13
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/session-activation.js +1 -1
- package/dist/session-activation.js.map +1 -1
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/ui/help.d.ts +4 -5
- package/dist/ui/help.js +5 -6
- package/dist/ui/help.js.map +1 -1
- package/package.json +2 -2
- package/src/cli-bin.test.ts +145 -7
- package/src/cli-bin.ts +176 -10
- package/src/cli-update-contract.ts +9 -0
- package/src/cli-update.test.ts +125 -0
- package/src/client.ts +7 -0
- package/src/codex-wake.ts +5 -1
- package/src/commands-auth.ts +47 -13
- package/src/commands-core.ts +21 -4
- package/src/commands-devices.ts +23 -16
- package/src/commands-doctor.ts +197 -75
- package/src/commands-harness-context.ts +8 -0
- package/src/commands-hooks.ts +210 -18
- package/src/commands-init.ts +275 -92
- package/src/commands-send.ts +145 -39
- package/src/commands-update.ts +180 -0
- package/src/commands.test.ts +701 -53
- package/src/commands.ts +2 -0
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +57 -0
- package/src/harness-integration.test.ts +82 -0
- package/src/harnesses.test.ts +9 -1
- package/src/harnesses.ts +24 -2
- package/src/hook-adapter.ts +14 -0
- package/src/hooks.test.ts +275 -0
- package/src/hooks.ts +270 -18
- package/src/install-hooks.test.ts +208 -0
- package/src/install-hooks.ts +19 -9
- package/src/interactive.test.ts +31 -0
- package/src/interactive.ts +20 -26
- package/src/main-run.ts +45 -0
- package/src/main.ts +9 -33
- package/src/node-floor.test.ts +27 -0
- package/src/node-floor.ts +37 -0
- package/src/openclaw-plugin.ts +409 -0
- package/src/program.test.ts +35 -0
- package/src/program.ts +37 -12
- package/src/question-settlement-process.ts +47 -0
- package/src/readiness.ts +35 -2
- package/src/session-activation.ts +1 -1
- package/src/setup-destinations.test.ts +41 -0
- package/src/setup-destinations.ts +50 -0
- package/src/skill.test.ts +13 -2
- package/src/ui/help.test.ts +16 -0
- package/src/ui/help.ts +6 -7
- package/src/commands-devices.test.ts +0 -15
package/src/commands.test.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notif
|
|
|
2
2
|
import { execFileSync } from 'node:child_process'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import {
|
|
5
|
+
chmodSync,
|
|
5
6
|
existsSync,
|
|
6
7
|
mkdirSync,
|
|
7
8
|
mkdtempSync,
|
|
8
9
|
readFileSync,
|
|
10
|
+
realpathSync,
|
|
9
11
|
readdirSync,
|
|
10
12
|
rmSync,
|
|
11
13
|
symlinkSync,
|
|
@@ -34,6 +36,7 @@ import {
|
|
|
34
36
|
acknowledgeCommand,
|
|
35
37
|
askCommand,
|
|
36
38
|
accessStatusCommand,
|
|
39
|
+
authStatusCommand,
|
|
37
40
|
buildQuestions,
|
|
38
41
|
assessReadiness,
|
|
39
42
|
capabilitiesCommand,
|
|
@@ -58,6 +61,7 @@ import {
|
|
|
58
61
|
repliesCommand,
|
|
59
62
|
sendCommand,
|
|
60
63
|
statusCommand,
|
|
64
|
+
updateCliCommand,
|
|
61
65
|
agentSessionRenameCommand,
|
|
62
66
|
type CommandDeps,
|
|
63
67
|
type CommandIo,
|
|
@@ -74,15 +78,21 @@ import {
|
|
|
74
78
|
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
75
79
|
settingsFile,
|
|
76
80
|
} from './install-hooks.js'
|
|
77
|
-
import {
|
|
81
|
+
import {
|
|
82
|
+
inspectQuestionState,
|
|
83
|
+
readSessionState,
|
|
84
|
+
writeProjectSession,
|
|
85
|
+
writeSessionState,
|
|
86
|
+
} from './hooks.js'
|
|
78
87
|
import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
|
|
79
88
|
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.js'
|
|
80
89
|
import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
|
|
81
90
|
import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
82
91
|
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
83
|
-
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
92
|
+
import { hookAdapterPath, inspectHookAdapter, installHookAdapter } from './hook-adapter.js'
|
|
84
93
|
import type { Tone } from './ui/theme.js'
|
|
85
94
|
import { projectBinding, projectEnabled } from './project-enablement.js'
|
|
95
|
+
import { firstRequiredBlocker } from './readiness.js'
|
|
86
96
|
|
|
87
97
|
afterEach(() => {
|
|
88
98
|
resetLatestPublishedCliVersionForTest()
|
|
@@ -446,6 +456,16 @@ describe('command contracts', () => {
|
|
|
446
456
|
expect(submitted?.draft.project).toBeUndefined()
|
|
447
457
|
expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
|
|
448
458
|
})
|
|
459
|
+
it('tells an unsigned machine to run init, not login', () => {
|
|
460
|
+
const io = new CapturedIo()
|
|
461
|
+
const deps = makeDeps(io, {} as ApiClient)
|
|
462
|
+
deps.store.load = () => null
|
|
463
|
+
|
|
464
|
+
expect(authStatusCommand(deps, {})).toBe(EXIT.auth)
|
|
465
|
+
expect(io.errLines.join('\n')).toMatch(/Not signed in\. Run `notifai init`/)
|
|
466
|
+
expect(io.errLines.join('\n')).not.toContain('notifai login')
|
|
467
|
+
})
|
|
468
|
+
|
|
449
469
|
it('shows an actionable no-plan access state', async () => {
|
|
450
470
|
const io = new CapturedIo()
|
|
451
471
|
const client = {
|
|
@@ -458,8 +478,8 @@ describe('command contracts', () => {
|
|
|
458
478
|
|
|
459
479
|
expect(await accessStatusCommand(makeDeps(io, client), {})).toBe(EXIT.failed)
|
|
460
480
|
expect(io.outLines).toEqual([
|
|
461
|
-
'
|
|
462
|
-
'next: Open https://test.notifai.invalid/
|
|
481
|
+
'This account does not have access to Notifai yet.',
|
|
482
|
+
'next: Open https://test.notifai.invalid/setup/access to set up access, then retry.',
|
|
463
483
|
])
|
|
464
484
|
})
|
|
465
485
|
|
|
@@ -2114,6 +2134,10 @@ describe('command contracts', () => {
|
|
|
2114
2134
|
closed: [],
|
|
2115
2135
|
})
|
|
2116
2136
|
expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
|
|
2137
|
+
expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
|
|
2138
|
+
found: true,
|
|
2139
|
+
question: { state: 'withdrawn', submitted: false, request_id: null },
|
|
2140
|
+
})
|
|
2117
2141
|
})
|
|
2118
2142
|
|
|
2119
2143
|
it('close withdraws one unpushed question by stable id from another checkout', async () => {
|
|
@@ -2171,6 +2195,7 @@ describe('command contracts', () => {
|
|
|
2171
2195
|
{ question: 'Unpushed?', question_id: 'q_local' },
|
|
2172
2196
|
{
|
|
2173
2197
|
question: 'Already on a device?',
|
|
2198
|
+
question_id: 'q_live',
|
|
2174
2199
|
request_id: receipt.request_id,
|
|
2175
2200
|
collapse_key: 'question-live',
|
|
2176
2201
|
device_ids: ['dev_iphone'],
|
|
@@ -2187,6 +2212,14 @@ describe('command contracts', () => {
|
|
|
2187
2212
|
closed: [receipt.request_id],
|
|
2188
2213
|
})
|
|
2189
2214
|
expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
|
|
2215
|
+
expect(inspectQuestionState('q_local', deps.env)).toMatchObject({
|
|
2216
|
+
found: true,
|
|
2217
|
+
question: { state: 'withdrawn', submitted: false, request_id: null },
|
|
2218
|
+
})
|
|
2219
|
+
expect(inspectQuestionState('q_live', deps.env)).toMatchObject({
|
|
2220
|
+
found: true,
|
|
2221
|
+
question: { state: 'retired', submitted: true, request_id: receipt.request_id },
|
|
2222
|
+
})
|
|
2190
2223
|
})
|
|
2191
2224
|
|
|
2192
2225
|
it('close requires a request id or --pending', async () => {
|
|
@@ -2596,8 +2629,88 @@ describe('credential origin pinning', () => {
|
|
|
2596
2629
|
expect(io.errLines).toEqual([
|
|
2597
2630
|
'This account has no active plan or temporary Alpha access.',
|
|
2598
2631
|
'next: Ask the account owner for Alpha access.',
|
|
2599
|
-
'After access is granted, run `notifai
|
|
2632
|
+
'After access is granted, run `notifai init` again.',
|
|
2600
2633
|
])
|
|
2634
|
+
expect(io.errLines.join('\n')).not.toMatch(/Pairing expired/i)
|
|
2635
|
+
expect(io.errLines.join('\n')).not.toContain('notifai login')
|
|
2636
|
+
expect(now).toBeLessThan(10_000)
|
|
2637
|
+
})
|
|
2638
|
+
|
|
2639
|
+
it('reports the access blocker to whoever asked for the sign-in', async () => {
|
|
2640
|
+
const io = new CapturedIo()
|
|
2641
|
+
let now = 0
|
|
2642
|
+
const client = {
|
|
2643
|
+
beginPairing: async () => ({
|
|
2644
|
+
pairing_id: 'pair_test',
|
|
2645
|
+
code: 'ABCD-EFGH',
|
|
2646
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2647
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2648
|
+
poll_interval_seconds: 1,
|
|
2649
|
+
}),
|
|
2650
|
+
pollPairing: async () => ({
|
|
2651
|
+
status: 'no_active_plan',
|
|
2652
|
+
next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
|
|
2653
|
+
}),
|
|
2654
|
+
} as unknown as ApiClient
|
|
2655
|
+
const deps: CommandDeps = {
|
|
2656
|
+
...makeDeps(io, client),
|
|
2657
|
+
now: () => now,
|
|
2658
|
+
sleep: async (milliseconds: number) => {
|
|
2659
|
+
now += milliseconds
|
|
2660
|
+
},
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
let blocked: { title: string; detail: string } | null = null
|
|
2664
|
+
expect(
|
|
2665
|
+
await loginCommand(deps, { open: false }, (state) => {
|
|
2666
|
+
blocked = state
|
|
2667
|
+
}),
|
|
2668
|
+
).toBe(EXIT.auth)
|
|
2669
|
+
// Handing the blocker over hands over the errand. Saying it here too gave
|
|
2670
|
+
// the caller's close a second, differently worded copy to contradict.
|
|
2671
|
+
expect(io.errLines).toEqual([])
|
|
2672
|
+
// Without this the caller falls back to the state it held before the
|
|
2673
|
+
// attempt — "not paired … run `notifai init`" — and prints it under the
|
|
2674
|
+
// correct line, in contradiction with it.
|
|
2675
|
+
expect(blocked).toMatchObject({
|
|
2676
|
+
id: 'auth',
|
|
2677
|
+
title: 'Access',
|
|
2678
|
+
status: 'gap',
|
|
2679
|
+
detail: 'this account does not have access to Notifai yet',
|
|
2680
|
+
remedy: {
|
|
2681
|
+
by: 'user-elsewhere',
|
|
2682
|
+
summary: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
|
|
2683
|
+
},
|
|
2684
|
+
})
|
|
2685
|
+
})
|
|
2686
|
+
|
|
2687
|
+
it('reports expiry only when pairing times out without a no-access mark', async () => {
|
|
2688
|
+
const io = new CapturedIo()
|
|
2689
|
+
let now = 0
|
|
2690
|
+
const client = {
|
|
2691
|
+
beginPairing: async () => ({
|
|
2692
|
+
pairing_id: 'pair_test',
|
|
2693
|
+
code: 'ABCD-EFGH',
|
|
2694
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2695
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2696
|
+
poll_interval_seconds: 1,
|
|
2697
|
+
}),
|
|
2698
|
+
pollPairing: async () => ({ status: 'expired' }),
|
|
2699
|
+
} as unknown as ApiClient
|
|
2700
|
+
const deps: CommandDeps = {
|
|
2701
|
+
...makeDeps(io, client),
|
|
2702
|
+
now: () => now,
|
|
2703
|
+
sleep: async (milliseconds: number) => {
|
|
2704
|
+
now += milliseconds
|
|
2705
|
+
},
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
2709
|
+
expect(io.errLines).toEqual([
|
|
2710
|
+
'Pairing expired before it was approved. Run `notifai init` again.',
|
|
2711
|
+
])
|
|
2712
|
+
expect(io.errLines.join('\n')).not.toMatch(/no active plan/i)
|
|
2713
|
+
expect(io.errLines.join('\n')).not.toContain('notifai login')
|
|
2601
2714
|
})
|
|
2602
2715
|
|
|
2603
2716
|
it('keeps notification titles out of send logs', async () => {
|
|
@@ -2694,6 +2807,96 @@ describe('delivery evidence status', () => {
|
|
|
2694
2807
|
}
|
|
2695
2808
|
}
|
|
2696
2809
|
|
|
2810
|
+
it('reports every local question state by q identity without creating or submitting anything', async () => {
|
|
2811
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-question-status-'))
|
|
2812
|
+
const env = {
|
|
2813
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
2814
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
2815
|
+
}
|
|
2816
|
+
writeSessionState('question-states', env, {
|
|
2817
|
+
pending: [
|
|
2818
|
+
{ question: 'Local?', question_id: 'q_local' },
|
|
2819
|
+
{
|
|
2820
|
+
question: 'Frozen?',
|
|
2821
|
+
question_id: 'q_frozen',
|
|
2822
|
+
submission: {
|
|
2823
|
+
request_id: 'req_frozen',
|
|
2824
|
+
idempotency_key: 'idem-frozen',
|
|
2825
|
+
collapse_key: 'collapse-frozen',
|
|
2826
|
+
device_ids: ['dev_status_test'],
|
|
2827
|
+
draft: {} as never,
|
|
2828
|
+
owner_deadline_at: Date.now() + 60_000,
|
|
2829
|
+
},
|
|
2830
|
+
},
|
|
2831
|
+
{ question: 'Live?', question_id: 'q_live', request_id: 'req_live' },
|
|
2832
|
+
],
|
|
2833
|
+
question_history: [
|
|
2834
|
+
{ question_id: 'q_answered', state: 'answered', request_id: 'req_answered' },
|
|
2835
|
+
{ question_id: 'q_withdrawn', state: 'withdrawn' },
|
|
2836
|
+
{ question_id: 'q_retired', state: 'retired', request_id: 'req_retired' },
|
|
2837
|
+
],
|
|
2838
|
+
})
|
|
2839
|
+
const evidenceCalls: string[] = []
|
|
2840
|
+
const client = {
|
|
2841
|
+
evidence: async (requestId: string) => {
|
|
2842
|
+
evidenceCalls.push(requestId)
|
|
2843
|
+
return { ...snapshot({ state: 'unknown', observed_at: null, latency_ms: null }), request_id: requestId }
|
|
2844
|
+
},
|
|
2845
|
+
} as unknown as ApiClient
|
|
2846
|
+
const io = new CapturedIo()
|
|
2847
|
+
const deps = { ...makeDeps(io, client), cwd: root, env }
|
|
2848
|
+
|
|
2849
|
+
const expected = [
|
|
2850
|
+
['q_local', 'local', false, null, null],
|
|
2851
|
+
['q_frozen', 'frozen', null, null, 'req_frozen'],
|
|
2852
|
+
['q_live', 'live', true, 'req_live', null],
|
|
2853
|
+
['q_answered', 'answered', true, 'req_answered', null],
|
|
2854
|
+
['q_withdrawn', 'withdrawn', false, null, null],
|
|
2855
|
+
['q_retired', 'retired', true, 'req_retired', null],
|
|
2856
|
+
] as const
|
|
2857
|
+
for (const [questionId, state, submitted, requestId, frozenRequestId] of expected) {
|
|
2858
|
+
io.outLines = []
|
|
2859
|
+
expect(await statusCommand(deps, questionId, { json: true })).toBe(EXIT.ok)
|
|
2860
|
+
expect(JSON.parse(io.outLines.join('\n'))).toMatchObject({
|
|
2861
|
+
question_id: questionId,
|
|
2862
|
+
state,
|
|
2863
|
+
submitted,
|
|
2864
|
+
request_id: requestId,
|
|
2865
|
+
frozen_request_id: frozenRequestId,
|
|
2866
|
+
recovery: {
|
|
2867
|
+
inspect: `notifai status ${questionId}`,
|
|
2868
|
+
close: `notifai close ${questionId}`,
|
|
2869
|
+
register_replacement: false,
|
|
2870
|
+
},
|
|
2871
|
+
})
|
|
2872
|
+
}
|
|
2873
|
+
expect(evidenceCalls).toEqual(['req_live', 'req_answered', 'req_retired'])
|
|
2874
|
+
})
|
|
2875
|
+
|
|
2876
|
+
it('states plainly that a local registration has no submission or Provider Acceptance', async () => {
|
|
2877
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-local-question-status-'))
|
|
2878
|
+
const env = { XDG_STATE_HOME: path.join(root, 'state') }
|
|
2879
|
+
writeSessionState('local-question', env, {
|
|
2880
|
+
pending: [{ question: 'Ship?', question_id: 'q_local_plain' }],
|
|
2881
|
+
})
|
|
2882
|
+
const io = new CapturedIo()
|
|
2883
|
+
const client = {
|
|
2884
|
+
evidence: async () => {
|
|
2885
|
+
throw new Error('local status must not call the service')
|
|
2886
|
+
},
|
|
2887
|
+
} as unknown as ApiClient
|
|
2888
|
+
|
|
2889
|
+
expect(
|
|
2890
|
+
await statusCommand({ ...makeDeps(io, client), cwd: root, env }, 'q_local_plain', {}),
|
|
2891
|
+
).toBe(EXIT.ok)
|
|
2892
|
+
const said = io.outLines.join('\n')
|
|
2893
|
+
expect(said).toContain('question q_local_plain — local')
|
|
2894
|
+
expect(said).toContain('not submitted')
|
|
2895
|
+
expect(said).toContain('only a local registration')
|
|
2896
|
+
expect(said).toContain('Provider Acceptance: not available')
|
|
2897
|
+
expect(said).toContain('do not register a replacement')
|
|
2898
|
+
})
|
|
2899
|
+
|
|
2697
2900
|
it('calls an unobserved first-minute receipt unknown rather than failed', async () => {
|
|
2698
2901
|
const io = new CapturedIo()
|
|
2699
2902
|
const client = {
|
|
@@ -3277,6 +3480,71 @@ describe('harness activation guidance', () => {
|
|
|
3277
3480
|
expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
|
|
3278
3481
|
expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
|
|
3279
3482
|
})
|
|
3483
|
+
|
|
3484
|
+
it('installs an owned OpenClaw plugin and reports unsupported continuation', async () => {
|
|
3485
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-activation-'))
|
|
3486
|
+
const io = new CapturedIo()
|
|
3487
|
+
const client = {
|
|
3488
|
+
health: async () => true,
|
|
3489
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3490
|
+
listDevices: async () => ({ devices: [] }),
|
|
3491
|
+
} as unknown as ApiClient
|
|
3492
|
+
const home = path.join(cwd, 'home')
|
|
3493
|
+
const deps = {
|
|
3494
|
+
...makeDeps(io, client),
|
|
3495
|
+
cwd,
|
|
3496
|
+
env: {
|
|
3497
|
+
HOME: home,
|
|
3498
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3499
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3500
|
+
OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
|
|
3501
|
+
},
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3505
|
+
expect(io.outLines.join('\n')).toContain('Installed openclaw hooks in')
|
|
3506
|
+
expect(io.outLines.join('\n')).toContain(
|
|
3507
|
+
'Restart the OpenClaw Gateway, start one fresh Agent Session, send one prompt, then run `notifai doctor`.',
|
|
3508
|
+
)
|
|
3509
|
+
const pluginFile = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
|
|
3510
|
+
const plugin = readFileSync(pluginFile, 'utf8')
|
|
3511
|
+
expect(plugin).toContain('api.on("before_prompt_build"')
|
|
3512
|
+
expect(plugin).toContain('api.on("resolve_exec_env"')
|
|
3513
|
+
expect(plugin).not.toContain('command:stop')
|
|
3514
|
+
const config = JSON.parse(
|
|
3515
|
+
readFileSync(path.join(cwd, 'openclaw-home', 'openclaw.json'), 'utf8'),
|
|
3516
|
+
) as {
|
|
3517
|
+
plugins: { entries: { notifai: { enabled: boolean; hooks: { allowConversationAccess: boolean } } } }
|
|
3518
|
+
}
|
|
3519
|
+
expect(config.plugins.entries.notifai).toEqual({
|
|
3520
|
+
enabled: true,
|
|
3521
|
+
hooks: { allowConversationAccess: true },
|
|
3522
|
+
})
|
|
3523
|
+
|
|
3524
|
+
io.outLines = []
|
|
3525
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
3526
|
+
expect(io.outLines.join('\n')).toContain('no proven answer continuation')
|
|
3527
|
+
})
|
|
3528
|
+
|
|
3529
|
+
it('refuses to overwrite a foreign OpenClaw plugin', () => {
|
|
3530
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-openclaw-foreign-'))
|
|
3531
|
+
const plugin = path.join(cwd, '.openclaw', 'extensions', 'notifai', 'index.js')
|
|
3532
|
+
mkdirSync(path.dirname(plugin), { recursive: true })
|
|
3533
|
+
writeFileSync(plugin, 'export default { id: "foreign" }\n')
|
|
3534
|
+
const io = new CapturedIo()
|
|
3535
|
+
const deps = {
|
|
3536
|
+
...makeDeps(io, {} as ApiClient),
|
|
3537
|
+
cwd,
|
|
3538
|
+
env: {
|
|
3539
|
+
HOME: path.join(cwd, 'home'),
|
|
3540
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3541
|
+
OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
|
|
3542
|
+
},
|
|
3543
|
+
}
|
|
3544
|
+
expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.failed)
|
|
3545
|
+
expect(io.errLines.join('\n')).toMatch(/was not written by Notifai/)
|
|
3546
|
+
expect(readFileSync(plugin, 'utf8')).toBe('export default { id: "foreign" }\n')
|
|
3547
|
+
})
|
|
3280
3548
|
})
|
|
3281
3549
|
|
|
3282
3550
|
describe('stable hook installation', () => {
|
|
@@ -4074,7 +4342,7 @@ describe('interactive command UX', () => {
|
|
|
4074
4342
|
|
|
4075
4343
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
4076
4344
|
expect(io.outLines).toContain('A newer Notifai is available.')
|
|
4077
|
-
expect(io.outLines).toContain(
|
|
4345
|
+
expect(io.outLines).toContain(updateCliCommand(deps))
|
|
4078
4346
|
})
|
|
4079
4347
|
})
|
|
4080
4348
|
|
|
@@ -4085,7 +4353,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4085
4353
|
status: 'ready' | 'optional-gap' | 'gap'
|
|
4086
4354
|
detail: string
|
|
4087
4355
|
exit: number
|
|
4088
|
-
humanLines: string[]
|
|
4089
4356
|
}> = [
|
|
4090
4357
|
{
|
|
4091
4358
|
name: 'current',
|
|
@@ -4093,7 +4360,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4093
4360
|
status: 'ready',
|
|
4094
4361
|
detail: 'Notifai can send notifications.',
|
|
4095
4362
|
exit: EXIT.ok,
|
|
4096
|
-
humanLines: [],
|
|
4097
4363
|
},
|
|
4098
4364
|
{
|
|
4099
4365
|
name: 'optional newer release',
|
|
@@ -4106,10 +4372,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4106
4372
|
status: 'optional-gap',
|
|
4107
4373
|
detail: 'A newer Notifai is available.',
|
|
4108
4374
|
exit: EXIT.ok,
|
|
4109
|
-
humanLines: [
|
|
4110
|
-
'A newer Notifai is available.',
|
|
4111
|
-
'npm install -g @raidiant/notifai',
|
|
4112
|
-
],
|
|
4113
4375
|
},
|
|
4114
4376
|
{
|
|
4115
4377
|
name: 'scheduled Sunset',
|
|
@@ -4124,10 +4386,6 @@ describe('compatibility-first update guidance', () => {
|
|
|
4124
4386
|
status: 'optional-gap',
|
|
4125
4387
|
detail: 'Update Notifai soon to keep sending notifications.',
|
|
4126
4388
|
exit: EXIT.ok,
|
|
4127
|
-
humanLines: [
|
|
4128
|
-
'Update Notifai soon to keep sending notifications.',
|
|
4129
|
-
'npm install -g @raidiant/notifai',
|
|
4130
|
-
],
|
|
4131
4389
|
},
|
|
4132
4390
|
{
|
|
4133
4391
|
name: 'required update',
|
|
@@ -4142,16 +4400,12 @@ describe('compatibility-first update guidance', () => {
|
|
|
4142
4400
|
status: 'gap',
|
|
4143
4401
|
detail: "Notifai can't send notifications until you update.",
|
|
4144
4402
|
exit: EXIT.failed,
|
|
4145
|
-
humanLines: [
|
|
4146
|
-
"Notifai can't send notifications until you update.",
|
|
4147
|
-
'npm install -g @raidiant/notifai',
|
|
4148
|
-
],
|
|
4149
4403
|
},
|
|
4150
4404
|
]
|
|
4151
4405
|
|
|
4152
4406
|
it.each(cases)(
|
|
4153
4407
|
'maps $name policy to closed human copy and structured JSON',
|
|
4154
|
-
async ({ support, status, detail, exit
|
|
4408
|
+
async ({ support, status, detail, exit }) => {
|
|
4155
4409
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
|
|
4156
4410
|
const client = {
|
|
4157
4411
|
health: async () => true,
|
|
@@ -4172,7 +4426,9 @@ describe('compatibility-first update guidance', () => {
|
|
|
4172
4426
|
expect(
|
|
4173
4427
|
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
4174
4428
|
).toBe(exit)
|
|
4175
|
-
expect(humanIo.outLines).toEqual(
|
|
4429
|
+
expect(humanIo.outLines).toEqual(
|
|
4430
|
+
status === 'ready' ? [] : [detail, updateCliCommand(deps)],
|
|
4431
|
+
)
|
|
4176
4432
|
expect(humanIo.errLines).toEqual([])
|
|
4177
4433
|
expect(humanIo.outros).toEqual(
|
|
4178
4434
|
status === 'ready' ? ['Everything looks good'] : [],
|
|
@@ -4202,7 +4458,7 @@ describe('compatibility-first update guidance', () => {
|
|
|
4202
4458
|
states: [{ status, detail }],
|
|
4203
4459
|
})
|
|
4204
4460
|
expect(payload.states[0]?.remedy?.command ?? null).toBe(
|
|
4205
|
-
status === 'ready' ? null :
|
|
4461
|
+
status === 'ready' ? null : updateCliCommand(deps),
|
|
4206
4462
|
)
|
|
4207
4463
|
expect(jsonIo.errLines).toEqual([])
|
|
4208
4464
|
|
|
@@ -4271,7 +4527,7 @@ describe('compatibility-first update guidance', () => {
|
|
|
4271
4527
|
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
4272
4528
|
).toBe(EXIT.ok)
|
|
4273
4529
|
expect(io.outLines).toEqual(['The service is being updated; try again later.'])
|
|
4274
|
-
expect(io.outLines.join('\n')).not.toContain('
|
|
4530
|
+
expect(io.outLines.join('\n')).not.toContain('notifai update')
|
|
4275
4531
|
|
|
4276
4532
|
const sendIo = new CapturedIo()
|
|
4277
4533
|
expect(
|
|
@@ -4484,7 +4740,7 @@ describe('init', () => {
|
|
|
4484
4740
|
}),
|
|
4485
4741
|
).toBe(EXIT.failed)
|
|
4486
4742
|
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
4487
|
-
expect(io.outLines).toContain(
|
|
4743
|
+
expect(io.outLines).toContain(updateCliCommand(deps))
|
|
4488
4744
|
expect(io.outros).toEqual([])
|
|
4489
4745
|
expect(submissions).toBe(0)
|
|
4490
4746
|
|
|
@@ -4504,10 +4760,54 @@ describe('init', () => {
|
|
|
4504
4760
|
expect(io.outLines).toContain(
|
|
4505
4761
|
'All set. Agents in this project can notify you. Questions are terminal-only until Question Routing is ready.',
|
|
4506
4762
|
)
|
|
4507
|
-
expect(io.outLines).not.toContain('
|
|
4763
|
+
expect(io.outLines).not.toContain('notifai update')
|
|
4508
4764
|
expect(io.errLines).toEqual([])
|
|
4509
4765
|
})
|
|
4510
4766
|
|
|
4767
|
+
it('retargets Question Routing while the post-update init resumes', async () => {
|
|
4768
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-adapter-'))
|
|
4769
|
+
const io = new CapturedIo()
|
|
4770
|
+
const nativeSkills: NativeSkills = {
|
|
4771
|
+
add: async () => 0,
|
|
4772
|
+
remove: async () => 0,
|
|
4773
|
+
list: async () => ({ skills: [] }),
|
|
4774
|
+
}
|
|
4775
|
+
const deps = setupReadyDeps(io, cwd, nativeSkills, { submit: 0 })
|
|
4776
|
+
const oldArtifact = path.join(cwd, 'old', 'dist', 'main.js')
|
|
4777
|
+
const prefix = path.join(cwd, 'current')
|
|
4778
|
+
const packageRoot = path.join(prefix, 'lib', 'node_modules', '@raidiant', 'notifai')
|
|
4779
|
+
const currentArtifact = path.join(packageRoot, 'dist', 'main.js')
|
|
4780
|
+
const currentCommand = path.join(prefix, 'bin', 'notifai')
|
|
4781
|
+
mkdirSync(path.dirname(oldArtifact), { recursive: true })
|
|
4782
|
+
mkdirSync(path.dirname(currentArtifact), { recursive: true })
|
|
4783
|
+
mkdirSync(path.dirname(currentCommand), { recursive: true })
|
|
4784
|
+
writeFileSync(oldArtifact, '#!/usr/bin/env node\n')
|
|
4785
|
+
writeFileSync(currentArtifact, '#!/usr/bin/env node\n')
|
|
4786
|
+
writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: RELEASE_REF.slice(1) }))
|
|
4787
|
+
chmodSync(oldArtifact, 0o755)
|
|
4788
|
+
chmodSync(currentArtifact, 0o755)
|
|
4789
|
+
symlinkSync(path.relative(path.dirname(currentCommand), currentArtifact), currentCommand)
|
|
4790
|
+
deps.env = { ...deps.env, PATH: path.dirname(currentCommand) }
|
|
4791
|
+
deps.hookInstallTarget = { execPath: process.execPath, scriptPath: currentArtifact }
|
|
4792
|
+
|
|
4793
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: oldArtifact }, deps.hookAdapterHome)
|
|
4794
|
+
applyPlan(path.join(cwd, '.codex', 'hooks.json'), {
|
|
4795
|
+
hooks: buildHookConfig({
|
|
4796
|
+
adapterPath: hookAdapterPath(deps.hookAdapterHome),
|
|
4797
|
+
harness: 'codex',
|
|
4798
|
+
}),
|
|
4799
|
+
})
|
|
4800
|
+
|
|
4801
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({ scriptPath: oldArtifact })
|
|
4802
|
+
expect(
|
|
4803
|
+
await initCommand(deps, { hooks: false, setupScope: 'project', skills: false }),
|
|
4804
|
+
).toBe(EXIT.ok)
|
|
4805
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).target).toMatchObject({
|
|
4806
|
+
scriptPath: realpathSync(currentArtifact),
|
|
4807
|
+
})
|
|
4808
|
+
expect(io.outLines.join('\n')).not.toContain('notifai hooks install')
|
|
4809
|
+
})
|
|
4810
|
+
|
|
4511
4811
|
it('surfaces one next step, not the whole remaining list', async () => {
|
|
4512
4812
|
// The behavioural core of the design: someone handed five things to do
|
|
4513
4813
|
// does none of them. Signing in gates the device check, so naming both
|
|
@@ -4524,8 +4824,9 @@ describe('init', () => {
|
|
|
4524
4824
|
expect(await initCommand(deps, {})).toBe(EXIT.failed)
|
|
4525
4825
|
const out = io.outLines.join('\n')
|
|
4526
4826
|
expect(out).toContain('Next: This machine')
|
|
4527
|
-
expect(out).toContain('notifai
|
|
4528
|
-
expect(out).toContain('
|
|
4827
|
+
expect(out).toContain('notifai init')
|
|
4828
|
+
expect(out).not.toContain('notifai login')
|
|
4829
|
+
expect(out).not.toContain('Then re-run')
|
|
4529
4830
|
// The device gap is real and downstream; it must stay hidden until the
|
|
4530
4831
|
// sign-in that would let anyone actually check it has happened.
|
|
4531
4832
|
expect(out).not.toContain('companion app')
|
|
@@ -4549,19 +4850,23 @@ describe('init', () => {
|
|
|
4549
4850
|
)
|
|
4550
4851
|
})
|
|
4551
4852
|
|
|
4552
|
-
it('
|
|
4853
|
+
it('closes what a process can close when no scope was passed, and asks nothing', async () => {
|
|
4553
4854
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-agent-'))
|
|
4554
4855
|
const io = new CapturedIo()
|
|
4555
4856
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd }
|
|
4556
4857
|
|
|
4557
4858
|
expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.failed)
|
|
4558
|
-
|
|
4859
|
+
// Naming this checkout is not skill or hook placement, so it no longer
|
|
4860
|
+
// waits on a scope question nobody can answer here.
|
|
4861
|
+
expect(readFileSync(path.join(cwd, '.notifai', 'config.toml'), 'utf8')).toContain('project = "')
|
|
4559
4862
|
const result = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4560
|
-
states: Array<{ id: string; status: string }>
|
|
4863
|
+
states: Array<{ id: string; status: string; remedy: { command?: string } | null }>
|
|
4561
4864
|
}
|
|
4562
|
-
expect(result.states.find((state) => state.id === 'project')).toMatchObject({
|
|
4563
|
-
|
|
4564
|
-
|
|
4865
|
+
expect(result.states.find((state) => state.id === 'project')).toMatchObject({ status: 'ready' })
|
|
4866
|
+
// The run stops where a human genuinely is required, and never hands back
|
|
4867
|
+
// the command that produced this output as the thing to do about it.
|
|
4868
|
+
const project = result.states.find((state) => state.id === 'project')
|
|
4869
|
+
expect(project?.remedy).toBeNull()
|
|
4565
4870
|
expect(io.errLines).toEqual([])
|
|
4566
4871
|
})
|
|
4567
4872
|
|
|
@@ -5019,7 +5324,11 @@ describe('init', () => {
|
|
|
5019
5324
|
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(true)
|
|
5020
5325
|
expect(io.outLines.join('\n')).not.toContain('Installed claude-code hooks')
|
|
5021
5326
|
expect(io.outLines.join('\n')).not.toContain('Installed codex hooks')
|
|
5022
|
-
|
|
5327
|
+
// A hook diagnostic is a report line. The run it appears in still reaches
|
|
5328
|
+
// the delivery proof, which is the thing `init` is there to produce.
|
|
5329
|
+
expect(io.outLines.join('\n')).toContain('Companion Receipt')
|
|
5330
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5331
|
+
expect(io.outLines.join('\n')).not.toContain('Next: Codex hook trust')
|
|
5023
5332
|
})
|
|
5024
5333
|
|
|
5025
5334
|
it('lets a human keep a subset of the detected harnesses', async () => {
|
|
@@ -5071,7 +5380,8 @@ describe('init', () => {
|
|
|
5071
5380
|
}
|
|
5072
5381
|
|
|
5073
5382
|
expect(await initCommand(deps, { setupScope: 'project' })).toBe(EXIT.failed)
|
|
5074
|
-
expect(io.outLines.join('\n')).toContain('notifai
|
|
5383
|
+
expect(io.outLines.join('\n')).toContain('notifai init')
|
|
5384
|
+
expect(io.outLines.join('\n')).not.toContain('notifai login')
|
|
5075
5385
|
})
|
|
5076
5386
|
|
|
5077
5387
|
it('announces sign-in for a present human without re-confirming', async () => {
|
|
@@ -5104,7 +5414,8 @@ describe('init', () => {
|
|
|
5104
5414
|
const out = io.outLines.join('\n')
|
|
5105
5415
|
expect(out).toContain('Opening your browser to approve this machine — Ctrl-C to stop.')
|
|
5106
5416
|
expect(out).toContain('Next: This machine')
|
|
5107
|
-
expect(out).toContain('
|
|
5417
|
+
expect(out).toContain('notifai init')
|
|
5418
|
+
expect(out).not.toContain('notifai login')
|
|
5108
5419
|
})
|
|
5109
5420
|
|
|
5110
5421
|
it('never prompts or opens a browser when an agent runs it unattended', async () => {
|
|
@@ -5152,11 +5463,11 @@ describe('init', () => {
|
|
|
5152
5463
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
5153
5464
|
const out = io.outLines.join('\n')
|
|
5154
5465
|
expect(out).toContain('Next: Your devices')
|
|
5155
|
-
|
|
5466
|
+
// One focused destination for this errand, never the omnibus help page.
|
|
5467
|
+
expect(out).toContain('https://test.notifai.invalid/setup/companion')
|
|
5468
|
+
expect(out).not.toContain('/support')
|
|
5156
5469
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
5157
|
-
expect(out).toContain(
|
|
5158
|
-
'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
|
|
5159
|
-
)
|
|
5470
|
+
expect(out).toContain('no active Companion App registered yet')
|
|
5160
5471
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
5161
5472
|
})
|
|
5162
5473
|
|
|
@@ -5271,6 +5582,9 @@ describe('init', () => {
|
|
|
5271
5582
|
},
|
|
5272
5583
|
}
|
|
5273
5584
|
|
|
5585
|
+
// The terminal asks which phone, in those words, and the destination it
|
|
5586
|
+
// opens is already about that answer — nobody chooses twice.
|
|
5587
|
+
io.selectAnswer = 'iphone'
|
|
5274
5588
|
expect(
|
|
5275
5589
|
await initCommand(deps, {
|
|
5276
5590
|
hooks: false,
|
|
@@ -5279,10 +5593,11 @@ describe('init', () => {
|
|
|
5279
5593
|
}),
|
|
5280
5594
|
).toBe(EXIT.ok)
|
|
5281
5595
|
expect(io.prompts).toEqual([
|
|
5282
|
-
'
|
|
5283
|
-
'
|
|
5596
|
+
'Where do you want to receive notifications?',
|
|
5597
|
+
'Open those steps in your browser?',
|
|
5598
|
+
'Wait here while you finish that on your phone?',
|
|
5284
5599
|
])
|
|
5285
|
-
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/
|
|
5600
|
+
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/setup/companion?platform=iphone'])
|
|
5286
5601
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
5287
5602
|
expect(io.spinnerEvents).toContain(
|
|
5288
5603
|
'message:Waiting for a Companion App to sign in and register…',
|
|
@@ -5296,6 +5611,11 @@ describe('init', () => {
|
|
|
5296
5611
|
expect(submitCalls).toBe(1)
|
|
5297
5612
|
expect(submittedDraft?.draft).not.toHaveProperty('event')
|
|
5298
5613
|
expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
|
|
5614
|
+
// The proof is a real send that has to be seen: a normal banner and sound,
|
|
5615
|
+
// not a silent arrival in Notification Center a first-time User never
|
|
5616
|
+
// looks at. Its sound is left to the server's kind/Project/Account layers.
|
|
5617
|
+
expect(submittedDraft?.draft.platform).toEqual({ ios: { interruption_level: 'active' } })
|
|
5618
|
+
expect(submittedDraft?.draft.kind).toBe('done')
|
|
5299
5619
|
|
|
5300
5620
|
io.outLines = []
|
|
5301
5621
|
io.prompts = []
|
|
@@ -5402,7 +5722,8 @@ describe('init', () => {
|
|
|
5402
5722
|
).toBe(EXIT.failed)
|
|
5403
5723
|
const out = io.outLines.join('\n')
|
|
5404
5724
|
expect(out).toContain('Next: Your devices')
|
|
5405
|
-
expect(out).toContain('https://test.notifai.invalid/
|
|
5725
|
+
expect(out).toContain('https://test.notifai.invalid/setup/companion')
|
|
5726
|
+
expect(out).not.toContain('/support')
|
|
5406
5727
|
expect(out).toContain('sign in with the same email as this account (agent@example.com)')
|
|
5407
5728
|
expect(io.openedUrls).toEqual([])
|
|
5408
5729
|
})
|
|
@@ -5553,6 +5874,57 @@ describe('init', () => {
|
|
|
5553
5874
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
5554
5875
|
})
|
|
5555
5876
|
|
|
5877
|
+
it('writes the first notification about the User, not about the check', async () => {
|
|
5878
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-copy-'))
|
|
5879
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5880
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "orders-api"\n')
|
|
5881
|
+
const io = new CapturedIo()
|
|
5882
|
+
let submitted: SubmitNotificationRequestT | null = null
|
|
5883
|
+
const client = {
|
|
5884
|
+
health: async () => true,
|
|
5885
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5886
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
5887
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
5888
|
+
submitted = body
|
|
5889
|
+
return setupReceipt()
|
|
5890
|
+
},
|
|
5891
|
+
evidence: async (requestId: string) =>
|
|
5892
|
+
setupEvidence(requestId, {
|
|
5893
|
+
state: 'observed',
|
|
5894
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
5895
|
+
latency_ms: 1_000,
|
|
5896
|
+
}),
|
|
5897
|
+
} as unknown as ApiClient
|
|
5898
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
5899
|
+
|
|
5900
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5901
|
+
const presentation = submitted!.draft.presentation
|
|
5902
|
+
|
|
5903
|
+
// A title says what is now true for the reader, alone on a lock screen,
|
|
5904
|
+
// and carries neither the kind nor the Project — both travel as their own
|
|
5905
|
+
// fields.
|
|
5906
|
+
expect(presentation.title).toBe('Your agents can reach you')
|
|
5907
|
+
expect(presentation.title.length).toBeLessThanOrEqual(40)
|
|
5908
|
+
expect(presentation.title).not.toMatch(/orders-api|done/i)
|
|
5909
|
+
|
|
5910
|
+
// None of the vocabulary of the check that produced it: the reader has not
|
|
5911
|
+
// met "verification", a "proof", or the distinction between a real
|
|
5912
|
+
// notification and any other kind.
|
|
5913
|
+
const copy = `${presentation.title} ${presentation.body}`
|
|
5914
|
+
expect(copy).not.toMatch(/verification|verify|proof|receipt|real notification|test/i)
|
|
5915
|
+
// And nothing about where it landed or what to do to it.
|
|
5916
|
+
expect(copy).not.toMatch(/iPhone|Android|phone|tap|swipe|banner|lock screen/i)
|
|
5917
|
+
|
|
5918
|
+
expect(presentation.body).toContain('orders-api')
|
|
5919
|
+
expect(presentation.body).toMatch(/^Notifai setup is finished/)
|
|
5920
|
+
|
|
5921
|
+
// Still the same bar: a real send whose Companion Receipt was observed.
|
|
5922
|
+
expect(io.outLines.join('\n')).toContain(
|
|
5923
|
+
"Companion Receipt (the app's delivery confirmation) observed from iPhone.",
|
|
5924
|
+
)
|
|
5925
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5926
|
+
})
|
|
5927
|
+
|
|
5556
5928
|
it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
|
|
5557
5929
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
|
|
5558
5930
|
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
@@ -5591,7 +5963,12 @@ describe('init', () => {
|
|
|
5591
5963
|
mode: 'selected',
|
|
5592
5964
|
device_ids: [readyAndroid.device_id],
|
|
5593
5965
|
})
|
|
5594
|
-
|
|
5966
|
+
// Android has no caller-selected interruption level, so a configured Apple
|
|
5967
|
+
// preference must not reach it — and nothing else is stamped either, so the
|
|
5968
|
+
// server's kind sound applies rather than the old hard silence.
|
|
5969
|
+
expect(submitted?.draft.platform).toBeUndefined()
|
|
5970
|
+
expect(JSON.stringify(submitted?.draft)).not.toContain('interruption_level')
|
|
5971
|
+
expect(submitted?.draft.kind).toBe('done')
|
|
5595
5972
|
expect(io.outLines.join('\n')).toContain(
|
|
5596
5973
|
"Companion Receipt (the app's delivery confirmation) observed from Pixel.",
|
|
5597
5974
|
)
|
|
@@ -5623,7 +6000,7 @@ describe('init', () => {
|
|
|
5623
6000
|
expect(submitCalls).toBe(0)
|
|
5624
6001
|
const out = io.outLines.join('\n')
|
|
5625
6002
|
expect(out).toContain('Next: Your devices')
|
|
5626
|
-
expect(out).toContain('no active Companion
|
|
6003
|
+
expect(out).toContain('no active Companion App registered yet')
|
|
5627
6004
|
expect(out).not.toContain('All set.')
|
|
5628
6005
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
5629
6006
|
|
|
@@ -5640,13 +6017,159 @@ describe('init', () => {
|
|
|
5640
6017
|
),
|
|
5641
6018
|
).toBe(EXIT.failed)
|
|
5642
6019
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
5643
|
-
expect(doctorOut).toMatch(/FAIL\s+Your devices
|
|
5644
|
-
expect(doctorOut).toContain('no active Companion device registered yet')
|
|
6020
|
+
expect(doctorOut).toMatch(/FAIL\s+Your devices: no active Companion App registered yet/)
|
|
5645
6021
|
expect(doctorOut).toContain(
|
|
5646
6022
|
'Delivery proof: not checked — no iPhone or Android Companion App is ready',
|
|
5647
6023
|
)
|
|
5648
6024
|
})
|
|
5649
6025
|
|
|
6026
|
+
it('says access is already requested rather than asking for it again', async () => {
|
|
6027
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-requested-'))
|
|
6028
|
+
const io = new CapturedIo()
|
|
6029
|
+
const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
|
|
6030
|
+
const client = {
|
|
6031
|
+
health: async () => true,
|
|
6032
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6033
|
+
listDevices: async () => {
|
|
6034
|
+
throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
|
|
6035
|
+
},
|
|
6036
|
+
accessStatus: async () => ({
|
|
6037
|
+
status: 'no_active_plan',
|
|
6038
|
+
reason: 'no_active_grant',
|
|
6039
|
+
expires_at: null,
|
|
6040
|
+
email: 'waiting@example.test',
|
|
6041
|
+
public_v1_cutover: false,
|
|
6042
|
+
}),
|
|
6043
|
+
accessRequest: async () => ({
|
|
6044
|
+
request: {
|
|
6045
|
+
status: 'requested',
|
|
6046
|
+
requested_at: '2026-08-21T09:15:00.000Z',
|
|
6047
|
+
updated_at: '2026-08-21T09:15:00.000Z',
|
|
6048
|
+
},
|
|
6049
|
+
}),
|
|
6050
|
+
} as unknown as ApiClient
|
|
6051
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
6052
|
+
|
|
6053
|
+
const readiness = await assessReadiness(deps)
|
|
6054
|
+
const auth = readiness.states.find((state) => state.id === 'auth')
|
|
6055
|
+
// The middle value. Without it, someone who asked in August is told to ask
|
|
6056
|
+
// again on every run, forever, for a wait no command of theirs can shorten.
|
|
6057
|
+
expect(auth?.detail).toContain('access requested on 2026-08-21')
|
|
6058
|
+
expect(auth?.detail).toContain('waiting@example.test')
|
|
6059
|
+
expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere' })
|
|
6060
|
+
expect(JSON.stringify(auth)).not.toContain('/setup/access')
|
|
6061
|
+
|
|
6062
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
6063
|
+
const out = io.outLines.join('\n')
|
|
6064
|
+
expect(out).toContain('nothing is needed from you')
|
|
6065
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
6066
|
+
})
|
|
6067
|
+
|
|
6068
|
+
it('asks for access only when this account has never asked', async () => {
|
|
6069
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-access-never-asked-'))
|
|
6070
|
+
const io = new CapturedIo()
|
|
6071
|
+
const nextAction = 'Open https://app.notifai.sh/setup/access to set up access, then retry.'
|
|
6072
|
+
const client = {
|
|
6073
|
+
health: async () => true,
|
|
6074
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6075
|
+
listDevices: async () => {
|
|
6076
|
+
throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
|
|
6077
|
+
},
|
|
6078
|
+
accessStatus: async () => ({
|
|
6079
|
+
status: 'no_active_plan',
|
|
6080
|
+
reason: 'no_active_grant',
|
|
6081
|
+
expires_at: null,
|
|
6082
|
+
email: 'fresh@example.test',
|
|
6083
|
+
public_v1_cutover: false,
|
|
6084
|
+
}),
|
|
6085
|
+
accessRequest: async () => ({ request: null }),
|
|
6086
|
+
} as unknown as ApiClient
|
|
6087
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
6088
|
+
|
|
6089
|
+
const auth = (await assessReadiness(deps)).states.find((state) => state.id === 'auth')
|
|
6090
|
+
expect(auth?.detail).toContain('does not have access to Notifai yet')
|
|
6091
|
+
expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
|
|
6092
|
+
})
|
|
6093
|
+
|
|
6094
|
+
it('does not let a duplicate agent-skill install stand in front of the proof', async () => {
|
|
6095
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-'))
|
|
6096
|
+
const io = new CapturedIo()
|
|
6097
|
+
const calls = { submit: 0 }
|
|
6098
|
+
const nativeSkills: NativeSkills = {
|
|
6099
|
+
add: async () => 0,
|
|
6100
|
+
remove: async () => 0,
|
|
6101
|
+
// Both scopes installed: the harness lists two, which is a real problem
|
|
6102
|
+
// and a human decision — and still not a reason to withhold the proof.
|
|
6103
|
+
list: async (scope) => ({ skills: [managedSkill(scope ?? 'project', cwd)] }),
|
|
6104
|
+
}
|
|
6105
|
+
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
6106
|
+
|
|
6107
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
6108
|
+
const readiness = await assessReadiness(deps)
|
|
6109
|
+
expect(readiness.states.find((state) => state.id === 'skill')?.status).toBe('gap')
|
|
6110
|
+
expect(firstRequiredBlocker(readiness)).toBeNull()
|
|
6111
|
+
expect(calls.submit).toBe(1)
|
|
6112
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
6113
|
+
})
|
|
6114
|
+
|
|
6115
|
+
it('closes on the blocker that stopped the run, not the state before it', async () => {
|
|
6116
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-login-access-'))
|
|
6117
|
+
const io = new InteractiveIo()
|
|
6118
|
+
let now = 0
|
|
6119
|
+
const client = {
|
|
6120
|
+
health: async () => true,
|
|
6121
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6122
|
+
beginPairing: async () => ({
|
|
6123
|
+
pairing_id: 'pair_test',
|
|
6124
|
+
code: 'ABCD-EFGH',
|
|
6125
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
6126
|
+
expires_at: new Date(10_000).toISOString(),
|
|
6127
|
+
poll_interval_seconds: 1,
|
|
6128
|
+
}),
|
|
6129
|
+
pollPairing: async () => ({
|
|
6130
|
+
status: 'no_active_plan',
|
|
6131
|
+
next_action: 'Open https://app.notifai.sh/setup/access to set up access, then retry.',
|
|
6132
|
+
}),
|
|
6133
|
+
} as unknown as ApiClient
|
|
6134
|
+
const deps: CommandDeps = {
|
|
6135
|
+
...makeDeps(io, client),
|
|
6136
|
+
cwd,
|
|
6137
|
+
env: isolatedEnv(cwd),
|
|
6138
|
+
now: () => now,
|
|
6139
|
+
sleep: async (milliseconds: number) => {
|
|
6140
|
+
now += milliseconds
|
|
6141
|
+
},
|
|
6142
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
6143
|
+
}
|
|
6144
|
+
|
|
6145
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
6146
|
+
const out = io.outLines.join('\n')
|
|
6147
|
+
// One next step, and it is the one that actually stopped the run. The
|
|
6148
|
+
// pairing state that preceded it would send the reader straight back into
|
|
6149
|
+
// the command that just failed, for a reason it never names.
|
|
6150
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
6151
|
+
expect(out).toContain('Next: Access')
|
|
6152
|
+
expect(out).toContain('https://app.notifai.sh/setup/access')
|
|
6153
|
+
expect(out).not.toContain('not paired with your account')
|
|
6154
|
+
|
|
6155
|
+
// …and the wall is stated once across the whole visit. It used to arrive
|
|
6156
|
+
// three ways — a lowercase `next:` from the pairing failure, an "after
|
|
6157
|
+
// access … run `notifai init`" remedy, and this close — so a stopped User
|
|
6158
|
+
// had to work out which of three destinations-and-commands was theirs.
|
|
6159
|
+
const said = [
|
|
6160
|
+
...io.outLines,
|
|
6161
|
+
...io.errLines,
|
|
6162
|
+
...io.notes.map((note) => note.message),
|
|
6163
|
+
...io.spinnerEvents,
|
|
6164
|
+
].join('\n')
|
|
6165
|
+
expect(said.match(/setup\/access/g)).toHaveLength(1)
|
|
6166
|
+
expect(said.match(/does not have access/g)).toHaveLength(1)
|
|
6167
|
+
expect(said).not.toContain('After access is granted')
|
|
6168
|
+
expect(said).not.toContain('next: ')
|
|
6169
|
+
// Rerunning is how they resume, not how they get access.
|
|
6170
|
+
expect(out).toContain('Then re-run `notifai init` and it will pick up from here.')
|
|
6171
|
+
})
|
|
6172
|
+
|
|
5650
6173
|
it('treats a revoked credential as the one blocker and points back to pairing', async () => {
|
|
5651
6174
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
|
|
5652
6175
|
const io = new CapturedIo()
|
|
@@ -5667,8 +6190,86 @@ describe('init', () => {
|
|
|
5667
6190
|
const out = io.outLines.join('\n')
|
|
5668
6191
|
expect(out).toContain('Next: Account')
|
|
5669
6192
|
expect(out).toContain('pair it again')
|
|
5670
|
-
expect(out).toContain('notifai
|
|
6193
|
+
expect(out).toContain('notifai init')
|
|
6194
|
+
expect(out).not.toContain('notifai login')
|
|
6195
|
+
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
6196
|
+
})
|
|
6197
|
+
|
|
6198
|
+
it('names the support next action when a paired account has no active plan', async () => {
|
|
6199
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-'))
|
|
6200
|
+
const io = new CapturedIo()
|
|
6201
|
+
const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
|
|
6202
|
+
const client = {
|
|
6203
|
+
health: async () => true,
|
|
6204
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
6205
|
+
listDevices: async () => {
|
|
6206
|
+
throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
|
|
6207
|
+
},
|
|
6208
|
+
accessStatus: async () => ({
|
|
6209
|
+
status: 'no_active_plan',
|
|
6210
|
+
reason: 'no_active_grant',
|
|
6211
|
+
expires_at: null,
|
|
6212
|
+
email: 'rafael@example.test',
|
|
6213
|
+
}),
|
|
6214
|
+
} as unknown as ApiClient
|
|
6215
|
+
const deps = {
|
|
6216
|
+
...makeDeps(io, client),
|
|
6217
|
+
cwd,
|
|
6218
|
+
env: isolatedEnv(cwd),
|
|
6219
|
+
}
|
|
6220
|
+
|
|
6221
|
+
const readiness = await assessReadiness(deps)
|
|
6222
|
+
const auth = readiness.states.find((state) => state.id === 'auth')
|
|
6223
|
+
expect(auth).toMatchObject({
|
|
6224
|
+
status: 'gap',
|
|
6225
|
+
detail: 'this account does not have access to Notifai yet (rafael@example.test)',
|
|
6226
|
+
remedy: { by: 'user-elsewhere', summary: nextAction },
|
|
6227
|
+
})
|
|
6228
|
+
expect(auth?.remedy && 'command' in auth.remedy ? auth.remedy.command : undefined).toBeUndefined()
|
|
6229
|
+
|
|
6230
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
6231
|
+
const out = io.outLines.join('\n')
|
|
6232
|
+
expect(out).toContain('Next: Account')
|
|
6233
|
+
expect(out).toContain(nextAction)
|
|
6234
|
+
expect(out).not.toContain('pair it again')
|
|
6235
|
+
expect(out).not.toContain('notifai login')
|
|
5671
6236
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
6237
|
+
|
|
6238
|
+
const doctorIo = new CapturedIo()
|
|
6239
|
+
expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.failed)
|
|
6240
|
+
const payload = JSON.parse(doctorIo.outLines[0] ?? '{}') as {
|
|
6241
|
+
states: Array<{ id: string; remedy?: { summary?: string; command?: string } }>
|
|
6242
|
+
}
|
|
6243
|
+
const doctorAuth = payload.states.find((state) => state.id === 'auth')
|
|
6244
|
+
expect(doctorAuth?.remedy?.summary).toBe(nextAction)
|
|
6245
|
+
expect(doctorAuth?.remedy?.command).toBeUndefined()
|
|
6246
|
+
})
|
|
6247
|
+
|
|
6248
|
+
it('does not treat a listDevices no-plan 403 as a revoked machine when accessStatus fails', async () => {
|
|
6249
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-no-plan-devices-only-'))
|
|
6250
|
+
const io = new CapturedIo()
|
|
6251
|
+
const nextAction = 'Open https://app.notifai.sh/support to request Alpha access, then retry.'
|
|
6252
|
+
const client = {
|
|
6253
|
+
health: async () => true,
|
|
6254
|
+
listDevices: async () => {
|
|
6255
|
+
throw new ApiCallError(403, 'no_active_plan', 'No active plan.', nextAction)
|
|
6256
|
+
},
|
|
6257
|
+
accessStatus: async () => {
|
|
6258
|
+
throw new ApiCallError(500, 'internal_error', 'access status unavailable')
|
|
6259
|
+
},
|
|
6260
|
+
} as unknown as ApiClient
|
|
6261
|
+
const deps = {
|
|
6262
|
+
...makeDeps(io, client),
|
|
6263
|
+
cwd,
|
|
6264
|
+
env: isolatedEnv(cwd),
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
const readiness = await assessReadiness(deps)
|
|
6268
|
+
const auth = readiness.states.find((state) => state.id === 'auth')
|
|
6269
|
+
expect(auth?.status).toBe('gap')
|
|
6270
|
+
expect(auth?.detail).toContain('does not have access to Notifai yet')
|
|
6271
|
+
expect(auth?.remedy).toMatchObject({ by: 'user-elsewhere', summary: nextAction })
|
|
6272
|
+
expect(JSON.stringify(auth)).not.toContain('pair it again')
|
|
5672
6273
|
})
|
|
5673
6274
|
|
|
5674
6275
|
it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
|
|
@@ -6134,7 +6735,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6134
6735
|
io.outLines = []
|
|
6135
6736
|
|
|
6136
6737
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
6137
|
-
expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
|
|
6738
|
+
expect(io.outLines.some((line) => line.startsWith('Question registered locally (q_'))).toBe(true)
|
|
6138
6739
|
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
6139
6740
|
session_id: 'codex-current-thread',
|
|
6140
6741
|
harness: 'codex',
|
|
@@ -6142,6 +6743,28 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6142
6743
|
expect(
|
|
6143
6744
|
readSessionState('codex-current-thread', env).pending?.[0]?.source?.session_label,
|
|
6144
6745
|
).toBe('Gentle Salmon')
|
|
6746
|
+
|
|
6747
|
+
io.outLines = []
|
|
6748
|
+
expect(askCommand(deps, 'Wait?', { json: true })).toBe(EXIT.ok)
|
|
6749
|
+
const registered = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
6750
|
+
expect(registered).toMatchObject({
|
|
6751
|
+
registered: true,
|
|
6752
|
+
state: 'local',
|
|
6753
|
+
submitted: false,
|
|
6754
|
+
request_id: null,
|
|
6755
|
+
provider_acceptance: 'not_available',
|
|
6756
|
+
status: `notifai status ${registered.question_id as string}`,
|
|
6757
|
+
close: `notifai close ${registered.question_id as string}`,
|
|
6758
|
+
})
|
|
6759
|
+
expect(inspectQuestionState(registered.question_id as string, env)).toMatchObject({
|
|
6760
|
+
found: true,
|
|
6761
|
+
question: {
|
|
6762
|
+
question_id: registered.question_id,
|
|
6763
|
+
state: 'local',
|
|
6764
|
+
submitted: false,
|
|
6765
|
+
request_id: null,
|
|
6766
|
+
},
|
|
6767
|
+
})
|
|
6145
6768
|
})
|
|
6146
6769
|
|
|
6147
6770
|
it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
|
|
@@ -6678,8 +7301,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6678
7301
|
|
|
6679
7302
|
const readiness = await assessReadiness(deps)
|
|
6680
7303
|
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
6681
|
-
|
|
7304
|
+
// Reported, never a blocker: the only thing that closes this is a turn
|
|
7305
|
+
// ending, which the agent standing inside that turn cannot supply.
|
|
7306
|
+
expect(fired?.status).toBe('optional-gap')
|
|
6682
7307
|
expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
|
|
7308
|
+
expect(firstRequiredBlocker(readiness)?.id).not.toBe('hooks-fired')
|
|
6683
7309
|
})
|
|
6684
7310
|
|
|
6685
7311
|
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
@@ -6723,6 +7349,28 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6723
7349
|
expect(readSessionState('opencode-current', env).pending).toBeUndefined()
|
|
6724
7350
|
})
|
|
6725
7351
|
|
|
7352
|
+
it('rejects OpenClaw before registration even with an exact matching pointer', () => {
|
|
7353
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-openclaw-unsupported-'))
|
|
7354
|
+
const io = new CapturedIo()
|
|
7355
|
+
const env = {
|
|
7356
|
+
HOME: path.join(cwd, 'home'),
|
|
7357
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
7358
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
7359
|
+
OPENCLAW_STATE_DIR: path.join(cwd, 'openclaw-home'),
|
|
7360
|
+
NOTIFAI_ACTIVE_HARNESS: 'openclaw',
|
|
7361
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'agent:main:main',
|
|
7362
|
+
}
|
|
7363
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
7364
|
+
expect(hooksInstallCommand(deps, { harness: 'openclaw', execPath, scriptPath })).toBe(EXIT.ok)
|
|
7365
|
+
writeSessionState('agent:main:main', env, { harness: 'openclaw', last_prompt_at: 42, last_stop_at: 41 })
|
|
7366
|
+
writeProjectSession(cwd, env, 'agent:main:main', 42, 'openclaw')
|
|
7367
|
+
io.outLines = []
|
|
7368
|
+
|
|
7369
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
7370
|
+
expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
|
|
7371
|
+
expect(readSessionState('agent:main:main', env).pending).toBeUndefined()
|
|
7372
|
+
})
|
|
7373
|
+
|
|
6726
7374
|
it('refuses Hermes ask as unsupported rather than as missing hooks', () => {
|
|
6727
7375
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-ask-unsupported-'))
|
|
6728
7376
|
const io = new CapturedIo()
|
|
@@ -7231,7 +7879,7 @@ describe('a server behind this CLI', () => {
|
|
|
7231
7879
|
|
|
7232
7880
|
const said = io.outLines.concat(io.errLines).join(' ')
|
|
7233
7881
|
expect(said).toMatch(/service is being updated/)
|
|
7234
|
-
expect(said).not.toContain('
|
|
7882
|
+
expect(said).not.toContain('notifai update')
|
|
7235
7883
|
expect(said).not.toMatch(/Protocol version|schema v/i)
|
|
7236
7884
|
|
|
7237
7885
|
const sendIo = new CapturedIo()
|