@raidiant/notifai 0.2.1 → 0.4.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/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/commands.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import type {
|
|
5
6
|
CapabilityDocument,
|
|
6
7
|
EvidenceSnapshot,
|
|
@@ -17,7 +18,10 @@ import {
|
|
|
17
18
|
buildQuestions,
|
|
18
19
|
assessReadiness,
|
|
19
20
|
capabilitiesCommand,
|
|
21
|
+
configExplainCommand,
|
|
20
22
|
configSetCommand,
|
|
23
|
+
configShowCommand,
|
|
24
|
+
configUnsetCommand,
|
|
21
25
|
contradictingAnswer,
|
|
22
26
|
describeHookFailure,
|
|
23
27
|
doctorCommand,
|
|
@@ -27,6 +31,8 @@ import {
|
|
|
27
31
|
initCommand,
|
|
28
32
|
SKILLS_SOURCE,
|
|
29
33
|
loginCommand,
|
|
34
|
+
logsCommand,
|
|
35
|
+
parseSince,
|
|
30
36
|
projectSlugFrom,
|
|
31
37
|
repliesCommand,
|
|
32
38
|
sendCommand,
|
|
@@ -35,9 +41,19 @@ import {
|
|
|
35
41
|
type CommandIo,
|
|
36
42
|
type CommandSpinner,
|
|
37
43
|
} from './commands.js'
|
|
38
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
applyPlan,
|
|
46
|
+
buildHookConfig,
|
|
47
|
+
codexHookIdentityHash,
|
|
48
|
+
codexTrustKey,
|
|
49
|
+
findInstallations,
|
|
50
|
+
} from './install-hooks.js'
|
|
39
51
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
40
52
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
53
|
+
import { CONFIG_KEYS, loadConfig } from './config.js'
|
|
54
|
+
import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
55
|
+
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
56
|
+
import type { Tone } from './ui/theme.js'
|
|
41
57
|
|
|
42
58
|
class CapturedIo implements CommandIo {
|
|
43
59
|
outLines: string[] = []
|
|
@@ -72,7 +88,7 @@ class InteractiveIo extends CapturedIo {
|
|
|
72
88
|
intros: string[] = []
|
|
73
89
|
outros: string[] = []
|
|
74
90
|
spinnerEvents: string[] = []
|
|
75
|
-
checks: { ok: boolean; message: string }[] = []
|
|
91
|
+
checks: { ok: boolean; message: string; tone?: Tone }[] = []
|
|
76
92
|
|
|
77
93
|
override async confirm(question: string) {
|
|
78
94
|
this.prompts.push(question)
|
|
@@ -111,12 +127,28 @@ class InteractiveIo extends CapturedIo {
|
|
|
111
127
|
}
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
async check(ok: boolean, message: string) {
|
|
115
|
-
this.checks.push({ ok, message })
|
|
130
|
+
async check(ok: boolean, message: string, tone?: Tone) {
|
|
131
|
+
this.checks.push({ ok, message, ...(tone === undefined ? {} : { tone }) })
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
|
|
135
|
+
function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
|
|
136
|
+
const installations = findInstallations(cwd, env).filter(
|
|
137
|
+
(installation) => installation.harness === 'codex',
|
|
138
|
+
)
|
|
139
|
+
const sections = installations.flatMap((installation) =>
|
|
140
|
+
installation.handlers.map((handler) => {
|
|
141
|
+
const key = codexTrustKey(installation, handler)
|
|
142
|
+
return `[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(codexHookIdentityHash(handler))}\n`
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
const codexHome = env['CODEX_HOME'] as string
|
|
146
|
+
mkdirSync(codexHome, { recursive: true })
|
|
147
|
+
writeFileSync(path.join(codexHome, 'config.toml'), sections.join('\n'))
|
|
148
|
+
}
|
|
149
|
+
|
|
119
150
|
function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
151
|
+
const testRoot = path.join(os.tmpdir(), 'notifai-cli-command-tests')
|
|
120
152
|
return {
|
|
121
153
|
io,
|
|
122
154
|
store: {
|
|
@@ -130,7 +162,11 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
130
162
|
clear: () => {},
|
|
131
163
|
describe: () => 'test credential store',
|
|
132
164
|
},
|
|
133
|
-
env: {
|
|
165
|
+
env: {
|
|
166
|
+
XDG_CONFIG_HOME: testRoot,
|
|
167
|
+
XDG_STATE_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests-state'),
|
|
168
|
+
},
|
|
169
|
+
hookAdapterHome: path.join(testRoot, 'home'),
|
|
134
170
|
cwd: os.tmpdir(),
|
|
135
171
|
clientFactory: () => client,
|
|
136
172
|
}
|
|
@@ -490,13 +526,19 @@ describe('command contracts', () => {
|
|
|
490
526
|
return replyResponse(polls.length === 3 ? [reply] : [])
|
|
491
527
|
},
|
|
492
528
|
} as unknown as ApiClient
|
|
529
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-direct-reply-log-'))
|
|
493
530
|
const deps = {
|
|
494
531
|
...makeDeps(io, client),
|
|
532
|
+
env: {
|
|
533
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
534
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
535
|
+
},
|
|
495
536
|
now: () => now,
|
|
496
537
|
sleep: async (milliseconds: number) => {
|
|
497
538
|
now += milliseconds
|
|
498
539
|
},
|
|
499
540
|
}
|
|
541
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
500
542
|
|
|
501
543
|
expect(
|
|
502
544
|
await sendCommand(deps, {
|
|
@@ -509,6 +551,14 @@ describe('command contracts', () => {
|
|
|
509
551
|
expect(polls).toHaveLength(3)
|
|
510
552
|
expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
|
|
511
553
|
expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
|
|
554
|
+
const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
|
|
555
|
+
expect(received).toHaveLength(1)
|
|
556
|
+
expect(received[0]?.data).toMatchObject({
|
|
557
|
+
request_id: receipt.request_id,
|
|
558
|
+
sequence: reply.seq,
|
|
559
|
+
device: reply.device_name,
|
|
560
|
+
text: reply.text,
|
|
561
|
+
})
|
|
512
562
|
})
|
|
513
563
|
|
|
514
564
|
it('backs off and retries a transient network error while waiting', async () => {
|
|
@@ -545,6 +595,97 @@ describe('command contracts', () => {
|
|
|
545
595
|
expect(sleeps).toEqual([250])
|
|
546
596
|
})
|
|
547
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Regression context: Provider Acceptance and Companion Receipt succeeded,
|
|
600
|
+
* then a long-poll returned HTTP 500 internal_error and the blocking wait aborted
|
|
601
|
+
* while the reply window was still open. The answer arrived ~1 minute later
|
|
602
|
+
* and was recovered only on the next turn. Transient server faults must not
|
|
603
|
+
* end the wait; permanent client errors still may.
|
|
604
|
+
*/
|
|
605
|
+
it('keeps blocking through a transient internal_error on the replies poll', async () => {
|
|
606
|
+
const io = new CapturedIo()
|
|
607
|
+
let now = 0
|
|
608
|
+
let replyCalls = 0
|
|
609
|
+
const client = {
|
|
610
|
+
submit: async () => receipt,
|
|
611
|
+
replies: async () => {
|
|
612
|
+
replyCalls += 1
|
|
613
|
+
if (replyCalls <= 2) {
|
|
614
|
+
throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
|
|
615
|
+
}
|
|
616
|
+
return replyResponse([reply])
|
|
617
|
+
},
|
|
618
|
+
} as unknown as ApiClient
|
|
619
|
+
const deps = {
|
|
620
|
+
...makeDeps(io, client),
|
|
621
|
+
now: () => now,
|
|
622
|
+
sleep: async (milliseconds: number) => {
|
|
623
|
+
now += milliseconds
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
expect(
|
|
628
|
+
await sendCommand(deps, {
|
|
629
|
+
title: 'Question',
|
|
630
|
+
body: 'Deploy?',
|
|
631
|
+
reply: true,
|
|
632
|
+
replyTimeout: 30,
|
|
633
|
+
}),
|
|
634
|
+
).toBe(EXIT.ok)
|
|
635
|
+
expect(replyCalls).toBe(3)
|
|
636
|
+
expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
|
|
637
|
+
expect(io.errLines.join('\n')).not.toContain('internal_error')
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('does not treat a permanent replies error as a transient wait fault', async () => {
|
|
641
|
+
const io = new CapturedIo()
|
|
642
|
+
let now = 0
|
|
643
|
+
let replyCalls = 0
|
|
644
|
+
const client = {
|
|
645
|
+
submit: async () => receipt,
|
|
646
|
+
replies: async () => {
|
|
647
|
+
replyCalls += 1
|
|
648
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
649
|
+
},
|
|
650
|
+
} as unknown as ApiClient
|
|
651
|
+
const deps: CommandDeps = {
|
|
652
|
+
...makeDeps(io, client),
|
|
653
|
+
now: () => now,
|
|
654
|
+
sleep: async (milliseconds: number) => {
|
|
655
|
+
now += milliseconds
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
deps.env = {
|
|
659
|
+
...deps.env,
|
|
660
|
+
XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-reply-wait-log-')),
|
|
661
|
+
}
|
|
662
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
663
|
+
|
|
664
|
+
expect(
|
|
665
|
+
await sendCommand(deps, {
|
|
666
|
+
title: 'Question',
|
|
667
|
+
body: 'Deploy?',
|
|
668
|
+
reply: true,
|
|
669
|
+
replyTimeout: 30,
|
|
670
|
+
}),
|
|
671
|
+
).toBe(EXIT.failed)
|
|
672
|
+
expect(replyCalls).toBe(1)
|
|
673
|
+
expect(io.errLines.join('\n')).toContain('reply wait failed')
|
|
674
|
+
expect(io.errLines.join('\n')).toContain(receipt.request_id)
|
|
675
|
+
expect(io.errLines.join('\n')).toContain('not_found')
|
|
676
|
+
// Receipt was already printed — the durable send is not the failure.
|
|
677
|
+
expect(io.outLines[0]).toContain(receipt.request_id)
|
|
678
|
+
const waitError = readLogRecords(deps.env, { event: ['cli.error'], limit: 20 }).records.find(
|
|
679
|
+
(record) => record.data?.['operation'] === 'reply_wait',
|
|
680
|
+
)
|
|
681
|
+
expect(waitError?.data).toMatchObject({
|
|
682
|
+
kind: 'api',
|
|
683
|
+
request_id: receipt.request_id,
|
|
684
|
+
status: 404,
|
|
685
|
+
code: 'not_found',
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
548
689
|
it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
|
|
549
690
|
const io = new CapturedIo()
|
|
550
691
|
let now = 0
|
|
@@ -615,10 +756,18 @@ describe('command contracts', () => {
|
|
|
615
756
|
return replyResponse([reply])
|
|
616
757
|
},
|
|
617
758
|
} as unknown as ApiClient
|
|
759
|
+
const deps = makeDeps(io, client)
|
|
760
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-replies-log-'))
|
|
761
|
+
deps.env = {
|
|
762
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
763
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
764
|
+
}
|
|
765
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
618
766
|
|
|
619
|
-
expect(await repliesCommand(
|
|
767
|
+
expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
|
|
620
768
|
expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
|
|
621
769
|
expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
|
|
770
|
+
expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
|
|
622
771
|
})
|
|
623
772
|
|
|
624
773
|
it('resolves replies --pending through the project session pointer and prints its id', async () => {
|
|
@@ -674,6 +823,198 @@ describe('command contracts', () => {
|
|
|
674
823
|
expect(io.outLines[1]).toContain('yes, after the migration')
|
|
675
824
|
expect(io.outLines.join('\n')).toContain('req_second')
|
|
676
825
|
})
|
|
826
|
+
|
|
827
|
+
it('keeps the failing request identity when one pending reply wait throws', async () => {
|
|
828
|
+
const io = new CapturedIo()
|
|
829
|
+
const client = {
|
|
830
|
+
replies: async (requestId: string) => {
|
|
831
|
+
if (requestId === 'req_failed') {
|
|
832
|
+
throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
|
|
833
|
+
}
|
|
834
|
+
return replyResponse([reply])
|
|
835
|
+
},
|
|
836
|
+
} as unknown as ApiClient
|
|
837
|
+
const deps = makeDeps(io, client)
|
|
838
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-error-log-'))
|
|
839
|
+
deps.cwd = root
|
|
840
|
+
deps.env = {
|
|
841
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
842
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
843
|
+
}
|
|
844
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
845
|
+
writeSessionState('pending-error-log', deps.env, {
|
|
846
|
+
pending: [
|
|
847
|
+
{ question: 'First?', request_id: 'req_healthy' },
|
|
848
|
+
{ question: 'Second?', request_id: 'req_failed' },
|
|
849
|
+
{ question: 'Third?', request_id: 'req_unreached' },
|
|
850
|
+
],
|
|
851
|
+
})
|
|
852
|
+
writeProjectSession(root, deps.env, 'pending-error-log', Date.now(), 'codex')
|
|
853
|
+
|
|
854
|
+
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.failed)
|
|
855
|
+
|
|
856
|
+
io.outLines = []
|
|
857
|
+
io.errLines = []
|
|
858
|
+
expect(logsCommand(deps, { request: 'req_failed', json: true, allProjects: true })).toBe(
|
|
859
|
+
EXIT.ok,
|
|
860
|
+
)
|
|
861
|
+
expect(io.outLines).toHaveLength(1)
|
|
862
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
863
|
+
event: 'cli.error',
|
|
864
|
+
data: { request_id: 'req_failed', operation: 'reply_wait' },
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
io.outLines = []
|
|
868
|
+
expect(logsCommand(deps, { request: 'req_healthy', json: true, allProjects: true })).toBe(
|
|
869
|
+
EXIT.ok,
|
|
870
|
+
)
|
|
871
|
+
expect(io.outLines).toHaveLength(1)
|
|
872
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
873
|
+
event: 'reply.received',
|
|
874
|
+
data: { request_id: 'req_healthy' },
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
io.outLines = []
|
|
878
|
+
expect(logsCommand(deps, { request: 'req_unreached', json: true, allProjects: true })).toBe(
|
|
879
|
+
EXIT.ok,
|
|
880
|
+
)
|
|
881
|
+
expect(io.outLines).toEqual([])
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('attributes a degraded multi-request wait only to the request whose polls failed', async () => {
|
|
885
|
+
const io = new CapturedIo()
|
|
886
|
+
let now = 0
|
|
887
|
+
let failedPolls = 0
|
|
888
|
+
const client = {
|
|
889
|
+
replies: async (requestId: string) => {
|
|
890
|
+
if (requestId !== 'req_degraded') return replyResponse([reply])
|
|
891
|
+
failedPolls += 1
|
|
892
|
+
if (failedPolls === 1) return replyResponse([])
|
|
893
|
+
throw new NetworkError('temporary disconnect')
|
|
894
|
+
},
|
|
895
|
+
} as unknown as ApiClient
|
|
896
|
+
const deps = makeDeps(io, client)
|
|
897
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-degraded-log-'))
|
|
898
|
+
deps.cwd = root
|
|
899
|
+
deps.env = {
|
|
900
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
901
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
902
|
+
}
|
|
903
|
+
deps.now = () => now
|
|
904
|
+
deps.sleep = async (milliseconds: number) => {
|
|
905
|
+
now += milliseconds
|
|
906
|
+
}
|
|
907
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
908
|
+
writeSessionState('pending-degraded-log', deps.env, {
|
|
909
|
+
pending: [
|
|
910
|
+
{ question: 'First?', request_id: 'req_healthy_before' },
|
|
911
|
+
{ question: 'Second?', request_id: 'req_degraded' },
|
|
912
|
+
{ question: 'Third?', request_id: 'req_healthy_after' },
|
|
913
|
+
],
|
|
914
|
+
})
|
|
915
|
+
writeProjectSession(root, deps.env, 'pending-degraded-log', Date.now(), 'codex')
|
|
916
|
+
|
|
917
|
+
expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.network)
|
|
918
|
+
|
|
919
|
+
io.outLines = []
|
|
920
|
+
io.errLines = []
|
|
921
|
+
expect(logsCommand(deps, { request: 'req_degraded', json: true, allProjects: true })).toBe(
|
|
922
|
+
EXIT.ok,
|
|
923
|
+
)
|
|
924
|
+
expect(io.outLines).toHaveLength(1)
|
|
925
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
926
|
+
event: 'cli.error',
|
|
927
|
+
data: { request_ids: ['req_degraded'], operation: 'reply_wait' },
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
for (const requestId of ['req_healthy_before', 'req_healthy_after']) {
|
|
931
|
+
io.outLines = []
|
|
932
|
+
expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
|
|
933
|
+
EXIT.ok,
|
|
934
|
+
)
|
|
935
|
+
expect(io.outLines).toHaveLength(1)
|
|
936
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
937
|
+
event: 'reply.received',
|
|
938
|
+
data: { request_id: requestId },
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
it('records earlier degraded requests before a later pending wait throws', async () => {
|
|
944
|
+
const io = new CapturedIo()
|
|
945
|
+
let now = 0
|
|
946
|
+
let degradedPolls = 0
|
|
947
|
+
const client = {
|
|
948
|
+
replies: async (requestId: string) => {
|
|
949
|
+
if (requestId === 'req_success') return replyResponse([reply])
|
|
950
|
+
if (requestId === 'req_degraded') {
|
|
951
|
+
degradedPolls += 1
|
|
952
|
+
if (degradedPolls === 1) return replyResponse([])
|
|
953
|
+
throw new NetworkError('temporary disconnect')
|
|
954
|
+
}
|
|
955
|
+
if (requestId === 'req_failed') {
|
|
956
|
+
throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
|
|
957
|
+
}
|
|
958
|
+
return replyResponse([reply])
|
|
959
|
+
},
|
|
960
|
+
} as unknown as ApiClient
|
|
961
|
+
const deps = makeDeps(io, client)
|
|
962
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-mixed-log-'))
|
|
963
|
+
deps.cwd = root
|
|
964
|
+
deps.env = {
|
|
965
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
966
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
967
|
+
}
|
|
968
|
+
deps.now = () => now
|
|
969
|
+
deps.sleep = async (milliseconds: number) => {
|
|
970
|
+
now += milliseconds
|
|
971
|
+
}
|
|
972
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
973
|
+
writeSessionState('pending-mixed-log', deps.env, {
|
|
974
|
+
pending: [
|
|
975
|
+
{ question: 'Healthy?', request_id: 'req_success' },
|
|
976
|
+
{ question: 'Degraded?', request_id: 'req_degraded' },
|
|
977
|
+
{ question: 'Gone?', request_id: 'req_failed' },
|
|
978
|
+
{ question: 'Unreached?', request_id: 'req_unreached' },
|
|
979
|
+
],
|
|
980
|
+
})
|
|
981
|
+
writeProjectSession(root, deps.env, 'pending-mixed-log', Date.now(), 'codex')
|
|
982
|
+
|
|
983
|
+
expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.failed)
|
|
984
|
+
|
|
985
|
+
const recordsFor = (requestId: string): Array<Record<string, unknown>> => {
|
|
986
|
+
io.outLines = []
|
|
987
|
+
io.errLines = []
|
|
988
|
+
expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
|
|
989
|
+
EXIT.ok,
|
|
990
|
+
)
|
|
991
|
+
return io.outLines.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
expect(recordsFor('req_success')).toEqual([
|
|
995
|
+
expect.objectContaining({
|
|
996
|
+
event: 'reply.received',
|
|
997
|
+
data: expect.objectContaining({ request_id: 'req_success' }),
|
|
998
|
+
}),
|
|
999
|
+
])
|
|
1000
|
+
expect(recordsFor('req_degraded')).toEqual([
|
|
1001
|
+
expect.objectContaining({
|
|
1002
|
+
event: 'cli.error',
|
|
1003
|
+
data: expect.objectContaining({
|
|
1004
|
+
request_ids: ['req_degraded'],
|
|
1005
|
+
operation: 'reply_wait',
|
|
1006
|
+
degraded: true,
|
|
1007
|
+
}),
|
|
1008
|
+
}),
|
|
1009
|
+
])
|
|
1010
|
+
expect(recordsFor('req_failed')).toEqual([
|
|
1011
|
+
expect.objectContaining({
|
|
1012
|
+
event: 'cli.error',
|
|
1013
|
+
data: expect.objectContaining({ request_id: 'req_failed', operation: 'reply_wait' }),
|
|
1014
|
+
}),
|
|
1015
|
+
])
|
|
1016
|
+
expect(recordsFor('req_unreached')).toEqual([])
|
|
1017
|
+
})
|
|
677
1018
|
})
|
|
678
1019
|
|
|
679
1020
|
describe('delivery evidence status', () => {
|
|
@@ -730,6 +1071,8 @@ describe('delivery evidence status', () => {
|
|
|
730
1071
|
expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
|
|
731
1072
|
expect(said).toContain('not a failure')
|
|
732
1073
|
expect(said).toContain('attempt_started')
|
|
1074
|
+
expect(said).toContain('OS presentation: not observed by Notifai')
|
|
1075
|
+
expect(said).toContain('Reply wait:')
|
|
733
1076
|
})
|
|
734
1077
|
|
|
735
1078
|
it('reports the observed device receipt and measured provider-to-companion latency', async () => {
|
|
@@ -747,12 +1090,40 @@ describe('delivery evidence status', () => {
|
|
|
747
1090
|
const said = io.outLines.join('\n')
|
|
748
1091
|
expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
|
|
749
1092
|
expect(said).toContain('11m 27s after Provider Acceptance')
|
|
1093
|
+
expect(said).toContain('OS presentation: not observed by Notifai')
|
|
1094
|
+
expect(said).toContain('Reply received: not yet recorded')
|
|
1095
|
+
expect(said).toContain('Reply wait: no answer stored yet')
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('separates a stored reply from Delivery so a wait fault is not misread as non-delivery', async () => {
|
|
1099
|
+
const io = new CapturedIo()
|
|
1100
|
+
const base = snapshot({
|
|
1101
|
+
state: 'observed',
|
|
1102
|
+
observed_at: '2026-08-05T13:17:17.000Z',
|
|
1103
|
+
latency_ms: 1_000,
|
|
1104
|
+
})
|
|
1105
|
+
base.deliveries[0]!.events.push({
|
|
1106
|
+
stage: 'reply_received',
|
|
1107
|
+
source: 'companion',
|
|
1108
|
+
reason: null,
|
|
1109
|
+
attempt: null,
|
|
1110
|
+
occurred_at: '2026-08-05T13:18:00.000Z',
|
|
1111
|
+
})
|
|
1112
|
+
const client = { evidence: async () => base } as unknown as ApiClient
|
|
1113
|
+
|
|
1114
|
+
expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
|
|
1115
|
+
const said = io.outLines.join('\n')
|
|
1116
|
+
expect(said).toContain('Provider Acceptance: accepted')
|
|
1117
|
+
expect(said).toContain('Companion Receipt')
|
|
1118
|
+
expect(said).toContain('OS presentation: not observed by Notifai')
|
|
1119
|
+
expect(said).toContain('Reply received: yes')
|
|
1120
|
+
expect(said).toContain('answers are on the server')
|
|
750
1121
|
})
|
|
751
1122
|
})
|
|
752
1123
|
|
|
753
1124
|
describe('Cursor hook commands', () => {
|
|
754
|
-
const execPath =
|
|
755
|
-
const scriptPath =
|
|
1125
|
+
const execPath = process.execPath
|
|
1126
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
756
1127
|
|
|
757
1128
|
it('installs native Cursor hooks with bounded chained answer continuations', () => {
|
|
758
1129
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
|
|
@@ -846,8 +1217,8 @@ describe('Cursor hook commands', () => {
|
|
|
846
1217
|
})
|
|
847
1218
|
|
|
848
1219
|
describe('harness activation guidance', () => {
|
|
849
|
-
const execPath =
|
|
850
|
-
const scriptPath =
|
|
1220
|
+
const execPath = process.execPath
|
|
1221
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
851
1222
|
|
|
852
1223
|
it('does not require a Claude Code restart for project hook files', () => {
|
|
853
1224
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
|
|
@@ -877,7 +1248,7 @@ describe('harness activation guidance', () => {
|
|
|
877
1248
|
expect(output).not.toMatch(/trust|approve/i)
|
|
878
1249
|
})
|
|
879
1250
|
|
|
880
|
-
it('keeps OpenCode permission prompts local and reports
|
|
1251
|
+
it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
|
|
881
1252
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
|
|
882
1253
|
const io = new CapturedIo()
|
|
883
1254
|
const client = {
|
|
@@ -885,21 +1256,33 @@ describe('harness activation guidance', () => {
|
|
|
885
1256
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
886
1257
|
listDevices: async () => ({ devices: [] }),
|
|
887
1258
|
} as unknown as ApiClient
|
|
888
|
-
const
|
|
1259
|
+
const base = makeDeps(io, client)
|
|
1260
|
+
const deps = {
|
|
1261
|
+
...base,
|
|
1262
|
+
cwd,
|
|
1263
|
+
env: {
|
|
1264
|
+
...base.env,
|
|
1265
|
+
HOME: path.join(cwd, 'home'),
|
|
1266
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
1267
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
1268
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
1269
|
+
},
|
|
1270
|
+
}
|
|
889
1271
|
|
|
890
1272
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
|
|
891
1273
|
EXIT.ok,
|
|
892
1274
|
)
|
|
893
1275
|
|
|
894
1276
|
expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
|
|
895
|
-
expect(io.outLines.join('\n')).toContain('
|
|
1277
|
+
expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
|
|
896
1278
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
897
1279
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
898
1280
|
expect(plugin).toContain('const TIMEOUT_MS = 540000')
|
|
899
1281
|
|
|
900
1282
|
io.outLines = []
|
|
901
1283
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
902
|
-
expect(io.outLines.join('\n')).toContain('hooks (
|
|
1284
|
+
expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
|
|
1285
|
+
expect(io.outLines.join('\n')).toContain('no proven answer continuation')
|
|
903
1286
|
expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
|
|
904
1287
|
|
|
905
1288
|
writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
|
|
@@ -908,6 +1291,107 @@ describe('harness activation guidance', () => {
|
|
|
908
1291
|
expect(io.outLines.join('\n')).toContain('hooks (adapter)')
|
|
909
1292
|
expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
|
|
910
1293
|
})
|
|
1294
|
+
|
|
1295
|
+
it('refuses a symlinked OpenCode plugin without touching its target', () => {
|
|
1296
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-symlink-'))
|
|
1297
|
+
const plugin = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
1298
|
+
const target = path.join(cwd, 'foreign.js')
|
|
1299
|
+
mkdirSync(path.dirname(plugin), { recursive: true })
|
|
1300
|
+
writeFileSync(target, '// notifai managed opencode plugin\nleave me\n')
|
|
1301
|
+
symlinkSync(target, plugin)
|
|
1302
|
+
const io = new CapturedIo()
|
|
1303
|
+
const deps = {
|
|
1304
|
+
...makeDeps(io, {} as ApiClient),
|
|
1305
|
+
cwd,
|
|
1306
|
+
env: { XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
|
|
1310
|
+
EXIT.failed,
|
|
1311
|
+
)
|
|
1312
|
+
expect(io.errLines.join('\n')).toMatch(/not a regular file/)
|
|
1313
|
+
expect(readFileSync(target, 'utf8')).toBe('// notifai managed opencode plugin\nleave me\n')
|
|
1314
|
+
// Adapter preparation commits first. A definition failure leaves a valid
|
|
1315
|
+
// shared adapter (useful to any existing harness) and never half-writes the
|
|
1316
|
+
// rejected definition.
|
|
1317
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
|
|
1318
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
|
|
1319
|
+
})
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
describe('stable hook installation', () => {
|
|
1323
|
+
it('keeps definition bytes stable across config, CLI target, and project/global changes', async () => {
|
|
1324
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-stable-hook-install-'))
|
|
1325
|
+
const firstCli = path.join(cwd, 'first cli.js')
|
|
1326
|
+
const secondCli = path.join(cwd, 'second cli.js')
|
|
1327
|
+
writeFileSync(firstCli, '')
|
|
1328
|
+
writeFileSync(secondCli, '')
|
|
1329
|
+
const io = new CapturedIo()
|
|
1330
|
+
const env = {
|
|
1331
|
+
HOME: path.join(cwd, 'home'),
|
|
1332
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
1333
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
1334
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
1335
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
1336
|
+
}
|
|
1337
|
+
const client = {
|
|
1338
|
+
health: async () => true,
|
|
1339
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1340
|
+
listDevices: async () => ({ devices: [] }),
|
|
1341
|
+
} as unknown as ApiClient
|
|
1342
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
1343
|
+
const local = path.join(cwd, '.claude', 'settings.local.json')
|
|
1344
|
+
|
|
1345
|
+
expect(
|
|
1346
|
+
hooksInstallCommand(deps, {
|
|
1347
|
+
harness: 'claude-code',
|
|
1348
|
+
execPath: process.execPath,
|
|
1349
|
+
scriptPath: firstCli,
|
|
1350
|
+
}),
|
|
1351
|
+
).toBe(EXIT.ok)
|
|
1352
|
+
const firstDefinition = readFileSync(local, 'utf8')
|
|
1353
|
+
const firstAdapter = readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')
|
|
1354
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
1355
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
|
|
1356
|
+
env.XDG_CONFIG_HOME = path.join(cwd, 'different-config')
|
|
1357
|
+
env.XDG_STATE_HOME = path.join(cwd, 'different-state')
|
|
1358
|
+
|
|
1359
|
+
expect(
|
|
1360
|
+
hooksInstallCommand(deps, {
|
|
1361
|
+
harness: 'claude-code',
|
|
1362
|
+
execPath: process.execPath,
|
|
1363
|
+
scriptPath: secondCli,
|
|
1364
|
+
}),
|
|
1365
|
+
).toBe(EXIT.ok)
|
|
1366
|
+
expect(readFileSync(local, 'utf8')).toBe(firstDefinition)
|
|
1367
|
+
expect(readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')).not.toBe(firstAdapter)
|
|
1368
|
+
expect(firstDefinition).toContain(hookAdapterPath(deps.hookAdapterHome))
|
|
1369
|
+
expect(firstDefinition).not.toContain(firstCli)
|
|
1370
|
+
expect(firstDefinition).not.toContain(secondCli)
|
|
1371
|
+
|
|
1372
|
+
expect(
|
|
1373
|
+
hooksInstallCommand(deps, {
|
|
1374
|
+
harness: 'claude-code',
|
|
1375
|
+
global: true,
|
|
1376
|
+
execPath: process.execPath,
|
|
1377
|
+
scriptPath: secondCli,
|
|
1378
|
+
}),
|
|
1379
|
+
).toBe(EXIT.ok)
|
|
1380
|
+
const global = readFileSync(path.join(env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')
|
|
1381
|
+
expect(global).toBe(firstDefinition)
|
|
1382
|
+
|
|
1383
|
+
io.outLines = []
|
|
1384
|
+
await doctorCommand(deps, {})
|
|
1385
|
+
expect(io.outLines.join('\n')).toMatch(/hooks \(duplicates\).*project or global routing/is)
|
|
1386
|
+
|
|
1387
|
+
expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
|
|
1388
|
+
expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
|
|
1389
|
+
expect(
|
|
1390
|
+
findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
1391
|
+
(item) => item.harness === 'claude-code',
|
|
1392
|
+
),
|
|
1393
|
+
).toHaveLength(1)
|
|
1394
|
+
})
|
|
911
1395
|
})
|
|
912
1396
|
|
|
913
1397
|
describe('projectSlugFrom', () => {
|
|
@@ -919,6 +1403,261 @@ describe('projectSlugFrom', () => {
|
|
|
919
1403
|
})
|
|
920
1404
|
})
|
|
921
1405
|
|
|
1406
|
+
describe('logs', () => {
|
|
1407
|
+
function logDeps(io: CapturedIo): CommandDeps {
|
|
1408
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-logs-cmd-'))
|
|
1409
|
+
return {
|
|
1410
|
+
...makeDeps(io, {} as ApiClient),
|
|
1411
|
+
cwd,
|
|
1412
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function seed(deps: CommandDeps, project?: string): void {
|
|
1417
|
+
const logger = createLogger({ env: deps.env, runId: 'r_seed', cmd: 'send' })
|
|
1418
|
+
if (project !== undefined) logger.bind({ project })
|
|
1419
|
+
logger.info('send.submitted', { request_id: 'req_seeded' })
|
|
1420
|
+
logger.error('cli.error', { message: 'the server said no' })
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
it('shows the recent records, newest last', () => {
|
|
1424
|
+
const io = new CapturedIo()
|
|
1425
|
+
const deps = logDeps(io)
|
|
1426
|
+
seed(deps)
|
|
1427
|
+
expect(logsCommand(deps, {})).toBe(EXIT.ok)
|
|
1428
|
+
expect(io.outLines).toHaveLength(2)
|
|
1429
|
+
expect(io.outLines[0]).toContain('send.submitted')
|
|
1430
|
+
expect(io.outLines[1]).toContain('cli.error')
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
it('keeps stdout pure JSONL under --json so a parser needs no filtering', () => {
|
|
1434
|
+
// Every word of explanation goes to stderr. A machine reading stdout gets
|
|
1435
|
+
// records and nothing else.
|
|
1436
|
+
const io = new CapturedIo()
|
|
1437
|
+
const deps = logDeps(io)
|
|
1438
|
+
seed(deps)
|
|
1439
|
+
expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
|
|
1440
|
+
for (const line of io.outLines) {
|
|
1441
|
+
expect(() => JSON.parse(line) as unknown).not.toThrow()
|
|
1442
|
+
}
|
|
1443
|
+
expect(io.outLines).toHaveLength(2)
|
|
1444
|
+
})
|
|
1445
|
+
|
|
1446
|
+
it('narrows to the failures', () => {
|
|
1447
|
+
const io = new CapturedIo()
|
|
1448
|
+
const deps = logDeps(io)
|
|
1449
|
+
seed(deps)
|
|
1450
|
+
expect(logsCommand(deps, { level: 'error', json: true })).toBe(EXIT.ok)
|
|
1451
|
+
expect(io.outLines).toHaveLength(1)
|
|
1452
|
+
expect(io.outLines[0]).toContain('cli.error')
|
|
1453
|
+
})
|
|
1454
|
+
|
|
1455
|
+
it('narrows to one notification request', () => {
|
|
1456
|
+
const io = new CapturedIo()
|
|
1457
|
+
const deps = logDeps(io)
|
|
1458
|
+
seed(deps)
|
|
1459
|
+
expect(logsCommand(deps, { request: 'req_seeded', json: true })).toBe(EXIT.ok)
|
|
1460
|
+
expect(io.outLines).toHaveLength(1)
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
it('scopes to this project unless told otherwise', () => {
|
|
1464
|
+
const io = new CapturedIo()
|
|
1465
|
+
const deps = logDeps(io)
|
|
1466
|
+
mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
|
|
1467
|
+
writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'project = "mine"\n')
|
|
1468
|
+
seed(deps, 'mine')
|
|
1469
|
+
seed(deps, 'someone-elses')
|
|
1470
|
+
|
|
1471
|
+
// A machine commonly runs several agents in several worktrees at once, and
|
|
1472
|
+
// an answer interleaved with three other projects answers nothing.
|
|
1473
|
+
expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
|
|
1474
|
+
expect(io.outLines).toHaveLength(2)
|
|
1475
|
+
io.outLines.length = 0
|
|
1476
|
+
expect(logsCommand(deps, { json: true, allProjects: true })).toBe(EXIT.ok)
|
|
1477
|
+
expect(io.outLines).toHaveLength(4)
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
it('rejects an unreadable time, unknown event, and invalid limits', () => {
|
|
1481
|
+
const io = new CapturedIo()
|
|
1482
|
+
const deps = logDeps(io)
|
|
1483
|
+
expect(logsCommand(deps, { since: 'yesterday-ish' })).toBe(EXIT.usage)
|
|
1484
|
+
expect(io.errLines.join('\n')).toContain('10m')
|
|
1485
|
+
io.errLines.length = 0
|
|
1486
|
+
expect(logsCommand(deps, { event: ['send.exploded'] })).toBe(EXIT.usage)
|
|
1487
|
+
expect(io.errLines.join('\n')).toContain('send.submitted')
|
|
1488
|
+
io.errLines.length = 0
|
|
1489
|
+
expect(logsCommand(deps, { limit: Number.NaN })).toBe(EXIT.usage)
|
|
1490
|
+
expect(io.errLines.join('\n')).toContain('--limit')
|
|
1491
|
+
io.errLines.length = 0
|
|
1492
|
+
expect(logsCommand(deps, { limit: 2_001 })).toBe(EXIT.usage)
|
|
1493
|
+
expect(io.errLines.join('\n')).toContain('cannot exceed 2000')
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
it.each([
|
|
1497
|
+
[{ path: true, clear: true }, '--path'],
|
|
1498
|
+
[{ path: true, request: 'req_seeded' }, '--path'],
|
|
1499
|
+
[{ clear: true, since: '1d' }, '--clear'],
|
|
1500
|
+
[{ project: 'mine', allProjects: true }, '--project'],
|
|
1501
|
+
[{ limit: 10, all: true }, '--limit'],
|
|
1502
|
+
] as const)('rejects incompatible log options %j', (flags, namedFlag) => {
|
|
1503
|
+
const io = new CapturedIo()
|
|
1504
|
+
expect(logsCommand(logDeps(io), flags)).toBe(EXIT.usage)
|
|
1505
|
+
expect(io.errLines.join('\n')).toContain(namedFlag)
|
|
1506
|
+
})
|
|
1507
|
+
|
|
1508
|
+
it('accepts relative and absolute times', () => {
|
|
1509
|
+
const now = Date.parse('2026-08-11T12:00:00Z')
|
|
1510
|
+
expect(parseSince('10m', now)).toBe(now - 600_000)
|
|
1511
|
+
expect(parseSince('2h', now)).toBe(now - 7_200_000)
|
|
1512
|
+
expect(parseSince('1d', now)).toBe(now - 86_400_000)
|
|
1513
|
+
expect(parseSince('2026-08-11T11:00:00Z', now)).toBe(Date.parse('2026-08-11T11:00:00Z'))
|
|
1514
|
+
expect(parseSince('whenever', now)).toBeNull()
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
it('says why it is empty, and what to do about it', () => {
|
|
1518
|
+
// "No records" with no explanation sends the reader looking for a bug that
|
|
1519
|
+
// is actually a setting.
|
|
1520
|
+
const io = new CapturedIo()
|
|
1521
|
+
const deps = logDeps(io)
|
|
1522
|
+
mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
|
|
1523
|
+
writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'log_level = "off"\n')
|
|
1524
|
+
expect(logsCommand(deps, {})).toBe(EXIT.ok)
|
|
1525
|
+
expect(io.errLines.join('\n')).toContain('log_level is off')
|
|
1526
|
+
expect(io.errLines.join('\n')).toContain('config set log_level info')
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
it('points at the files, and says they stay here', () => {
|
|
1530
|
+
const io = new CapturedIo()
|
|
1531
|
+
const deps = logDeps(io)
|
|
1532
|
+
seed(deps)
|
|
1533
|
+
expect(logsCommand(deps, { path: true })).toBe(EXIT.ok)
|
|
1534
|
+
expect(io.outLines[0]).toContain('notifai.jsonl')
|
|
1535
|
+
expect(io.errLines.join('\n')).toContain('stay on this machine')
|
|
1536
|
+
})
|
|
1537
|
+
|
|
1538
|
+
it('reports local_only in the machine-readable form', () => {
|
|
1539
|
+
const io = new CapturedIo()
|
|
1540
|
+
const deps = logDeps(io)
|
|
1541
|
+
expect(logsCommand(deps, { path: true, json: true })).toBe(EXIT.ok)
|
|
1542
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
1543
|
+
expect(parsed['local_only']).toBe(true)
|
|
1544
|
+
expect(parsed['level']).toBe('info')
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
it('clears the files when asked', () => {
|
|
1548
|
+
const io = new CapturedIo()
|
|
1549
|
+
const deps = logDeps(io)
|
|
1550
|
+
seed(deps)
|
|
1551
|
+
expect(logsCommand(deps, { clear: true })).toBe(EXIT.ok)
|
|
1552
|
+
expect(logsDiskUsage(deps.env).files).toBe(0)
|
|
1553
|
+
})
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
describe('config surfaces', () => {
|
|
1557
|
+
function configDeps(io: CapturedIo): CommandDeps {
|
|
1558
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
|
|
1559
|
+
return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
it('gives an agent the same flat key = value output it always had', () => {
|
|
1563
|
+
// The whole point of gating the readable rendering on a human terminal.
|
|
1564
|
+
// Anything parsing this today must keep parsing it tomorrow.
|
|
1565
|
+
const io = new CapturedIo()
|
|
1566
|
+
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
1567
|
+
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
1568
|
+
expect(io.outLines[0]).toMatch(/^base_url = "/)
|
|
1569
|
+
for (const line of io.outLines) expect(line).toMatch(/^[a-z_]+ = /)
|
|
1570
|
+
})
|
|
1571
|
+
|
|
1572
|
+
it('explains each setting once a human is at the terminal', () => {
|
|
1573
|
+
const io = new InteractiveIo()
|
|
1574
|
+
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
1575
|
+
const text = io.outLines.join('\n')
|
|
1576
|
+
expect(text).toContain('Questions & presence')
|
|
1577
|
+
expect(text).toContain('Whether sitting at this keyboard holds a question back')
|
|
1578
|
+
})
|
|
1579
|
+
|
|
1580
|
+
it('keeps --plain available to a human who wants the parseable form', () => {
|
|
1581
|
+
const io = new InteractiveIo()
|
|
1582
|
+
expect(configShowCommand(configDeps(io), { plain: true })).toBe(EXIT.ok)
|
|
1583
|
+
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
1584
|
+
})
|
|
1585
|
+
|
|
1586
|
+
it('explains one setting, and says so in JSON when asked', () => {
|
|
1587
|
+
const io = new InteractiveIo()
|
|
1588
|
+
expect(configExplainCommand(configDeps(io), 'require_idle', { json: true })).toBe(EXIT.ok)
|
|
1589
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
1590
|
+
expect(parsed['key']).toBe('require_idle')
|
|
1591
|
+
expect(parsed['accepts']).toBe('true or false')
|
|
1592
|
+
expect(parsed['detail']).toContain('Off (the default)')
|
|
1593
|
+
expect(parsed['detail']).toContain('Turn this on')
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
it('rejects an unknown setting and points at the nearest real one', () => {
|
|
1597
|
+
const io = new CapturedIo()
|
|
1598
|
+
expect(configExplainCommand(configDeps(io), 'require_idl')).toBe(EXIT.usage)
|
|
1599
|
+
expect(io.errLines[0]).toBe('Unknown setting "require_idl".')
|
|
1600
|
+
})
|
|
1601
|
+
|
|
1602
|
+
it('refuses an enum value the sender would later reject', async () => {
|
|
1603
|
+
// `config set sound whatever` used to be written straight to disk: the
|
|
1604
|
+
// typo only surfaced when a notification failed to carry the sound.
|
|
1605
|
+
const io = new CapturedIo()
|
|
1606
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'whatever', { yes: true })).toBe(
|
|
1607
|
+
EXIT.usage,
|
|
1608
|
+
)
|
|
1609
|
+
expect(io.errLines[0]).toContain('default, done, attention, alert, none')
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1612
|
+
it('still accepts a legal enum value', async () => {
|
|
1613
|
+
const io = new CapturedIo()
|
|
1614
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
|
|
1615
|
+
})
|
|
1616
|
+
|
|
1617
|
+
it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
|
|
1618
|
+
const io = new CapturedIo()
|
|
1619
|
+
const deps = configDeps(io)
|
|
1620
|
+
const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
1621
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
1622
|
+
writeFileSync(configFile, 'wait_seconds = 20\nsound = "done"\n')
|
|
1623
|
+
|
|
1624
|
+
expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
|
|
1625
|
+
|
|
1626
|
+
const remaining = readFileSync(configFile, 'utf8')
|
|
1627
|
+
expect(remaining).toContain('sound = "done"')
|
|
1628
|
+
expect(remaining).not.toContain('wait_seconds')
|
|
1629
|
+
const resolved = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1630
|
+
expect(resolved.wait_seconds).toEqual({ value: 10, source: 'default' })
|
|
1631
|
+
expect(resolved.sound.source).toMatch(/^global:/)
|
|
1632
|
+
})
|
|
1633
|
+
|
|
1634
|
+
it('refuses to create a redundant machine override equal to the shipped default', async () => {
|
|
1635
|
+
const io = new CapturedIo()
|
|
1636
|
+
const deps = configDeps(io)
|
|
1637
|
+
|
|
1638
|
+
expect(await configSetCommand(deps, 'wait_seconds', '10', { yes: true })).toBe(EXIT.usage)
|
|
1639
|
+
expect(io.errLines.join('\n')).toContain('already the shipped default')
|
|
1640
|
+
expect(io.errLines.join('\n')).toContain('notifai config unset wait_seconds')
|
|
1641
|
+
expect(existsSync(path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml'))).toBe(false)
|
|
1642
|
+
})
|
|
1643
|
+
|
|
1644
|
+
it('allows a project to choose the shipped value when it masks a machine override', async () => {
|
|
1645
|
+
const io = new CapturedIo()
|
|
1646
|
+
const deps = configDeps(io)
|
|
1647
|
+
const globalFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
1648
|
+
mkdirSync(path.dirname(globalFile), { recursive: true })
|
|
1649
|
+
writeFileSync(globalFile, 'wait_seconds = 20\n')
|
|
1650
|
+
|
|
1651
|
+
expect(await configSetCommand(deps, 'wait_seconds', '10', { project: true, yes: true })).toBe(
|
|
1652
|
+
EXIT.ok,
|
|
1653
|
+
)
|
|
1654
|
+
expect(loadConfig({ cwd: deps.cwd, env: deps.env }).wait_seconds).toMatchObject({
|
|
1655
|
+
value: 10,
|
|
1656
|
+
source: expect.stringMatching(/^project:/),
|
|
1657
|
+
})
|
|
1658
|
+
})
|
|
1659
|
+
})
|
|
1660
|
+
|
|
922
1661
|
describe('interactive command UX', () => {
|
|
923
1662
|
it('styles login pairing progress for a human terminal', async () => {
|
|
924
1663
|
const io = new InteractiveIo()
|
|
@@ -1034,6 +1773,25 @@ describe('interactive command UX', () => {
|
|
|
1034
1773
|
)
|
|
1035
1774
|
})
|
|
1036
1775
|
|
|
1776
|
+
it('asks the same layer question when returning a setting to its inherited value', async () => {
|
|
1777
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
|
|
1778
|
+
const io = new InteractiveIo()
|
|
1779
|
+
io.selectAnswer = 'local'
|
|
1780
|
+
const localFile = path.join(cwd, '.notifai', 'config.local.toml')
|
|
1781
|
+
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
1782
|
+
writeFileSync(localFile, 'sound = "done"\n')
|
|
1783
|
+
const deps = {
|
|
1784
|
+
...makeDeps(io, {} as ApiClient),
|
|
1785
|
+
cwd,
|
|
1786
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
|
|
1790
|
+
expect(io.prompts[0]).toBe('Where should this setting live?')
|
|
1791
|
+
expect(io.prompts[1]).toContain(localFile)
|
|
1792
|
+
expect(existsSync(localFile)).toBe(false)
|
|
1793
|
+
})
|
|
1794
|
+
|
|
1037
1795
|
it('rejects numeric config values that resolution would otherwise silently clamp', async () => {
|
|
1038
1796
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-bounds-'))
|
|
1039
1797
|
const io = new CapturedIo()
|
|
@@ -1051,8 +1809,10 @@ describe('interactive command UX', () => {
|
|
|
1051
1809
|
EXIT.usage,
|
|
1052
1810
|
)
|
|
1053
1811
|
expect(io.errLines).toEqual([
|
|
1054
|
-
'ask_grace_seconds must be between 0 and
|
|
1055
|
-
|
|
1812
|
+
'ask_grace_seconds must be between 0 and 334.',
|
|
1813
|
+
// Names the key and its range: `"1.5" is not an integer` left the reader
|
|
1814
|
+
// to work out which of the two settings they had just mistyped.
|
|
1815
|
+
'ask_grace_seconds takes a whole number from 0s–334s, not "1.5".',
|
|
1056
1816
|
])
|
|
1057
1817
|
expect(existsSync(configFile)).toBe(false)
|
|
1058
1818
|
})
|
|
@@ -1081,6 +1841,18 @@ describe('interactive command UX', () => {
|
|
|
1081
1841
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
1082
1842
|
expect(io.checks.some((check) => check.ok && check.message.startsWith('Protocol version:'))).toBe(true)
|
|
1083
1843
|
expect(io.outLines).toEqual([])
|
|
1844
|
+
|
|
1845
|
+
// Four tones, not two. A boolean has to round `optional-gap` and `unknown`
|
|
1846
|
+
// to pass or fail, and rounding them to pass put a tick beside things the
|
|
1847
|
+
// user had declined and beside things nothing had checked.
|
|
1848
|
+
const tone = (prefix: string): Tone | undefined =>
|
|
1849
|
+
io.checks.find((check) => check.message.startsWith(prefix))?.tone
|
|
1850
|
+
expect(tone('This machine:')).toBe('bad')
|
|
1851
|
+
expect(tone('Protocol version:')).toBe('ok')
|
|
1852
|
+
// Never evaluated: the account cannot be checked without a credential.
|
|
1853
|
+
expect(tone('Account:')).toBe('pending')
|
|
1854
|
+
// Legitimately declined rather than broken.
|
|
1855
|
+
expect(tone('Project identity:')).toBe('warn')
|
|
1084
1856
|
})
|
|
1085
1857
|
|
|
1086
1858
|
it('keeps doctor JSON as one machine-readable stdout document', async () => {
|
|
@@ -1181,7 +1953,7 @@ describe('init', () => {
|
|
|
1181
1953
|
source: 'Raidiant-io/notifai',
|
|
1182
1954
|
sourceType: 'github',
|
|
1183
1955
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1184
|
-
ref: 'v0.
|
|
1956
|
+
ref: 'v0.4.0',
|
|
1185
1957
|
}
|
|
1186
1958
|
}
|
|
1187
1959
|
|
|
@@ -1284,7 +2056,7 @@ describe('init', () => {
|
|
|
1284
2056
|
})
|
|
1285
2057
|
|
|
1286
2058
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1287
|
-
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.
|
|
2059
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.4.0')
|
|
1288
2060
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1289
2061
|
})
|
|
1290
2062
|
|
|
@@ -1307,7 +2079,7 @@ describe('init', () => {
|
|
|
1307
2079
|
source: 'Raidiant-io/notifai',
|
|
1308
2080
|
sourceType: 'github',
|
|
1309
2081
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1310
|
-
ref: 'v0.
|
|
2082
|
+
ref: 'v0.4.0',
|
|
1311
2083
|
},
|
|
1312
2084
|
],
|
|
1313
2085
|
}),
|
|
@@ -2035,7 +2807,9 @@ describe('an outage is not an answer', () => {
|
|
|
2035
2807
|
* agent scripted to read exit 3 as "nobody objected" would proceed against a
|
|
2036
2808
|
* refusal it never saw.
|
|
2037
2809
|
*/
|
|
2038
|
-
function outageAfterFirstPoll(io: CapturedIo
|
|
2810
|
+
function outageAfterFirstPoll(io: CapturedIo, fault: () => never = () => {
|
|
2811
|
+
throw new NetworkError('link went down')
|
|
2812
|
+
}): CommandDeps {
|
|
2039
2813
|
let now = 0
|
|
2040
2814
|
let polls = 0
|
|
2041
2815
|
const client = {
|
|
@@ -2043,7 +2817,7 @@ describe('an outage is not an answer', () => {
|
|
|
2043
2817
|
replies: async () => {
|
|
2044
2818
|
polls += 1
|
|
2045
2819
|
if (polls === 1) return replyResponse([])
|
|
2046
|
-
|
|
2820
|
+
fault()
|
|
2047
2821
|
},
|
|
2048
2822
|
} as unknown as ApiClient
|
|
2049
2823
|
return {
|
|
@@ -2069,6 +2843,27 @@ describe('an outage is not an answer', () => {
|
|
|
2069
2843
|
expect(exit).not.toBe(EXIT.noReply)
|
|
2070
2844
|
expect(exit).toBe(EXIT.network)
|
|
2071
2845
|
expect(io.errLines.join('\n')).toContain('could not find out')
|
|
2846
|
+
expect(io.errLines.join('\n')).toContain(receipt.request_id)
|
|
2847
|
+
})
|
|
2848
|
+
|
|
2849
|
+
it('treats a late internal_error outage the same as a network outage', async () => {
|
|
2850
|
+
const io = new CapturedIo()
|
|
2851
|
+
const exit = await sendCommand(
|
|
2852
|
+
outageAfterFirstPoll(io, () => {
|
|
2853
|
+
throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
|
|
2854
|
+
}),
|
|
2855
|
+
{
|
|
2856
|
+
title: 'Question',
|
|
2857
|
+
body: 'Deploy to production?',
|
|
2858
|
+
reply: true,
|
|
2859
|
+
replyTimeout: 10,
|
|
2860
|
+
},
|
|
2861
|
+
)
|
|
2862
|
+
|
|
2863
|
+
expect(exit).not.toBe(EXIT.noReply)
|
|
2864
|
+
expect(exit).toBe(EXIT.network)
|
|
2865
|
+
expect(io.errLines.join('\n')).toContain('could not find out')
|
|
2866
|
+
expect(io.errLines.join('\n')).toContain('Provider Acceptance')
|
|
2072
2867
|
})
|
|
2073
2868
|
|
|
2074
2869
|
it('marks the JSON so an agent reading it programmatically can tell', async () => {
|
|
@@ -2107,8 +2902,8 @@ describe('an outage is not an answer', () => {
|
|
|
2107
2902
|
})
|
|
2108
2903
|
|
|
2109
2904
|
describe('asking before the hooks have ever run', () => {
|
|
2110
|
-
const execPath =
|
|
2111
|
-
const scriptPath =
|
|
2905
|
+
const execPath = process.execPath
|
|
2906
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
2112
2907
|
|
|
2113
2908
|
it('names the active Codex harness instead of unrelated installed adapters', () => {
|
|
2114
2909
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
|
|
@@ -2151,7 +2946,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2151
2946
|
io.outLines = []
|
|
2152
2947
|
|
|
2153
2948
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2154
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
2949
|
+
expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
|
|
2155
2950
|
expect(io.outLines).not.toContain(
|
|
2156
2951
|
'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
2157
2952
|
)
|
|
@@ -2169,8 +2964,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2169
2964
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2170
2965
|
|
|
2171
2966
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2172
|
-
|
|
2967
|
+
trustInstalledCodexHooks(cwd, env)
|
|
2968
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
2173
2969
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
2970
|
+
writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
|
|
2971
|
+
writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
|
|
2174
2972
|
io.outLines = []
|
|
2175
2973
|
|
|
2176
2974
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
@@ -2179,6 +2977,40 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2179
2977
|
)
|
|
2180
2978
|
})
|
|
2181
2979
|
|
|
2980
|
+
it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
|
|
2981
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-stale-trust-'))
|
|
2982
|
+
const io = new CapturedIo()
|
|
2983
|
+
const env = {
|
|
2984
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
2985
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2986
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
2987
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
2988
|
+
}
|
|
2989
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2990
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2991
|
+
trustInstalledCodexHooks(cwd, env)
|
|
2992
|
+
const stop = findInstallations(cwd, env)
|
|
2993
|
+
.find((installation) => installation.harness === 'codex')
|
|
2994
|
+
?.handlers.find((handler) => handler.event === 'Stop')
|
|
2995
|
+
expect(stop).toBeDefined()
|
|
2996
|
+
const configFile = path.join(env.CODEX_HOME, 'config.toml')
|
|
2997
|
+
writeFileSync(
|
|
2998
|
+
configFile,
|
|
2999
|
+
readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
|
|
3000
|
+
)
|
|
3001
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3002
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
3003
|
+
io.outLines = []
|
|
3004
|
+
|
|
3005
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3006
|
+
expect(io.errLines.join('\n')).toMatch(/Stop.*changed since it was trusted.*\/hooks/is)
|
|
3007
|
+
|
|
3008
|
+
io.outLines = []
|
|
3009
|
+
await doctorCommand(deps, {})
|
|
3010
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
|
|
3011
|
+
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
3012
|
+
})
|
|
3013
|
+
|
|
2182
3014
|
it('gives doctor the same active-Codex diagnosis as ask', async () => {
|
|
2183
3015
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
|
|
2184
3016
|
const io = new CapturedIo()
|
|
@@ -2207,7 +3039,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2207
3039
|
source: 'Raidiant-io/notifai',
|
|
2208
3040
|
sourceType: 'github',
|
|
2209
3041
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2210
|
-
ref: 'v0.
|
|
3042
|
+
ref: 'v0.4.0',
|
|
2211
3043
|
},
|
|
2212
3044
|
]
|
|
2213
3045
|
: [],
|
|
@@ -2244,7 +3076,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2244
3076
|
io.outLines = []
|
|
2245
3077
|
|
|
2246
3078
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2247
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
3079
|
+
expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
|
|
2248
3080
|
})
|
|
2249
3081
|
|
|
2250
3082
|
it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
|
|
@@ -2262,7 +3094,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2262
3094
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2263
3095
|
EXIT.ok,
|
|
2264
3096
|
)
|
|
2265
|
-
writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
|
|
3097
|
+
writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
2266
3098
|
writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
|
|
2267
3099
|
io.outLines = []
|
|
2268
3100
|
|
|
@@ -2270,6 +3102,80 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2270
3102
|
expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
|
|
2271
3103
|
})
|
|
2272
3104
|
|
|
3105
|
+
it('fails closed when Claude Code does not expose an exact session id', () => {
|
|
3106
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-no-id-'))
|
|
3107
|
+
const io = new CapturedIo()
|
|
3108
|
+
const env = {
|
|
3109
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3110
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3111
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
3112
|
+
CLAUDECODE: '1',
|
|
3113
|
+
}
|
|
3114
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3115
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
3116
|
+
EXIT.ok,
|
|
3117
|
+
)
|
|
3118
|
+
writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3119
|
+
writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
|
|
3120
|
+
io.outLines = []
|
|
3121
|
+
|
|
3122
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3123
|
+
expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
|
|
3124
|
+
expect(readSessionState('claude-last-writer', env).pending).toBeUndefined()
|
|
3125
|
+
})
|
|
3126
|
+
|
|
3127
|
+
it('rejects duplicate active Codex definitions before registration', () => {
|
|
3128
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-duplicate-'))
|
|
3129
|
+
const io = new CapturedIo()
|
|
3130
|
+
const env = {
|
|
3131
|
+
HOME: path.join(cwd, 'home'),
|
|
3132
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3133
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3134
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3135
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3136
|
+
}
|
|
3137
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3138
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3139
|
+
expect(
|
|
3140
|
+
hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
|
|
3141
|
+
).toBe(EXIT.ok)
|
|
3142
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3143
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3144
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
3145
|
+
io.outLines = []
|
|
3146
|
+
|
|
3147
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3148
|
+
expect(io.errLines.join('\n')).toMatch(/2 Codex definitions are active/i)
|
|
3149
|
+
expect(readSessionState('codex-current-thread', env).pending).toBeUndefined()
|
|
3150
|
+
})
|
|
3151
|
+
|
|
3152
|
+
it('does not let UserPromptSubmit alone count as a working turn-end route', async () => {
|
|
3153
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-prompt-only-'))
|
|
3154
|
+
const io = new CapturedIo()
|
|
3155
|
+
const env = {
|
|
3156
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3157
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3158
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
3159
|
+
CLAUDECODE: '1',
|
|
3160
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
3161
|
+
}
|
|
3162
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3163
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
3164
|
+
EXIT.ok,
|
|
3165
|
+
)
|
|
3166
|
+
writeSessionState('claude-current', env, { last_prompt_at: 42 })
|
|
3167
|
+
writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
|
|
3168
|
+
io.outLines = []
|
|
3169
|
+
|
|
3170
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3171
|
+
expect(io.errLines.join('\n')).toMatch(/Stop hook has not been observed/)
|
|
3172
|
+
|
|
3173
|
+
const readiness = await assessReadiness(deps)
|
|
3174
|
+
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
3175
|
+
expect(fired?.status).toBe('gap')
|
|
3176
|
+
expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
|
|
3177
|
+
})
|
|
3178
|
+
|
|
2273
3179
|
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
2274
3180
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
|
|
2275
3181
|
const io = new CapturedIo()
|
|
@@ -2287,10 +3193,31 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2287
3193
|
io.outLines = []
|
|
2288
3194
|
|
|
2289
3195
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2290
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
3196
|
+
expect(io.errLines.join(' ')).toMatch(/active OpenCode session:.*has not published/i)
|
|
3197
|
+
})
|
|
3198
|
+
|
|
3199
|
+
it('rejects OpenCode before registration even with an exact matching pointer', () => {
|
|
3200
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-unsupported-'))
|
|
3201
|
+
const io = new CapturedIo()
|
|
3202
|
+
const env = {
|
|
3203
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3204
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3205
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
3206
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
3207
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
|
|
3208
|
+
}
|
|
3209
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3210
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3211
|
+
writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3212
|
+
writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
|
|
3213
|
+
io.outLines = []
|
|
3214
|
+
|
|
3215
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3216
|
+
expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
|
|
3217
|
+
expect(readSessionState('opencode-current', env).pending).toBeUndefined()
|
|
2291
3218
|
})
|
|
2292
3219
|
|
|
2293
|
-
it('
|
|
3220
|
+
it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
|
|
2294
3221
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
|
|
2295
3222
|
const io = new CapturedIo()
|
|
2296
3223
|
const env = {
|
|
@@ -2300,12 +3227,13 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2300
3227
|
}
|
|
2301
3228
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2302
3229
|
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2303
|
-
writeSessionState('cursor-live', env, { last_prompt_at: 42 })
|
|
3230
|
+
writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
2304
3231
|
writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
|
|
2305
3232
|
io.outLines = []
|
|
2306
3233
|
|
|
2307
|
-
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.
|
|
2308
|
-
expect(
|
|
3234
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3235
|
+
expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
|
|
3236
|
+
expect(readSessionState('cursor-live', env).pending).toBeUndefined()
|
|
2309
3237
|
})
|
|
2310
3238
|
|
|
2311
3239
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
@@ -2383,7 +3311,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2383
3311
|
expect(said).toContain('hook_reply_timeout_seconds=120 (project-local:')
|
|
2384
3312
|
})
|
|
2385
3313
|
|
|
2386
|
-
it('
|
|
3314
|
+
it('keeps an installed Stop timeout valid when runtime timing preferences change', async () => {
|
|
2387
3315
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
|
|
2388
3316
|
const io = new CapturedIo()
|
|
2389
3317
|
const env = { XDG_STATE_HOME: path.join(cwd, 'state'), CLAUDE_CONFIG_DIR: path.join(cwd, 'claude') }
|
|
@@ -2395,9 +3323,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2395
3323
|
writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 400\n')
|
|
2396
3324
|
io.outLines = []
|
|
2397
3325
|
|
|
2398
|
-
|
|
2399
|
-
expect(io.outLines.join('\n')).toMatch(/
|
|
2400
|
-
expect(io.outLines.join('\n')).toContain(
|
|
3326
|
+
await doctorCommand(deps, {})
|
|
3327
|
+
expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(timeout\)/)
|
|
3328
|
+
expect(io.outLines.join('\n')).toContain(
|
|
3329
|
+
'declared Stop timeouts match the fixed 540s process budget where required; Codex uses host defaults',
|
|
3330
|
+
)
|
|
2401
3331
|
})
|
|
2402
3332
|
|
|
2403
3333
|
it('documents the failing exit contract in doctor JSON', async () => {
|
|
@@ -2415,10 +3345,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2415
3345
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
2416
3346
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
2417
3347
|
hooks: buildHookConfig({
|
|
2418
|
-
|
|
2419
|
-
scriptPath: '/opt/notifai/main.js',
|
|
2420
|
-
replyTimeoutSeconds: 180,
|
|
2421
|
-
graceSeconds: 300,
|
|
3348
|
+
adapterPath: path.join(cwd, 'notifai', 'hook-adapter'),
|
|
2422
3349
|
}),
|
|
2423
3350
|
})
|
|
2424
3351
|
|
|
@@ -2447,7 +3374,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2447
3374
|
expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
|
|
2448
3375
|
})
|
|
2449
3376
|
|
|
2450
|
-
it('keeps OpenCode activation separate from answer continuation', () => {
|
|
3377
|
+
it('keeps OpenCode activation separate from unsupported answer continuation', () => {
|
|
2451
3378
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
|
|
2452
3379
|
const io = new CapturedIo()
|
|
2453
3380
|
const deps = {
|
|
@@ -2465,7 +3392,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2465
3392
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2466
3393
|
const said = io.errLines.join(' ')
|
|
2467
3394
|
expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
|
|
2468
|
-
expect(said).toContain('
|
|
3395
|
+
expect(said).toContain('non-blocking question continuation is intentionally unsupported')
|
|
2469
3396
|
})
|
|
2470
3397
|
|
|
2471
3398
|
it('says to install when nothing is installed at all', () => {
|