@raidiant/notifai 0.3.0 → 0.5.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 +19 -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 +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- 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 +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- 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/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- 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 +34 -0
- package/src/harnesses.ts +57 -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 +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- 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 +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
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,
|
|
@@ -11,6 +12,7 @@ import type {
|
|
|
11
12
|
} from '@raidiant/notifai-protocol'
|
|
12
13
|
import { describe, expect, it } from 'vitest'
|
|
13
14
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
15
|
+
import type { ClaudeWakeAdapters } from './claude-wake.js'
|
|
14
16
|
import {
|
|
15
17
|
askCommand,
|
|
16
18
|
accessStatusCommand,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
configExplainCommand,
|
|
21
23
|
configSetCommand,
|
|
22
24
|
configShowCommand,
|
|
25
|
+
configUnsetCommand,
|
|
23
26
|
contradictingAnswer,
|
|
24
27
|
describeHookFailure,
|
|
25
28
|
doctorCommand,
|
|
@@ -29,6 +32,8 @@ import {
|
|
|
29
32
|
initCommand,
|
|
30
33
|
SKILLS_SOURCE,
|
|
31
34
|
loginCommand,
|
|
35
|
+
logsCommand,
|
|
36
|
+
parseSince,
|
|
32
37
|
projectSlugFrom,
|
|
33
38
|
repliesCommand,
|
|
34
39
|
sendCommand,
|
|
@@ -37,10 +42,18 @@ import {
|
|
|
37
42
|
type CommandIo,
|
|
38
43
|
type CommandSpinner,
|
|
39
44
|
} from './commands.js'
|
|
40
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
applyPlan,
|
|
47
|
+
buildHookConfig,
|
|
48
|
+
codexHookIdentityHash,
|
|
49
|
+
codexTrustKey,
|
|
50
|
+
findInstallations,
|
|
51
|
+
} from './install-hooks.js'
|
|
41
52
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
42
53
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
43
|
-
import { CONFIG_KEYS } from './config.js'
|
|
54
|
+
import { CONFIG_KEYS, loadConfig } from './config.js'
|
|
55
|
+
import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
56
|
+
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
44
57
|
import type { Tone } from './ui/theme.js'
|
|
45
58
|
|
|
46
59
|
class CapturedIo implements CommandIo {
|
|
@@ -120,7 +133,23 @@ class InteractiveIo extends CapturedIo {
|
|
|
120
133
|
}
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
|
|
137
|
+
const installations = findInstallations(cwd, env).filter(
|
|
138
|
+
(installation) => installation.harness === 'codex',
|
|
139
|
+
)
|
|
140
|
+
const sections = installations.flatMap((installation) =>
|
|
141
|
+
installation.handlers.map((handler) => {
|
|
142
|
+
const key = codexTrustKey(installation, handler)
|
|
143
|
+
return `[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(codexHookIdentityHash(handler))}\n`
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
const codexHome = env['CODEX_HOME'] as string
|
|
147
|
+
mkdirSync(codexHome, { recursive: true })
|
|
148
|
+
writeFileSync(path.join(codexHome, 'config.toml'), sections.join('\n'))
|
|
149
|
+
}
|
|
150
|
+
|
|
123
151
|
function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
152
|
+
const testRoot = path.join(os.tmpdir(), 'notifai-cli-command-tests')
|
|
124
153
|
return {
|
|
125
154
|
io,
|
|
126
155
|
store: {
|
|
@@ -134,12 +163,36 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
134
163
|
clear: () => {},
|
|
135
164
|
describe: () => 'test credential store',
|
|
136
165
|
},
|
|
137
|
-
env: {
|
|
166
|
+
env: {
|
|
167
|
+
XDG_CONFIG_HOME: testRoot,
|
|
168
|
+
XDG_STATE_HOME: path.join(os.tmpdir(), 'notifai-cli-command-tests-state'),
|
|
169
|
+
},
|
|
170
|
+
hookAdapterHome: path.join(testRoot, 'home'),
|
|
138
171
|
cwd: os.tmpdir(),
|
|
139
172
|
clientFactory: () => client,
|
|
140
173
|
}
|
|
141
174
|
}
|
|
142
175
|
|
|
176
|
+
/**
|
|
177
|
+
* An environment that cannot see the machine running the suite.
|
|
178
|
+
*
|
|
179
|
+
* Harness discovery walks the real home directory whenever the environment
|
|
180
|
+
* names no override, so a developer with Notifai's own global hooks installed
|
|
181
|
+
* was silently running these cases against their own settings file — and a
|
|
182
|
+
* check that failed on that file failed only for them. Every setup case that
|
|
183
|
+
* asserts on readiness therefore gets its own home.
|
|
184
|
+
*/
|
|
185
|
+
function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
|
|
186
|
+
const home = path.join(cwd, 'home')
|
|
187
|
+
return {
|
|
188
|
+
HOME: home,
|
|
189
|
+
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
|
190
|
+
CODEX_HOME: path.join(home, '.codex'),
|
|
191
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
192
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
const receipt: SubmissionReceipt = {
|
|
144
197
|
request_id: 'req_reply_test',
|
|
145
198
|
replayed: false,
|
|
@@ -494,13 +547,19 @@ describe('command contracts', () => {
|
|
|
494
547
|
return replyResponse(polls.length === 3 ? [reply] : [])
|
|
495
548
|
},
|
|
496
549
|
} as unknown as ApiClient
|
|
550
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-direct-reply-log-'))
|
|
497
551
|
const deps = {
|
|
498
552
|
...makeDeps(io, client),
|
|
553
|
+
env: {
|
|
554
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
555
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
556
|
+
},
|
|
499
557
|
now: () => now,
|
|
500
558
|
sleep: async (milliseconds: number) => {
|
|
501
559
|
now += milliseconds
|
|
502
560
|
},
|
|
503
561
|
}
|
|
562
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
504
563
|
|
|
505
564
|
expect(
|
|
506
565
|
await sendCommand(deps, {
|
|
@@ -513,6 +572,14 @@ describe('command contracts', () => {
|
|
|
513
572
|
expect(polls).toHaveLength(3)
|
|
514
573
|
expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
|
|
515
574
|
expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
|
|
575
|
+
const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
|
|
576
|
+
expect(received).toHaveLength(1)
|
|
577
|
+
expect(received[0]?.data).toMatchObject({
|
|
578
|
+
request_id: receipt.request_id,
|
|
579
|
+
sequence: reply.seq,
|
|
580
|
+
device: reply.device_name,
|
|
581
|
+
text: reply.text,
|
|
582
|
+
})
|
|
516
583
|
})
|
|
517
584
|
|
|
518
585
|
it('backs off and retries a transient network error while waiting', async () => {
|
|
@@ -549,6 +616,97 @@ describe('command contracts', () => {
|
|
|
549
616
|
expect(sleeps).toEqual([250])
|
|
550
617
|
})
|
|
551
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Regression context: Provider Acceptance and Companion Receipt succeeded,
|
|
621
|
+
* then a long-poll returned HTTP 500 internal_error and the blocking wait aborted
|
|
622
|
+
* while the reply window was still open. The answer arrived ~1 minute later
|
|
623
|
+
* and was recovered only on the next turn. Transient server faults must not
|
|
624
|
+
* end the wait; permanent client errors still may.
|
|
625
|
+
*/
|
|
626
|
+
it('keeps blocking through a transient internal_error on the replies poll', async () => {
|
|
627
|
+
const io = new CapturedIo()
|
|
628
|
+
let now = 0
|
|
629
|
+
let replyCalls = 0
|
|
630
|
+
const client = {
|
|
631
|
+
submit: async () => receipt,
|
|
632
|
+
replies: async () => {
|
|
633
|
+
replyCalls += 1
|
|
634
|
+
if (replyCalls <= 2) {
|
|
635
|
+
throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
|
|
636
|
+
}
|
|
637
|
+
return replyResponse([reply])
|
|
638
|
+
},
|
|
639
|
+
} as unknown as ApiClient
|
|
640
|
+
const deps = {
|
|
641
|
+
...makeDeps(io, client),
|
|
642
|
+
now: () => now,
|
|
643
|
+
sleep: async (milliseconds: number) => {
|
|
644
|
+
now += milliseconds
|
|
645
|
+
},
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
expect(
|
|
649
|
+
await sendCommand(deps, {
|
|
650
|
+
title: 'Question',
|
|
651
|
+
body: 'Deploy?',
|
|
652
|
+
reply: true,
|
|
653
|
+
replyTimeout: 30,
|
|
654
|
+
}),
|
|
655
|
+
).toBe(EXIT.ok)
|
|
656
|
+
expect(replyCalls).toBe(3)
|
|
657
|
+
expect(io.outLines.at(-1)).toBe('reply from iPhone: yes, after the migration')
|
|
658
|
+
expect(io.errLines.join('\n')).not.toContain('internal_error')
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('does not treat a permanent replies error as a transient wait fault', async () => {
|
|
662
|
+
const io = new CapturedIo()
|
|
663
|
+
let now = 0
|
|
664
|
+
let replyCalls = 0
|
|
665
|
+
const client = {
|
|
666
|
+
submit: async () => receipt,
|
|
667
|
+
replies: async () => {
|
|
668
|
+
replyCalls += 1
|
|
669
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
670
|
+
},
|
|
671
|
+
} as unknown as ApiClient
|
|
672
|
+
const deps: CommandDeps = {
|
|
673
|
+
...makeDeps(io, client),
|
|
674
|
+
now: () => now,
|
|
675
|
+
sleep: async (milliseconds: number) => {
|
|
676
|
+
now += milliseconds
|
|
677
|
+
},
|
|
678
|
+
}
|
|
679
|
+
deps.env = {
|
|
680
|
+
...deps.env,
|
|
681
|
+
XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-reply-wait-log-')),
|
|
682
|
+
}
|
|
683
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
684
|
+
|
|
685
|
+
expect(
|
|
686
|
+
await sendCommand(deps, {
|
|
687
|
+
title: 'Question',
|
|
688
|
+
body: 'Deploy?',
|
|
689
|
+
reply: true,
|
|
690
|
+
replyTimeout: 30,
|
|
691
|
+
}),
|
|
692
|
+
).toBe(EXIT.failed)
|
|
693
|
+
expect(replyCalls).toBe(1)
|
|
694
|
+
expect(io.errLines.join('\n')).toContain('reply wait failed')
|
|
695
|
+
expect(io.errLines.join('\n')).toContain(receipt.request_id)
|
|
696
|
+
expect(io.errLines.join('\n')).toContain('not_found')
|
|
697
|
+
// Receipt was already printed — the durable send is not the failure.
|
|
698
|
+
expect(io.outLines[0]).toContain(receipt.request_id)
|
|
699
|
+
const waitError = readLogRecords(deps.env, { event: ['cli.error'], limit: 20 }).records.find(
|
|
700
|
+
(record) => record.data?.['operation'] === 'reply_wait',
|
|
701
|
+
)
|
|
702
|
+
expect(waitError?.data).toMatchObject({
|
|
703
|
+
kind: 'api',
|
|
704
|
+
request_id: receipt.request_id,
|
|
705
|
+
status: 404,
|
|
706
|
+
code: 'not_found',
|
|
707
|
+
})
|
|
708
|
+
})
|
|
709
|
+
|
|
552
710
|
it('prints an NDJSON receipt before waiting and a result record on exit 3', async () => {
|
|
553
711
|
const io = new CapturedIo()
|
|
554
712
|
let now = 0
|
|
@@ -619,10 +777,18 @@ describe('command contracts', () => {
|
|
|
619
777
|
return replyResponse([reply])
|
|
620
778
|
},
|
|
621
779
|
} as unknown as ApiClient
|
|
780
|
+
const deps = makeDeps(io, client)
|
|
781
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-replies-log-'))
|
|
782
|
+
deps.env = {
|
|
783
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
784
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
785
|
+
}
|
|
786
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
622
787
|
|
|
623
|
-
expect(await repliesCommand(
|
|
788
|
+
expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
|
|
624
789
|
expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
|
|
625
790
|
expect(io.outLines).toEqual(['reply from iPhone: yes, after the migration'])
|
|
791
|
+
expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
|
|
626
792
|
})
|
|
627
793
|
|
|
628
794
|
it('resolves replies --pending through the project session pointer and prints its id', async () => {
|
|
@@ -678,6 +844,198 @@ describe('command contracts', () => {
|
|
|
678
844
|
expect(io.outLines[1]).toContain('yes, after the migration')
|
|
679
845
|
expect(io.outLines.join('\n')).toContain('req_second')
|
|
680
846
|
})
|
|
847
|
+
|
|
848
|
+
it('keeps the failing request identity when one pending reply wait throws', async () => {
|
|
849
|
+
const io = new CapturedIo()
|
|
850
|
+
const client = {
|
|
851
|
+
replies: async (requestId: string) => {
|
|
852
|
+
if (requestId === 'req_failed') {
|
|
853
|
+
throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
|
|
854
|
+
}
|
|
855
|
+
return replyResponse([reply])
|
|
856
|
+
},
|
|
857
|
+
} as unknown as ApiClient
|
|
858
|
+
const deps = makeDeps(io, client)
|
|
859
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-error-log-'))
|
|
860
|
+
deps.cwd = root
|
|
861
|
+
deps.env = {
|
|
862
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
863
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
864
|
+
}
|
|
865
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
866
|
+
writeSessionState('pending-error-log', deps.env, {
|
|
867
|
+
pending: [
|
|
868
|
+
{ question: 'First?', request_id: 'req_healthy' },
|
|
869
|
+
{ question: 'Second?', request_id: 'req_failed' },
|
|
870
|
+
{ question: 'Third?', request_id: 'req_unreached' },
|
|
871
|
+
],
|
|
872
|
+
})
|
|
873
|
+
writeProjectSession(root, deps.env, 'pending-error-log', Date.now(), 'codex')
|
|
874
|
+
|
|
875
|
+
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.failed)
|
|
876
|
+
|
|
877
|
+
io.outLines = []
|
|
878
|
+
io.errLines = []
|
|
879
|
+
expect(logsCommand(deps, { request: 'req_failed', json: true, allProjects: true })).toBe(
|
|
880
|
+
EXIT.ok,
|
|
881
|
+
)
|
|
882
|
+
expect(io.outLines).toHaveLength(1)
|
|
883
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
884
|
+
event: 'cli.error',
|
|
885
|
+
data: { request_id: 'req_failed', operation: 'reply_wait' },
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
io.outLines = []
|
|
889
|
+
expect(logsCommand(deps, { request: 'req_healthy', json: true, allProjects: true })).toBe(
|
|
890
|
+
EXIT.ok,
|
|
891
|
+
)
|
|
892
|
+
expect(io.outLines).toHaveLength(1)
|
|
893
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
894
|
+
event: 'reply.received',
|
|
895
|
+
data: { request_id: 'req_healthy' },
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
io.outLines = []
|
|
899
|
+
expect(logsCommand(deps, { request: 'req_unreached', json: true, allProjects: true })).toBe(
|
|
900
|
+
EXIT.ok,
|
|
901
|
+
)
|
|
902
|
+
expect(io.outLines).toEqual([])
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
it('attributes a degraded multi-request wait only to the request whose polls failed', async () => {
|
|
906
|
+
const io = new CapturedIo()
|
|
907
|
+
let now = 0
|
|
908
|
+
let failedPolls = 0
|
|
909
|
+
const client = {
|
|
910
|
+
replies: async (requestId: string) => {
|
|
911
|
+
if (requestId !== 'req_degraded') return replyResponse([reply])
|
|
912
|
+
failedPolls += 1
|
|
913
|
+
if (failedPolls === 1) return replyResponse([])
|
|
914
|
+
throw new NetworkError('temporary disconnect')
|
|
915
|
+
},
|
|
916
|
+
} as unknown as ApiClient
|
|
917
|
+
const deps = makeDeps(io, client)
|
|
918
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-degraded-log-'))
|
|
919
|
+
deps.cwd = root
|
|
920
|
+
deps.env = {
|
|
921
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
922
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
923
|
+
}
|
|
924
|
+
deps.now = () => now
|
|
925
|
+
deps.sleep = async (milliseconds: number) => {
|
|
926
|
+
now += milliseconds
|
|
927
|
+
}
|
|
928
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
929
|
+
writeSessionState('pending-degraded-log', deps.env, {
|
|
930
|
+
pending: [
|
|
931
|
+
{ question: 'First?', request_id: 'req_healthy_before' },
|
|
932
|
+
{ question: 'Second?', request_id: 'req_degraded' },
|
|
933
|
+
{ question: 'Third?', request_id: 'req_healthy_after' },
|
|
934
|
+
],
|
|
935
|
+
})
|
|
936
|
+
writeProjectSession(root, deps.env, 'pending-degraded-log', Date.now(), 'codex')
|
|
937
|
+
|
|
938
|
+
expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.network)
|
|
939
|
+
|
|
940
|
+
io.outLines = []
|
|
941
|
+
io.errLines = []
|
|
942
|
+
expect(logsCommand(deps, { request: 'req_degraded', json: true, allProjects: true })).toBe(
|
|
943
|
+
EXIT.ok,
|
|
944
|
+
)
|
|
945
|
+
expect(io.outLines).toHaveLength(1)
|
|
946
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
947
|
+
event: 'cli.error',
|
|
948
|
+
data: { request_ids: ['req_degraded'], operation: 'reply_wait' },
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
for (const requestId of ['req_healthy_before', 'req_healthy_after']) {
|
|
952
|
+
io.outLines = []
|
|
953
|
+
expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
|
|
954
|
+
EXIT.ok,
|
|
955
|
+
)
|
|
956
|
+
expect(io.outLines).toHaveLength(1)
|
|
957
|
+
expect(JSON.parse(io.outLines[0]!)).toMatchObject({
|
|
958
|
+
event: 'reply.received',
|
|
959
|
+
data: { request_id: requestId },
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
it('records earlier degraded requests before a later pending wait throws', async () => {
|
|
965
|
+
const io = new CapturedIo()
|
|
966
|
+
let now = 0
|
|
967
|
+
let degradedPolls = 0
|
|
968
|
+
const client = {
|
|
969
|
+
replies: async (requestId: string) => {
|
|
970
|
+
if (requestId === 'req_success') return replyResponse([reply])
|
|
971
|
+
if (requestId === 'req_degraded') {
|
|
972
|
+
degradedPolls += 1
|
|
973
|
+
if (degradedPolls === 1) return replyResponse([])
|
|
974
|
+
throw new NetworkError('temporary disconnect')
|
|
975
|
+
}
|
|
976
|
+
if (requestId === 'req_failed') {
|
|
977
|
+
throw new ApiCallError(404, 'not_found', 'That notification request does not exist.')
|
|
978
|
+
}
|
|
979
|
+
return replyResponse([reply])
|
|
980
|
+
},
|
|
981
|
+
} as unknown as ApiClient
|
|
982
|
+
const deps = makeDeps(io, client)
|
|
983
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-mixed-log-'))
|
|
984
|
+
deps.cwd = root
|
|
985
|
+
deps.env = {
|
|
986
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
987
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
988
|
+
}
|
|
989
|
+
deps.now = () => now
|
|
990
|
+
deps.sleep = async (milliseconds: number) => {
|
|
991
|
+
now += milliseconds
|
|
992
|
+
}
|
|
993
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'replies' })
|
|
994
|
+
writeSessionState('pending-mixed-log', deps.env, {
|
|
995
|
+
pending: [
|
|
996
|
+
{ question: 'Healthy?', request_id: 'req_success' },
|
|
997
|
+
{ question: 'Degraded?', request_id: 'req_degraded' },
|
|
998
|
+
{ question: 'Gone?', request_id: 'req_failed' },
|
|
999
|
+
{ question: 'Unreached?', request_id: 'req_unreached' },
|
|
1000
|
+
],
|
|
1001
|
+
})
|
|
1002
|
+
writeProjectSession(root, deps.env, 'pending-mixed-log', Date.now(), 'codex')
|
|
1003
|
+
|
|
1004
|
+
expect(await repliesCommand(deps, undefined, { pending: true, wait: 1 })).toBe(EXIT.failed)
|
|
1005
|
+
|
|
1006
|
+
const recordsFor = (requestId: string): Array<Record<string, unknown>> => {
|
|
1007
|
+
io.outLines = []
|
|
1008
|
+
io.errLines = []
|
|
1009
|
+
expect(logsCommand(deps, { request: requestId, json: true, allProjects: true })).toBe(
|
|
1010
|
+
EXIT.ok,
|
|
1011
|
+
)
|
|
1012
|
+
return io.outLines.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
expect(recordsFor('req_success')).toEqual([
|
|
1016
|
+
expect.objectContaining({
|
|
1017
|
+
event: 'reply.received',
|
|
1018
|
+
data: expect.objectContaining({ request_id: 'req_success' }),
|
|
1019
|
+
}),
|
|
1020
|
+
])
|
|
1021
|
+
expect(recordsFor('req_degraded')).toEqual([
|
|
1022
|
+
expect.objectContaining({
|
|
1023
|
+
event: 'cli.error',
|
|
1024
|
+
data: expect.objectContaining({
|
|
1025
|
+
request_ids: ['req_degraded'],
|
|
1026
|
+
operation: 'reply_wait',
|
|
1027
|
+
degraded: true,
|
|
1028
|
+
}),
|
|
1029
|
+
}),
|
|
1030
|
+
])
|
|
1031
|
+
expect(recordsFor('req_failed')).toEqual([
|
|
1032
|
+
expect.objectContaining({
|
|
1033
|
+
event: 'cli.error',
|
|
1034
|
+
data: expect.objectContaining({ request_id: 'req_failed', operation: 'reply_wait' }),
|
|
1035
|
+
}),
|
|
1036
|
+
])
|
|
1037
|
+
expect(recordsFor('req_unreached')).toEqual([])
|
|
1038
|
+
})
|
|
681
1039
|
})
|
|
682
1040
|
|
|
683
1041
|
describe('delivery evidence status', () => {
|
|
@@ -734,6 +1092,8 @@ describe('delivery evidence status', () => {
|
|
|
734
1092
|
expect(said).toContain("Companion Receipt (the app's delivery confirmation): unknown")
|
|
735
1093
|
expect(said).toContain('not a failure')
|
|
736
1094
|
expect(said).toContain('attempt_started')
|
|
1095
|
+
expect(said).toContain('OS presentation: not observed by Notifai')
|
|
1096
|
+
expect(said).toContain('Reply wait:')
|
|
737
1097
|
})
|
|
738
1098
|
|
|
739
1099
|
it('reports the observed device receipt and measured provider-to-companion latency', async () => {
|
|
@@ -751,12 +1111,40 @@ describe('delivery evidence status', () => {
|
|
|
751
1111
|
const said = io.outLines.join('\n')
|
|
752
1112
|
expect(said).toContain("Companion Receipt (the app's delivery confirmation): observed")
|
|
753
1113
|
expect(said).toContain('11m 27s after Provider Acceptance')
|
|
1114
|
+
expect(said).toContain('OS presentation: not observed by Notifai')
|
|
1115
|
+
expect(said).toContain('Reply received: not yet recorded')
|
|
1116
|
+
expect(said).toContain('Reply wait: no answer stored yet')
|
|
1117
|
+
})
|
|
1118
|
+
|
|
1119
|
+
it('separates a stored reply from Delivery so a wait fault is not misread as non-delivery', async () => {
|
|
1120
|
+
const io = new CapturedIo()
|
|
1121
|
+
const base = snapshot({
|
|
1122
|
+
state: 'observed',
|
|
1123
|
+
observed_at: '2026-08-05T13:17:17.000Z',
|
|
1124
|
+
latency_ms: 1_000,
|
|
1125
|
+
})
|
|
1126
|
+
base.deliveries[0]!.events.push({
|
|
1127
|
+
stage: 'reply_received',
|
|
1128
|
+
source: 'companion',
|
|
1129
|
+
reason: null,
|
|
1130
|
+
attempt: null,
|
|
1131
|
+
occurred_at: '2026-08-05T13:18:00.000Z',
|
|
1132
|
+
})
|
|
1133
|
+
const client = { evidence: async () => base } as unknown as ApiClient
|
|
1134
|
+
|
|
1135
|
+
expect(await statusCommand(makeDeps(io, client), 'req_status_test', {})).toBe(EXIT.ok)
|
|
1136
|
+
const said = io.outLines.join('\n')
|
|
1137
|
+
expect(said).toContain('Provider Acceptance: accepted')
|
|
1138
|
+
expect(said).toContain('Companion Receipt')
|
|
1139
|
+
expect(said).toContain('OS presentation: not observed by Notifai')
|
|
1140
|
+
expect(said).toContain('Reply received: yes')
|
|
1141
|
+
expect(said).toContain('answers are on the server')
|
|
754
1142
|
})
|
|
755
1143
|
})
|
|
756
1144
|
|
|
757
1145
|
describe('Cursor hook commands', () => {
|
|
758
|
-
const execPath =
|
|
759
|
-
const scriptPath =
|
|
1146
|
+
const execPath = process.execPath
|
|
1147
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
760
1148
|
|
|
761
1149
|
it('installs native Cursor hooks with bounded chained answer continuations', () => {
|
|
762
1150
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
|
|
@@ -850,8 +1238,8 @@ describe('Cursor hook commands', () => {
|
|
|
850
1238
|
})
|
|
851
1239
|
|
|
852
1240
|
describe('harness activation guidance', () => {
|
|
853
|
-
const execPath =
|
|
854
|
-
const scriptPath =
|
|
1241
|
+
const execPath = process.execPath
|
|
1242
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
855
1243
|
|
|
856
1244
|
it('does not require a Claude Code restart for project hook files', () => {
|
|
857
1245
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
|
|
@@ -881,7 +1269,7 @@ describe('harness activation guidance', () => {
|
|
|
881
1269
|
expect(output).not.toMatch(/trust|approve/i)
|
|
882
1270
|
})
|
|
883
1271
|
|
|
884
|
-
it('keeps OpenCode permission prompts local and reports
|
|
1272
|
+
it('keeps OpenCode permission prompts local and reports unsupported continuation', async () => {
|
|
885
1273
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-activation-'))
|
|
886
1274
|
const io = new CapturedIo()
|
|
887
1275
|
const client = {
|
|
@@ -889,21 +1277,33 @@ describe('harness activation guidance', () => {
|
|
|
889
1277
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
890
1278
|
listDevices: async () => ({ devices: [] }),
|
|
891
1279
|
} as unknown as ApiClient
|
|
892
|
-
const
|
|
1280
|
+
const base = makeDeps(io, client)
|
|
1281
|
+
const deps = {
|
|
1282
|
+
...base,
|
|
1283
|
+
cwd,
|
|
1284
|
+
env: {
|
|
1285
|
+
...base.env,
|
|
1286
|
+
HOME: path.join(cwd, 'home'),
|
|
1287
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
1288
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
1289
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
1290
|
+
},
|
|
1291
|
+
}
|
|
893
1292
|
|
|
894
1293
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
|
|
895
1294
|
EXIT.ok,
|
|
896
1295
|
)
|
|
897
1296
|
|
|
898
1297
|
expect(io.outLines.join('\n')).toContain('Permission prompts stay in OpenCode.')
|
|
899
|
-
expect(io.outLines.join('\n')).toContain('
|
|
1298
|
+
expect(io.outLines.join('\n')).toContain('does not expose a proven exactly-once continuation')
|
|
900
1299
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
901
1300
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
902
1301
|
expect(plugin).toContain('const TIMEOUT_MS = 540000')
|
|
903
1302
|
|
|
904
1303
|
io.outLines = []
|
|
905
1304
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
906
|
-
expect(io.outLines.join('\n')).toContain('hooks (
|
|
1305
|
+
expect(io.outLines.join('\n')).toContain('hooks (answer continuation)')
|
|
1306
|
+
expect(io.outLines.join('\n')).toContain('no proven answer continuation')
|
|
907
1307
|
expect(io.outLines.join('\n')).not.toContain('hooks (adapter)')
|
|
908
1308
|
|
|
909
1309
|
writeFileSync(pluginFile, plugin.replace(/^const ADAPTER_VERSION = .*\n/m, ''))
|
|
@@ -912,6 +1312,107 @@ describe('harness activation guidance', () => {
|
|
|
912
1312
|
expect(io.outLines.join('\n')).toContain('hooks (adapter)')
|
|
913
1313
|
expect(io.outLines.join('\n')).toContain('obsolete OpenCode event wiring')
|
|
914
1314
|
})
|
|
1315
|
+
|
|
1316
|
+
it('refuses a symlinked OpenCode plugin without touching its target', () => {
|
|
1317
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-symlink-'))
|
|
1318
|
+
const plugin = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
1319
|
+
const target = path.join(cwd, 'foreign.js')
|
|
1320
|
+
mkdirSync(path.dirname(plugin), { recursive: true })
|
|
1321
|
+
writeFileSync(target, '// notifai managed opencode plugin\nleave me\n')
|
|
1322
|
+
symlinkSync(target, plugin)
|
|
1323
|
+
const io = new CapturedIo()
|
|
1324
|
+
const deps = {
|
|
1325
|
+
...makeDeps(io, {} as ApiClient),
|
|
1326
|
+
cwd,
|
|
1327
|
+
env: { XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
|
|
1331
|
+
EXIT.failed,
|
|
1332
|
+
)
|
|
1333
|
+
expect(io.errLines.join('\n')).toMatch(/not a regular file/)
|
|
1334
|
+
expect(readFileSync(target, 'utf8')).toBe('// notifai managed opencode plugin\nleave me\n')
|
|
1335
|
+
// Adapter preparation commits first. A definition failure leaves a valid
|
|
1336
|
+
// shared adapter (useful to any existing harness) and never half-writes the
|
|
1337
|
+
// rejected definition.
|
|
1338
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).problems).toEqual([])
|
|
1339
|
+
expect(inspectHookAdapter(deps.hookAdapterHome).target?.scriptPath).toBe(scriptPath)
|
|
1340
|
+
})
|
|
1341
|
+
})
|
|
1342
|
+
|
|
1343
|
+
describe('stable hook installation', () => {
|
|
1344
|
+
it('keeps definition bytes stable across config, CLI target, and project/global changes', async () => {
|
|
1345
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-stable-hook-install-'))
|
|
1346
|
+
const firstCli = path.join(cwd, 'first cli.js')
|
|
1347
|
+
const secondCli = path.join(cwd, 'second cli.js')
|
|
1348
|
+
writeFileSync(firstCli, '')
|
|
1349
|
+
writeFileSync(secondCli, '')
|
|
1350
|
+
const io = new CapturedIo()
|
|
1351
|
+
const env = {
|
|
1352
|
+
HOME: path.join(cwd, 'home'),
|
|
1353
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
1354
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
1355
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
1356
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
1357
|
+
}
|
|
1358
|
+
const client = {
|
|
1359
|
+
health: async () => true,
|
|
1360
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
1361
|
+
listDevices: async () => ({ devices: [] }),
|
|
1362
|
+
} as unknown as ApiClient
|
|
1363
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
1364
|
+
const local = path.join(cwd, '.claude', 'settings.local.json')
|
|
1365
|
+
|
|
1366
|
+
expect(
|
|
1367
|
+
hooksInstallCommand(deps, {
|
|
1368
|
+
harness: 'claude-code',
|
|
1369
|
+
execPath: process.execPath,
|
|
1370
|
+
scriptPath: firstCli,
|
|
1371
|
+
}),
|
|
1372
|
+
).toBe(EXIT.ok)
|
|
1373
|
+
const firstDefinition = readFileSync(local, 'utf8')
|
|
1374
|
+
const firstAdapter = readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')
|
|
1375
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
1376
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
|
|
1377
|
+
env.XDG_CONFIG_HOME = path.join(cwd, 'different-config')
|
|
1378
|
+
env.XDG_STATE_HOME = path.join(cwd, 'different-state')
|
|
1379
|
+
|
|
1380
|
+
expect(
|
|
1381
|
+
hooksInstallCommand(deps, {
|
|
1382
|
+
harness: 'claude-code',
|
|
1383
|
+
execPath: process.execPath,
|
|
1384
|
+
scriptPath: secondCli,
|
|
1385
|
+
}),
|
|
1386
|
+
).toBe(EXIT.ok)
|
|
1387
|
+
expect(readFileSync(local, 'utf8')).toBe(firstDefinition)
|
|
1388
|
+
expect(readFileSync(hookAdapterPath(deps.hookAdapterHome), 'utf8')).not.toBe(firstAdapter)
|
|
1389
|
+
expect(firstDefinition).toContain(hookAdapterPath(deps.hookAdapterHome))
|
|
1390
|
+
expect(firstDefinition).not.toContain(firstCli)
|
|
1391
|
+
expect(firstDefinition).not.toContain(secondCli)
|
|
1392
|
+
|
|
1393
|
+
expect(
|
|
1394
|
+
hooksInstallCommand(deps, {
|
|
1395
|
+
harness: 'claude-code',
|
|
1396
|
+
global: true,
|
|
1397
|
+
execPath: process.execPath,
|
|
1398
|
+
scriptPath: secondCli,
|
|
1399
|
+
}),
|
|
1400
|
+
).toBe(EXIT.ok)
|
|
1401
|
+
const global = readFileSync(path.join(env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')
|
|
1402
|
+
expect(global).toBe(firstDefinition)
|
|
1403
|
+
|
|
1404
|
+
io.outLines = []
|
|
1405
|
+
await doctorCommand(deps, {})
|
|
1406
|
+
expect(io.outLines.join('\n')).toMatch(/hooks \(duplicates\).*project or global routing/is)
|
|
1407
|
+
|
|
1408
|
+
expect(hooksUninstallCommand(deps, { harness: 'claude-code' })).toBe(EXIT.ok)
|
|
1409
|
+
expect(existsSync(hookAdapterPath(deps.hookAdapterHome))).toBe(true)
|
|
1410
|
+
expect(
|
|
1411
|
+
findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
1412
|
+
(item) => item.harness === 'claude-code',
|
|
1413
|
+
),
|
|
1414
|
+
).toHaveLength(1)
|
|
1415
|
+
})
|
|
915
1416
|
})
|
|
916
1417
|
|
|
917
1418
|
describe('projectSlugFrom', () => {
|
|
@@ -923,15 +1424,165 @@ describe('projectSlugFrom', () => {
|
|
|
923
1424
|
})
|
|
924
1425
|
})
|
|
925
1426
|
|
|
926
|
-
describe('
|
|
927
|
-
function
|
|
928
|
-
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-
|
|
929
|
-
return {
|
|
1427
|
+
describe('logs', () => {
|
|
1428
|
+
function logDeps(io: CapturedIo): CommandDeps {
|
|
1429
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-logs-cmd-'))
|
|
1430
|
+
return {
|
|
1431
|
+
...makeDeps(io, {} as ApiClient),
|
|
1432
|
+
cwd,
|
|
1433
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg'), XDG_STATE_HOME: path.join(cwd, 'state') },
|
|
1434
|
+
}
|
|
930
1435
|
}
|
|
931
1436
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1437
|
+
function seed(deps: CommandDeps, project?: string): void {
|
|
1438
|
+
const logger = createLogger({ env: deps.env, runId: 'r_seed', cmd: 'send' })
|
|
1439
|
+
if (project !== undefined) logger.bind({ project })
|
|
1440
|
+
logger.info('send.submitted', { request_id: 'req_seeded' })
|
|
1441
|
+
logger.error('cli.error', { message: 'the server said no' })
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
it('shows the recent records, newest last', () => {
|
|
1445
|
+
const io = new CapturedIo()
|
|
1446
|
+
const deps = logDeps(io)
|
|
1447
|
+
seed(deps)
|
|
1448
|
+
expect(logsCommand(deps, {})).toBe(EXIT.ok)
|
|
1449
|
+
expect(io.outLines).toHaveLength(2)
|
|
1450
|
+
expect(io.outLines[0]).toContain('send.submitted')
|
|
1451
|
+
expect(io.outLines[1]).toContain('cli.error')
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
it('keeps stdout pure JSONL under --json so a parser needs no filtering', () => {
|
|
1455
|
+
// Every word of explanation goes to stderr. A machine reading stdout gets
|
|
1456
|
+
// records and nothing else.
|
|
1457
|
+
const io = new CapturedIo()
|
|
1458
|
+
const deps = logDeps(io)
|
|
1459
|
+
seed(deps)
|
|
1460
|
+
expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
|
|
1461
|
+
for (const line of io.outLines) {
|
|
1462
|
+
expect(() => JSON.parse(line) as unknown).not.toThrow()
|
|
1463
|
+
}
|
|
1464
|
+
expect(io.outLines).toHaveLength(2)
|
|
1465
|
+
})
|
|
1466
|
+
|
|
1467
|
+
it('narrows to the failures', () => {
|
|
1468
|
+
const io = new CapturedIo()
|
|
1469
|
+
const deps = logDeps(io)
|
|
1470
|
+
seed(deps)
|
|
1471
|
+
expect(logsCommand(deps, { level: 'error', json: true })).toBe(EXIT.ok)
|
|
1472
|
+
expect(io.outLines).toHaveLength(1)
|
|
1473
|
+
expect(io.outLines[0]).toContain('cli.error')
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
it('narrows to one notification request', () => {
|
|
1477
|
+
const io = new CapturedIo()
|
|
1478
|
+
const deps = logDeps(io)
|
|
1479
|
+
seed(deps)
|
|
1480
|
+
expect(logsCommand(deps, { request: 'req_seeded', json: true })).toBe(EXIT.ok)
|
|
1481
|
+
expect(io.outLines).toHaveLength(1)
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1484
|
+
it('scopes to this project unless told otherwise', () => {
|
|
1485
|
+
const io = new CapturedIo()
|
|
1486
|
+
const deps = logDeps(io)
|
|
1487
|
+
mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
|
|
1488
|
+
writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'project = "mine"\n')
|
|
1489
|
+
seed(deps, 'mine')
|
|
1490
|
+
seed(deps, 'someone-elses')
|
|
1491
|
+
|
|
1492
|
+
// A machine commonly runs several agents in several worktrees at once, and
|
|
1493
|
+
// an answer interleaved with three other projects answers nothing.
|
|
1494
|
+
expect(logsCommand(deps, { json: true })).toBe(EXIT.ok)
|
|
1495
|
+
expect(io.outLines).toHaveLength(2)
|
|
1496
|
+
io.outLines.length = 0
|
|
1497
|
+
expect(logsCommand(deps, { json: true, allProjects: true })).toBe(EXIT.ok)
|
|
1498
|
+
expect(io.outLines).toHaveLength(4)
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
it('rejects an unreadable time, unknown event, and invalid limits', () => {
|
|
1502
|
+
const io = new CapturedIo()
|
|
1503
|
+
const deps = logDeps(io)
|
|
1504
|
+
expect(logsCommand(deps, { since: 'yesterday-ish' })).toBe(EXIT.usage)
|
|
1505
|
+
expect(io.errLines.join('\n')).toContain('10m')
|
|
1506
|
+
io.errLines.length = 0
|
|
1507
|
+
expect(logsCommand(deps, { event: ['send.exploded'] })).toBe(EXIT.usage)
|
|
1508
|
+
expect(io.errLines.join('\n')).toContain('send.submitted')
|
|
1509
|
+
io.errLines.length = 0
|
|
1510
|
+
expect(logsCommand(deps, { limit: Number.NaN })).toBe(EXIT.usage)
|
|
1511
|
+
expect(io.errLines.join('\n')).toContain('--limit')
|
|
1512
|
+
io.errLines.length = 0
|
|
1513
|
+
expect(logsCommand(deps, { limit: 2_001 })).toBe(EXIT.usage)
|
|
1514
|
+
expect(io.errLines.join('\n')).toContain('cannot exceed 2000')
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
it.each([
|
|
1518
|
+
[{ path: true, clear: true }, '--path'],
|
|
1519
|
+
[{ path: true, request: 'req_seeded' }, '--path'],
|
|
1520
|
+
[{ clear: true, since: '1d' }, '--clear'],
|
|
1521
|
+
[{ project: 'mine', allProjects: true }, '--project'],
|
|
1522
|
+
[{ limit: 10, all: true }, '--limit'],
|
|
1523
|
+
] as const)('rejects incompatible log options %j', (flags, namedFlag) => {
|
|
1524
|
+
const io = new CapturedIo()
|
|
1525
|
+
expect(logsCommand(logDeps(io), flags)).toBe(EXIT.usage)
|
|
1526
|
+
expect(io.errLines.join('\n')).toContain(namedFlag)
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
it('accepts relative and absolute times', () => {
|
|
1530
|
+
const now = Date.parse('2026-08-11T12:00:00Z')
|
|
1531
|
+
expect(parseSince('10m', now)).toBe(now - 600_000)
|
|
1532
|
+
expect(parseSince('2h', now)).toBe(now - 7_200_000)
|
|
1533
|
+
expect(parseSince('1d', now)).toBe(now - 86_400_000)
|
|
1534
|
+
expect(parseSince('2026-08-11T11:00:00Z', now)).toBe(Date.parse('2026-08-11T11:00:00Z'))
|
|
1535
|
+
expect(parseSince('whenever', now)).toBeNull()
|
|
1536
|
+
})
|
|
1537
|
+
|
|
1538
|
+
it('says why it is empty, and what to do about it', () => {
|
|
1539
|
+
// "No records" with no explanation sends the reader looking for a bug that
|
|
1540
|
+
// is actually a setting.
|
|
1541
|
+
const io = new CapturedIo()
|
|
1542
|
+
const deps = logDeps(io)
|
|
1543
|
+
mkdirSync(path.join(deps.cwd, '.notifai'), { recursive: true })
|
|
1544
|
+
writeFileSync(path.join(deps.cwd, '.notifai', 'config.toml'), 'log_level = "off"\n')
|
|
1545
|
+
expect(logsCommand(deps, {})).toBe(EXIT.ok)
|
|
1546
|
+
expect(io.errLines.join('\n')).toContain('log_level is off')
|
|
1547
|
+
expect(io.errLines.join('\n')).toContain('config set log_level info')
|
|
1548
|
+
})
|
|
1549
|
+
|
|
1550
|
+
it('points at the files, and says they stay here', () => {
|
|
1551
|
+
const io = new CapturedIo()
|
|
1552
|
+
const deps = logDeps(io)
|
|
1553
|
+
seed(deps)
|
|
1554
|
+
expect(logsCommand(deps, { path: true })).toBe(EXIT.ok)
|
|
1555
|
+
expect(io.outLines[0]).toContain('notifai.jsonl')
|
|
1556
|
+
expect(io.errLines.join('\n')).toContain('stay on this machine')
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1559
|
+
it('reports local_only in the machine-readable form', () => {
|
|
1560
|
+
const io = new CapturedIo()
|
|
1561
|
+
const deps = logDeps(io)
|
|
1562
|
+
expect(logsCommand(deps, { path: true, json: true })).toBe(EXIT.ok)
|
|
1563
|
+
const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
1564
|
+
expect(parsed['local_only']).toBe(true)
|
|
1565
|
+
expect(parsed['level']).toBe('info')
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
it('clears the files when asked', () => {
|
|
1569
|
+
const io = new CapturedIo()
|
|
1570
|
+
const deps = logDeps(io)
|
|
1571
|
+
seed(deps)
|
|
1572
|
+
expect(logsCommand(deps, { clear: true })).toBe(EXIT.ok)
|
|
1573
|
+
expect(logsDiskUsage(deps.env).files).toBe(0)
|
|
1574
|
+
})
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
describe('config surfaces', () => {
|
|
1578
|
+
function configDeps(io: CapturedIo): CommandDeps {
|
|
1579
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-surface-'))
|
|
1580
|
+
return { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') } }
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
it('gives an agent the same flat key = value output it always had', () => {
|
|
1584
|
+
// The whole point of gating the readable rendering on a human terminal.
|
|
1585
|
+
// Anything parsing this today must keep parsing it tomorrow.
|
|
935
1586
|
const io = new CapturedIo()
|
|
936
1587
|
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
937
1588
|
expect(io.outLines).toHaveLength(CONFIG_KEYS.length)
|
|
@@ -943,8 +1594,8 @@ describe('config surfaces', () => {
|
|
|
943
1594
|
const io = new InteractiveIo()
|
|
944
1595
|
expect(configShowCommand(configDeps(io), {})).toBe(EXIT.ok)
|
|
945
1596
|
const text = io.outLines.join('\n')
|
|
946
|
-
expect(text).toContain('Questions
|
|
947
|
-
expect(text).toContain('
|
|
1597
|
+
expect(text).toContain('Questions')
|
|
1598
|
+
expect(text).toContain('Optional delay before a question may reach your devices')
|
|
948
1599
|
})
|
|
949
1600
|
|
|
950
1601
|
it('keeps --plain available to a human who wants the parseable form', () => {
|
|
@@ -955,17 +1606,18 @@ describe('config surfaces', () => {
|
|
|
955
1606
|
|
|
956
1607
|
it('explains one setting, and says so in JSON when asked', () => {
|
|
957
1608
|
const io = new InteractiveIo()
|
|
958
|
-
expect(configExplainCommand(configDeps(io), '
|
|
1609
|
+
expect(configExplainCommand(configDeps(io), 'ask_notifications', { json: true })).toBe(EXIT.ok)
|
|
959
1610
|
const parsed = JSON.parse(io.outLines.join('\n')) as Record<string, unknown>
|
|
960
|
-
expect(parsed['key']).toBe('
|
|
1611
|
+
expect(parsed['key']).toBe('ask_notifications')
|
|
961
1612
|
expect(parsed['accepts']).toBe('true or false')
|
|
962
|
-
expect(parsed['detail']).toContain('
|
|
1613
|
+
expect(parsed['detail']).toContain('The master switch')
|
|
1614
|
+
expect(parsed['detail']).toContain('Turn it off')
|
|
963
1615
|
})
|
|
964
1616
|
|
|
965
1617
|
it('rejects an unknown setting and points at the nearest real one', () => {
|
|
966
1618
|
const io = new CapturedIo()
|
|
967
|
-
expect(configExplainCommand(configDeps(io), '
|
|
968
|
-
expect(io.errLines[0]).toBe('Unknown setting "
|
|
1619
|
+
expect(configExplainCommand(configDeps(io), 'ask_notification')).toBe(EXIT.usage)
|
|
1620
|
+
expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
|
|
969
1621
|
})
|
|
970
1622
|
|
|
971
1623
|
it('refuses an enum value the sender would later reject', async () => {
|
|
@@ -982,6 +1634,49 @@ describe('config surfaces', () => {
|
|
|
982
1634
|
const io = new CapturedIo()
|
|
983
1635
|
expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
|
|
984
1636
|
})
|
|
1637
|
+
|
|
1638
|
+
it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
|
|
1639
|
+
const io = new CapturedIo()
|
|
1640
|
+
const deps = configDeps(io)
|
|
1641
|
+
const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
1642
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
1643
|
+
writeFileSync(configFile, 'wait_seconds = 20\nsound = "done"\n')
|
|
1644
|
+
|
|
1645
|
+
expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
|
|
1646
|
+
|
|
1647
|
+
const remaining = readFileSync(configFile, 'utf8')
|
|
1648
|
+
expect(remaining).toContain('sound = "done"')
|
|
1649
|
+
expect(remaining).not.toContain('wait_seconds')
|
|
1650
|
+
const resolved = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1651
|
+
expect(resolved.wait_seconds).toEqual({ value: 10, source: 'default' })
|
|
1652
|
+
expect(resolved.sound.source).toMatch(/^global:/)
|
|
1653
|
+
})
|
|
1654
|
+
|
|
1655
|
+
it('refuses to create a redundant machine override equal to the shipped default', async () => {
|
|
1656
|
+
const io = new CapturedIo()
|
|
1657
|
+
const deps = configDeps(io)
|
|
1658
|
+
|
|
1659
|
+
expect(await configSetCommand(deps, 'wait_seconds', '10', { yes: true })).toBe(EXIT.usage)
|
|
1660
|
+
expect(io.errLines.join('\n')).toContain('already the shipped default')
|
|
1661
|
+
expect(io.errLines.join('\n')).toContain('notifai config unset wait_seconds')
|
|
1662
|
+
expect(existsSync(path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml'))).toBe(false)
|
|
1663
|
+
})
|
|
1664
|
+
|
|
1665
|
+
it('allows a project to choose the shipped value when it masks a machine override', async () => {
|
|
1666
|
+
const io = new CapturedIo()
|
|
1667
|
+
const deps = configDeps(io)
|
|
1668
|
+
const globalFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
1669
|
+
mkdirSync(path.dirname(globalFile), { recursive: true })
|
|
1670
|
+
writeFileSync(globalFile, 'wait_seconds = 20\n')
|
|
1671
|
+
|
|
1672
|
+
expect(await configSetCommand(deps, 'wait_seconds', '10', { project: true, yes: true })).toBe(
|
|
1673
|
+
EXIT.ok,
|
|
1674
|
+
)
|
|
1675
|
+
expect(loadConfig({ cwd: deps.cwd, env: deps.env }).wait_seconds).toMatchObject({
|
|
1676
|
+
value: 10,
|
|
1677
|
+
source: expect.stringMatching(/^project:/),
|
|
1678
|
+
})
|
|
1679
|
+
})
|
|
985
1680
|
})
|
|
986
1681
|
|
|
987
1682
|
describe('interactive command UX', () => {
|
|
@@ -1099,6 +1794,25 @@ describe('interactive command UX', () => {
|
|
|
1099
1794
|
)
|
|
1100
1795
|
})
|
|
1101
1796
|
|
|
1797
|
+
it('asks the same layer question when returning a setting to its inherited value', async () => {
|
|
1798
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-unset-layer-'))
|
|
1799
|
+
const io = new InteractiveIo()
|
|
1800
|
+
io.selectAnswer = 'local'
|
|
1801
|
+
const localFile = path.join(cwd, '.notifai', 'config.local.toml')
|
|
1802
|
+
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
1803
|
+
writeFileSync(localFile, 'sound = "done"\n')
|
|
1804
|
+
const deps = {
|
|
1805
|
+
...makeDeps(io, {} as ApiClient),
|
|
1806
|
+
cwd,
|
|
1807
|
+
env: { XDG_CONFIG_HOME: path.join(cwd, 'xdg') },
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
expect(await configUnsetCommand(deps, 'sound', {})).toBe(EXIT.ok)
|
|
1811
|
+
expect(io.prompts[0]).toBe('Where should this setting live?')
|
|
1812
|
+
expect(io.prompts[1]).toContain(localFile)
|
|
1813
|
+
expect(existsSync(localFile)).toBe(false)
|
|
1814
|
+
})
|
|
1815
|
+
|
|
1102
1816
|
it('rejects numeric config values that resolution would otherwise silently clamp', async () => {
|
|
1103
1817
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-config-bounds-'))
|
|
1104
1818
|
const io = new CapturedIo()
|
|
@@ -1116,10 +1830,10 @@ describe('interactive command UX', () => {
|
|
|
1116
1830
|
EXIT.usage,
|
|
1117
1831
|
)
|
|
1118
1832
|
expect(io.errLines).toEqual([
|
|
1119
|
-
'ask_grace_seconds must be between 0 and
|
|
1833
|
+
'ask_grace_seconds must be between 0 and 360.',
|
|
1120
1834
|
// Names the key and its range: `"1.5" is not an integer` left the reader
|
|
1121
1835
|
// to work out which of the two settings they had just mistyped.
|
|
1122
|
-
'ask_grace_seconds takes a whole number from 0s–
|
|
1836
|
+
'ask_grace_seconds takes a whole number from 0s–360s, not "1.5".',
|
|
1123
1837
|
])
|
|
1124
1838
|
expect(existsSync(configFile)).toBe(false)
|
|
1125
1839
|
})
|
|
@@ -1260,7 +1974,7 @@ describe('init', () => {
|
|
|
1260
1974
|
source: 'Raidiant-io/notifai',
|
|
1261
1975
|
sourceType: 'github',
|
|
1262
1976
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1263
|
-
ref: 'v0.
|
|
1977
|
+
ref: 'v0.5.0',
|
|
1264
1978
|
}
|
|
1265
1979
|
}
|
|
1266
1980
|
|
|
@@ -1288,7 +2002,7 @@ describe('init', () => {
|
|
|
1288
2002
|
return {
|
|
1289
2003
|
...makeDeps(io, client),
|
|
1290
2004
|
cwd,
|
|
1291
|
-
env:
|
|
2005
|
+
env: isolatedEnv(cwd),
|
|
1292
2006
|
nativeSkills,
|
|
1293
2007
|
}
|
|
1294
2008
|
}
|
|
@@ -1363,7 +2077,7 @@ describe('init', () => {
|
|
|
1363
2077
|
})
|
|
1364
2078
|
|
|
1365
2079
|
it('pins the skill installer to the tagged public release syntax', () => {
|
|
1366
|
-
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.
|
|
2080
|
+
expect(SKILLS_SOURCE).toBe('Raidiant-io/notifai#v0.5.0')
|
|
1367
2081
|
expect(SKILLS_SOURCE).not.toContain('@v')
|
|
1368
2082
|
})
|
|
1369
2083
|
|
|
@@ -1386,7 +2100,7 @@ describe('init', () => {
|
|
|
1386
2100
|
source: 'Raidiant-io/notifai',
|
|
1387
2101
|
sourceType: 'github',
|
|
1388
2102
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
1389
|
-
ref: 'v0.
|
|
2103
|
+
ref: 'v0.5.0',
|
|
1390
2104
|
},
|
|
1391
2105
|
],
|
|
1392
2106
|
}),
|
|
@@ -1618,7 +2332,7 @@ describe('init', () => {
|
|
|
1618
2332
|
const deps: CommandDeps = {
|
|
1619
2333
|
...makeDeps(io, { health: async () => true } as unknown as ApiClient),
|
|
1620
2334
|
cwd,
|
|
1621
|
-
env:
|
|
2335
|
+
env: isolatedEnv(cwd),
|
|
1622
2336
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
1623
2337
|
}
|
|
1624
2338
|
|
|
@@ -1643,7 +2357,7 @@ describe('init', () => {
|
|
|
1643
2357
|
const deps = {
|
|
1644
2358
|
...makeDeps(io, client),
|
|
1645
2359
|
cwd,
|
|
1646
|
-
env:
|
|
2360
|
+
env: isolatedEnv(cwd),
|
|
1647
2361
|
}
|
|
1648
2362
|
|
|
1649
2363
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
@@ -1716,7 +2430,7 @@ describe('init', () => {
|
|
|
1716
2430
|
const deps = {
|
|
1717
2431
|
...makeDeps(io, client),
|
|
1718
2432
|
cwd,
|
|
1719
|
-
env:
|
|
2433
|
+
env: isolatedEnv(cwd),
|
|
1720
2434
|
}
|
|
1721
2435
|
|
|
1722
2436
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
@@ -1758,7 +2472,7 @@ describe('init', () => {
|
|
|
1758
2472
|
const deps: CommandDeps = {
|
|
1759
2473
|
...makeDeps(io, client),
|
|
1760
2474
|
cwd,
|
|
1761
|
-
env:
|
|
2475
|
+
env: isolatedEnv(cwd),
|
|
1762
2476
|
now: () => now,
|
|
1763
2477
|
sleep: async (milliseconds) => {
|
|
1764
2478
|
now += milliseconds
|
|
@@ -1824,7 +2538,7 @@ describe('init', () => {
|
|
|
1824
2538
|
const deps: CommandDeps = {
|
|
1825
2539
|
...makeDeps(io, client),
|
|
1826
2540
|
cwd,
|
|
1827
|
-
env:
|
|
2541
|
+
env: isolatedEnv(cwd),
|
|
1828
2542
|
now: () => now,
|
|
1829
2543
|
sleep: async (milliseconds) => {
|
|
1830
2544
|
now += milliseconds
|
|
@@ -1864,7 +2578,7 @@ describe('init', () => {
|
|
|
1864
2578
|
const deps = {
|
|
1865
2579
|
...makeDeps(io, client),
|
|
1866
2580
|
cwd,
|
|
1867
|
-
env:
|
|
2581
|
+
env: isolatedEnv(cwd),
|
|
1868
2582
|
}
|
|
1869
2583
|
|
|
1870
2584
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
@@ -1908,7 +2622,7 @@ describe('init', () => {
|
|
|
1908
2622
|
const deps: CommandDeps = {
|
|
1909
2623
|
...makeDeps(io, client),
|
|
1910
2624
|
cwd,
|
|
1911
|
-
env:
|
|
2625
|
+
env: isolatedEnv(cwd),
|
|
1912
2626
|
now: () => now,
|
|
1913
2627
|
sleep: async (milliseconds) => {
|
|
1914
2628
|
now += milliseconds
|
|
@@ -1954,7 +2668,7 @@ describe('init', () => {
|
|
|
1954
2668
|
const deps = {
|
|
1955
2669
|
...makeDeps(io, client),
|
|
1956
2670
|
cwd,
|
|
1957
|
-
env: {
|
|
2671
|
+
env: { ...isolatedEnv(cwd), XDG_STATE_HOME: '/dev/null' },
|
|
1958
2672
|
}
|
|
1959
2673
|
|
|
1960
2674
|
await expect(initCommand(deps, { hooks: false, skills: false })).resolves.toBe(EXIT.failed)
|
|
@@ -1981,7 +2695,7 @@ describe('init', () => {
|
|
|
1981
2695
|
const deps = {
|
|
1982
2696
|
...makeDeps(io, client),
|
|
1983
2697
|
cwd,
|
|
1984
|
-
env:
|
|
2698
|
+
env: isolatedEnv(cwd),
|
|
1985
2699
|
}
|
|
1986
2700
|
|
|
1987
2701
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
@@ -1999,7 +2713,7 @@ describe('init', () => {
|
|
|
1999
2713
|
{
|
|
2000
2714
|
...makeDeps(doctorIo, client),
|
|
2001
2715
|
cwd,
|
|
2002
|
-
env:
|
|
2716
|
+
env: isolatedEnv(cwd),
|
|
2003
2717
|
},
|
|
2004
2718
|
{},
|
|
2005
2719
|
),
|
|
@@ -2059,7 +2773,7 @@ describe('init', () => {
|
|
|
2059
2773
|
const deps = {
|
|
2060
2774
|
...makeDeps(io, client),
|
|
2061
2775
|
cwd,
|
|
2062
|
-
env:
|
|
2776
|
+
env: isolatedEnv(cwd),
|
|
2063
2777
|
}
|
|
2064
2778
|
|
|
2065
2779
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
@@ -2095,7 +2809,7 @@ describe('init', () => {
|
|
|
2095
2809
|
const deps = {
|
|
2096
2810
|
...makeDeps(io, client),
|
|
2097
2811
|
cwd,
|
|
2098
|
-
env: { XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
|
|
2812
|
+
env: { ...isolatedEnv(cwd), XDG_CONFIG_HOME: stateRoot, XDG_STATE_HOME: stateRoot },
|
|
2099
2813
|
}
|
|
2100
2814
|
expect(
|
|
2101
2815
|
await initCommand(deps, { projectId: 'shared-project', hooks: false, skills: false }),
|
|
@@ -2114,7 +2828,9 @@ describe('an outage is not an answer', () => {
|
|
|
2114
2828
|
* agent scripted to read exit 3 as "nobody objected" would proceed against a
|
|
2115
2829
|
* refusal it never saw.
|
|
2116
2830
|
*/
|
|
2117
|
-
function outageAfterFirstPoll(io: CapturedIo
|
|
2831
|
+
function outageAfterFirstPoll(io: CapturedIo, fault: () => never = () => {
|
|
2832
|
+
throw new NetworkError('link went down')
|
|
2833
|
+
}): CommandDeps {
|
|
2118
2834
|
let now = 0
|
|
2119
2835
|
let polls = 0
|
|
2120
2836
|
const client = {
|
|
@@ -2122,7 +2838,7 @@ describe('an outage is not an answer', () => {
|
|
|
2122
2838
|
replies: async () => {
|
|
2123
2839
|
polls += 1
|
|
2124
2840
|
if (polls === 1) return replyResponse([])
|
|
2125
|
-
|
|
2841
|
+
fault()
|
|
2126
2842
|
},
|
|
2127
2843
|
} as unknown as ApiClient
|
|
2128
2844
|
return {
|
|
@@ -2148,6 +2864,27 @@ describe('an outage is not an answer', () => {
|
|
|
2148
2864
|
expect(exit).not.toBe(EXIT.noReply)
|
|
2149
2865
|
expect(exit).toBe(EXIT.network)
|
|
2150
2866
|
expect(io.errLines.join('\n')).toContain('could not find out')
|
|
2867
|
+
expect(io.errLines.join('\n')).toContain(receipt.request_id)
|
|
2868
|
+
})
|
|
2869
|
+
|
|
2870
|
+
it('treats a late internal_error outage the same as a network outage', async () => {
|
|
2871
|
+
const io = new CapturedIo()
|
|
2872
|
+
const exit = await sendCommand(
|
|
2873
|
+
outageAfterFirstPoll(io, () => {
|
|
2874
|
+
throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
|
|
2875
|
+
}),
|
|
2876
|
+
{
|
|
2877
|
+
title: 'Question',
|
|
2878
|
+
body: 'Deploy to production?',
|
|
2879
|
+
reply: true,
|
|
2880
|
+
replyTimeout: 10,
|
|
2881
|
+
},
|
|
2882
|
+
)
|
|
2883
|
+
|
|
2884
|
+
expect(exit).not.toBe(EXIT.noReply)
|
|
2885
|
+
expect(exit).toBe(EXIT.network)
|
|
2886
|
+
expect(io.errLines.join('\n')).toContain('could not find out')
|
|
2887
|
+
expect(io.errLines.join('\n')).toContain('Provider Acceptance')
|
|
2151
2888
|
})
|
|
2152
2889
|
|
|
2153
2890
|
it('marks the JSON so an agent reading it programmatically can tell', async () => {
|
|
@@ -2186,8 +2923,8 @@ describe('an outage is not an answer', () => {
|
|
|
2186
2923
|
})
|
|
2187
2924
|
|
|
2188
2925
|
describe('asking before the hooks have ever run', () => {
|
|
2189
|
-
const execPath =
|
|
2190
|
-
const scriptPath =
|
|
2926
|
+
const execPath = process.execPath
|
|
2927
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
2191
2928
|
|
|
2192
2929
|
it('names the active Codex harness instead of unrelated installed adapters', () => {
|
|
2193
2930
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
|
|
@@ -2230,7 +2967,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2230
2967
|
io.outLines = []
|
|
2231
2968
|
|
|
2232
2969
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2233
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
2970
|
+
expect(io.errLines.join(' ')).toMatch(/active Codex session:.*has not published/i)
|
|
2234
2971
|
expect(io.outLines).not.toContain(
|
|
2235
2972
|
'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
2236
2973
|
)
|
|
@@ -2248,16 +2985,230 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2248
2985
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2249
2986
|
|
|
2250
2987
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2251
|
-
|
|
2988
|
+
trustInstalledCodexHooks(cwd, env)
|
|
2989
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
2252
2990
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
2991
|
+
writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
|
|
2992
|
+
writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
|
|
2253
2993
|
io.outLines = []
|
|
2254
2994
|
|
|
2255
2995
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
2256
2996
|
expect(io.outLines).toContain(
|
|
2257
|
-
'Question registered. Ask it in the conversation
|
|
2997
|
+
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
2998
|
+
)
|
|
2999
|
+
})
|
|
3000
|
+
|
|
3001
|
+
// A harness exports its markers into everything it starts, so a nested
|
|
3002
|
+
// harness sees its parent's markers alongside its own. Neither order between
|
|
3003
|
+
// two markers can be right, and both nestings are ordinary: an orchestrator
|
|
3004
|
+
// running inside Claude Code starts Codex, and the reverse happens just as
|
|
3005
|
+
// often. The environment cannot settle it; the pointer index can.
|
|
3006
|
+
it('routes to the live Codex thread when a Claude Code orchestrator started it', () => {
|
|
3007
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-under-claude-'))
|
|
3008
|
+
const io = new CapturedIo()
|
|
3009
|
+
const env = {
|
|
3010
|
+
...isolatedEnv(cwd),
|
|
3011
|
+
// Inherited from the parent Claude Code process, and unstrippable.
|
|
3012
|
+
CLAUDECODE: '1',
|
|
3013
|
+
CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
|
|
3014
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3015
|
+
}
|
|
3016
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3017
|
+
|
|
3018
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3019
|
+
// The configuration that made this unrecoverable: Claude Code hooks
|
|
3020
|
+
// installed machine-wide match every directory, including this one.
|
|
3021
|
+
expect(
|
|
3022
|
+
hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
|
|
3023
|
+
).toBe(EXIT.ok)
|
|
3024
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3025
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3026
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
3027
|
+
io.outLines = []
|
|
3028
|
+
|
|
3029
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3030
|
+
expect(readSessionState('codex-current-thread', env).pending?.[0]?.question).toBe('Ship it?')
|
|
3031
|
+
expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
|
|
3032
|
+
})
|
|
3033
|
+
|
|
3034
|
+
it('routes to the live Claude Code session when a Codex orchestrator started it', () => {
|
|
3035
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-under-codex-'))
|
|
3036
|
+
const io = new CapturedIo()
|
|
3037
|
+
const env = {
|
|
3038
|
+
...isolatedEnv(cwd),
|
|
3039
|
+
CODEX_THREAD_ID: 'codex-orchestrator',
|
|
3040
|
+
CLAUDECODE: '1',
|
|
3041
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
3042
|
+
}
|
|
3043
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3044
|
+
|
|
3045
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
3046
|
+
EXIT.ok,
|
|
3047
|
+
)
|
|
3048
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3049
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3050
|
+
// Both sessions are live here. The parent fired when its own turn began;
|
|
3051
|
+
// the child fired for the turn that is running this command.
|
|
3052
|
+
writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
|
|
3053
|
+
writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
|
|
3054
|
+
writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3055
|
+
writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
|
|
3056
|
+
io.outLines = []
|
|
3057
|
+
|
|
3058
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
3059
|
+
expect(readSessionState('claude-current', env).pending?.[0]?.question).toBe('Ship it?')
|
|
3060
|
+
expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
|
|
3061
|
+
})
|
|
3062
|
+
|
|
3063
|
+
it('judges the fired check against the active harness, not a global installation of another', async () => {
|
|
3064
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-active-harness-'))
|
|
3065
|
+
const io = new CapturedIo()
|
|
3066
|
+
const env = {
|
|
3067
|
+
...isolatedEnv(cwd),
|
|
3068
|
+
CLAUDECODE: '1',
|
|
3069
|
+
CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
|
|
3070
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3071
|
+
}
|
|
3072
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3073
|
+
|
|
3074
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3075
|
+
expect(
|
|
3076
|
+
hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
|
|
3077
|
+
).toBe(EXIT.ok)
|
|
3078
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3079
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3080
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
3081
|
+
|
|
3082
|
+
const readiness = await assessReadiness(deps)
|
|
3083
|
+
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
3084
|
+
// The Codex hooks fired. A missing Claude Code pointer says nothing about
|
|
3085
|
+
// that, and telling this agent to send a Claude Code prompt is advice it
|
|
3086
|
+
// cannot act on: it would refuse to ask, forever.
|
|
3087
|
+
expect(fired?.status).toBe('ready')
|
|
3088
|
+
expect(fired?.detail).toMatch(/active Codex session/i)
|
|
3089
|
+
expect(fired?.detail).not.toMatch(/Claude Code/i)
|
|
3090
|
+
})
|
|
3091
|
+
|
|
3092
|
+
it('advises every harness whose markers are present when none of them has fired here', async () => {
|
|
3093
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-fired-contested-'))
|
|
3094
|
+
const io = new CapturedIo()
|
|
3095
|
+
const env = {
|
|
3096
|
+
...isolatedEnv(cwd),
|
|
3097
|
+
CLAUDECODE: '1',
|
|
3098
|
+
CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
|
|
3099
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3100
|
+
}
|
|
3101
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3102
|
+
|
|
3103
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3104
|
+
expect(
|
|
3105
|
+
hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
|
|
3106
|
+
).toBe(EXIT.ok)
|
|
3107
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3108
|
+
|
|
3109
|
+
const readiness = await assessReadiness(deps)
|
|
3110
|
+
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
3111
|
+
expect(fired?.status).toBe('optional-gap')
|
|
3112
|
+
expect(fired?.detail).toMatch(/Claude Code/)
|
|
3113
|
+
expect(fired?.detail).toMatch(/Codex: send one prompt/)
|
|
3114
|
+
|
|
3115
|
+
io.errLines = []
|
|
3116
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3117
|
+
expect(io.errLines.join(' ')).toMatch(/Several harness markers are present/i)
|
|
3118
|
+
})
|
|
3119
|
+
|
|
3120
|
+
it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
|
|
3121
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-commitment-'))
|
|
3122
|
+
const io = new CapturedIo()
|
|
3123
|
+
const env = {
|
|
3124
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3125
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3126
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3127
|
+
CODEX_THREAD_ID: 'codex-commitment-thread',
|
|
3128
|
+
}
|
|
3129
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3130
|
+
|
|
3131
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3132
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3133
|
+
writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3134
|
+
writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
|
|
3135
|
+
io.outLines = []
|
|
3136
|
+
|
|
3137
|
+
expect(
|
|
3138
|
+
askCommand(deps, 'Which environment should I deploy to?', {
|
|
3139
|
+
choice: ['Staging', 'Production', 'Cancel'],
|
|
3140
|
+
}),
|
|
3141
|
+
).toBe(EXIT.ok)
|
|
3142
|
+
|
|
3143
|
+
const said = io.outLines.join('\n')
|
|
3144
|
+
expect(said).toContain('Before ending this turn')
|
|
3145
|
+
for (const answer of ['Staging', 'Production', 'Cancel']) {
|
|
3146
|
+
expect(said).toContain(`If the answer is "${answer}"`)
|
|
3147
|
+
}
|
|
3148
|
+
expect(said).toContain('unexpected typed answer')
|
|
3149
|
+
expect(said).toContain('work you will resume')
|
|
3150
|
+
expect(said).toContain('without asking the user to confirm again')
|
|
3151
|
+
expect(said).toContain('not as approval')
|
|
3152
|
+
expect(said).toContain('cannot answer a harness permission prompt or interactive picker')
|
|
3153
|
+
})
|
|
3154
|
+
|
|
3155
|
+
it('requires a concrete fallback for free-text answers before the turn ends', () => {
|
|
3156
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ask-free-text-'))
|
|
3157
|
+
const io = new CapturedIo()
|
|
3158
|
+
const env = {
|
|
3159
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3160
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3161
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3162
|
+
CODEX_THREAD_ID: 'codex-free-text-thread',
|
|
3163
|
+
}
|
|
3164
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3165
|
+
|
|
3166
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3167
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3168
|
+
writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3169
|
+
writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
|
|
3170
|
+
io.outLines = []
|
|
3171
|
+
|
|
3172
|
+
expect(askCommand(deps, 'What rollout adjustment should I make?', {})).toBe(EXIT.ok)
|
|
3173
|
+
expect(io.outLines.join('\n')).toContain(
|
|
3174
|
+
'For the free-text answer: state how its content will determine the concrete work you resume.',
|
|
2258
3175
|
)
|
|
2259
3176
|
})
|
|
2260
3177
|
|
|
3178
|
+
it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
|
|
3179
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-stale-trust-'))
|
|
3180
|
+
const io = new CapturedIo()
|
|
3181
|
+
const env = {
|
|
3182
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3183
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3184
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3185
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3186
|
+
}
|
|
3187
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3188
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3189
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3190
|
+
const stop = findInstallations(cwd, env)
|
|
3191
|
+
.find((installation) => installation.harness === 'codex')
|
|
3192
|
+
?.handlers.find((handler) => handler.event === 'Stop')
|
|
3193
|
+
expect(stop).toBeDefined()
|
|
3194
|
+
const configFile = path.join(env.CODEX_HOME, 'config.toml')
|
|
3195
|
+
writeFileSync(
|
|
3196
|
+
configFile,
|
|
3197
|
+
readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
|
|
3198
|
+
)
|
|
3199
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3200
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
3201
|
+
io.outLines = []
|
|
3202
|
+
|
|
3203
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3204
|
+
expect(io.errLines.join('\n')).toMatch(/Stop.*changed since it was trusted.*\/hooks/is)
|
|
3205
|
+
|
|
3206
|
+
io.outLines = []
|
|
3207
|
+
await doctorCommand(deps, {})
|
|
3208
|
+
expect(io.outLines.join('\n')).toMatch(/FAIL\s+hooks \(trust\).*Stop/is)
|
|
3209
|
+
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
3210
|
+
})
|
|
3211
|
+
|
|
2261
3212
|
it('gives doctor the same active-Codex diagnosis as ask', async () => {
|
|
2262
3213
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-doctor-'))
|
|
2263
3214
|
const io = new CapturedIo()
|
|
@@ -2286,7 +3237,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2286
3237
|
source: 'Raidiant-io/notifai',
|
|
2287
3238
|
sourceType: 'github',
|
|
2288
3239
|
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
2289
|
-
ref: 'v0.
|
|
3240
|
+
ref: 'v0.5.0',
|
|
2290
3241
|
},
|
|
2291
3242
|
]
|
|
2292
3243
|
: [],
|
|
@@ -2323,7 +3274,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2323
3274
|
io.outLines = []
|
|
2324
3275
|
|
|
2325
3276
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2326
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
3277
|
+
expect(io.errLines.join(' ')).toMatch(/active Claude Code session:.*has not published/i)
|
|
2327
3278
|
})
|
|
2328
3279
|
|
|
2329
3280
|
it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
|
|
@@ -2341,7 +3292,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2341
3292
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2342
3293
|
EXIT.ok,
|
|
2343
3294
|
)
|
|
2344
|
-
writeSessionState('claude-parent-loop', env, { last_prompt_at: 42 })
|
|
3295
|
+
writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
2345
3296
|
writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
|
|
2346
3297
|
io.outLines = []
|
|
2347
3298
|
|
|
@@ -2349,6 +3300,80 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2349
3300
|
expect(readSessionState('claude-parent-loop', env).pending?.[0]?.question).toBe('Ship it?')
|
|
2350
3301
|
})
|
|
2351
3302
|
|
|
3303
|
+
it('fails closed when Claude Code does not expose an exact session id', () => {
|
|
3304
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-no-id-'))
|
|
3305
|
+
const io = new CapturedIo()
|
|
3306
|
+
const env = {
|
|
3307
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3308
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3309
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
3310
|
+
CLAUDECODE: '1',
|
|
3311
|
+
}
|
|
3312
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3313
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
3314
|
+
EXIT.ok,
|
|
3315
|
+
)
|
|
3316
|
+
writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3317
|
+
writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
|
|
3318
|
+
io.outLines = []
|
|
3319
|
+
|
|
3320
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3321
|
+
expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
|
|
3322
|
+
expect(readSessionState('claude-last-writer', env).pending).toBeUndefined()
|
|
3323
|
+
})
|
|
3324
|
+
|
|
3325
|
+
it('rejects duplicate active Codex definitions before registration', () => {
|
|
3326
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-duplicate-'))
|
|
3327
|
+
const io = new CapturedIo()
|
|
3328
|
+
const env = {
|
|
3329
|
+
HOME: path.join(cwd, 'home'),
|
|
3330
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3331
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3332
|
+
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3333
|
+
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3334
|
+
}
|
|
3335
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3336
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3337
|
+
expect(
|
|
3338
|
+
hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
|
|
3339
|
+
).toBe(EXIT.ok)
|
|
3340
|
+
trustInstalledCodexHooks(cwd, env)
|
|
3341
|
+
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3342
|
+
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
3343
|
+
io.outLines = []
|
|
3344
|
+
|
|
3345
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3346
|
+
expect(io.errLines.join('\n')).toMatch(/2 Codex definitions are active/i)
|
|
3347
|
+
expect(readSessionState('codex-current-thread', env).pending).toBeUndefined()
|
|
3348
|
+
})
|
|
3349
|
+
|
|
3350
|
+
it('does not let UserPromptSubmit alone count as a working turn-end route', async () => {
|
|
3351
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-prompt-only-'))
|
|
3352
|
+
const io = new CapturedIo()
|
|
3353
|
+
const env = {
|
|
3354
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3355
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3356
|
+
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
3357
|
+
CLAUDECODE: '1',
|
|
3358
|
+
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
3359
|
+
}
|
|
3360
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3361
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
3362
|
+
EXIT.ok,
|
|
3363
|
+
)
|
|
3364
|
+
writeSessionState('claude-current', env, { last_prompt_at: 42 })
|
|
3365
|
+
writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
|
|
3366
|
+
io.outLines = []
|
|
3367
|
+
|
|
3368
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3369
|
+
expect(io.errLines.join('\n')).toMatch(/Stop hook has not been observed/)
|
|
3370
|
+
|
|
3371
|
+
const readiness = await assessReadiness(deps)
|
|
3372
|
+
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
3373
|
+
expect(fired?.status).toBe('gap')
|
|
3374
|
+
expect(fired?.detail).toMatch(/UserPromptSubmit.*Stop has not been observed/)
|
|
3375
|
+
})
|
|
3376
|
+
|
|
2352
3377
|
it('uses the OpenCode adapter marker instead of its config-directory variable', () => {
|
|
2353
3378
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-'))
|
|
2354
3379
|
const io = new CapturedIo()
|
|
@@ -2366,10 +3391,31 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2366
3391
|
io.outLines = []
|
|
2367
3392
|
|
|
2368
3393
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2369
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
3394
|
+
expect(io.errLines.join(' ')).toMatch(/active OpenCode session:.*has not published/i)
|
|
3395
|
+
})
|
|
3396
|
+
|
|
3397
|
+
it('rejects OpenCode before registration even with an exact matching pointer', () => {
|
|
3398
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-opencode-unsupported-'))
|
|
3399
|
+
const io = new CapturedIo()
|
|
3400
|
+
const env = {
|
|
3401
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3402
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3403
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
3404
|
+
NOTIFAI_ACTIVE_HARNESS: 'opencode',
|
|
3405
|
+
NOTIFAI_ACTIVE_SESSION_ID: 'opencode-current',
|
|
3406
|
+
}
|
|
3407
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3408
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3409
|
+
writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
3410
|
+
writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
|
|
3411
|
+
io.outLines = []
|
|
3412
|
+
|
|
3413
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3414
|
+
expect(io.errLines.join('\n')).toMatch(/no proven answer continuation/i)
|
|
3415
|
+
expect(readSessionState('opencode-current', env).pending).toBeUndefined()
|
|
2370
3416
|
})
|
|
2371
3417
|
|
|
2372
|
-
it('
|
|
3418
|
+
it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
|
|
2373
3419
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
|
|
2374
3420
|
const io = new CapturedIo()
|
|
2375
3421
|
const env = {
|
|
@@ -2379,12 +3425,13 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2379
3425
|
}
|
|
2380
3426
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
2381
3427
|
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2382
|
-
writeSessionState('cursor-live', env, { last_prompt_at: 42 })
|
|
3428
|
+
writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
2383
3429
|
writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
|
|
2384
3430
|
io.outLines = []
|
|
2385
3431
|
|
|
2386
|
-
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.
|
|
2387
|
-
expect(
|
|
3432
|
+
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
3433
|
+
expect(io.errLines.join('\n')).toMatch(/does not expose an exact session id/i)
|
|
3434
|
+
expect(readSessionState('cursor-live', env).pending).toBeUndefined()
|
|
2388
3435
|
})
|
|
2389
3436
|
|
|
2390
3437
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
@@ -2446,7 +3493,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2446
3493
|
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
2447
3494
|
writeFileSync(
|
|
2448
3495
|
path.join(cwd, '.notifai', 'config.local.toml'),
|
|
2449
|
-
'ask_notifications = false\
|
|
3496
|
+
'ask_notifications = false\nask_grace_seconds = 90\n',
|
|
2450
3497
|
)
|
|
2451
3498
|
const io = new CapturedIo()
|
|
2452
3499
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: {} }
|
|
@@ -2456,27 +3503,167 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2456
3503
|
const said = io.outLines.join('\n')
|
|
2457
3504
|
expect(said).toContain('Question routing settings:')
|
|
2458
3505
|
expect(said).toContain('ask_notifications=false (project-local:')
|
|
2459
|
-
expect(said).toContain('require_idle=false (project-local:')
|
|
2460
|
-
expect(said).toContain('away_after_seconds=45 (project-local:')
|
|
2461
3506
|
expect(said).toContain('ask_grace_seconds=90 (project-local:')
|
|
2462
|
-
|
|
3507
|
+
// Nothing about where the user is standing is listed, because nothing
|
|
3508
|
+
// about where they are standing decides anything any more.
|
|
3509
|
+
expect(said).not.toMatch(/require_idle|away_after_seconds|hook_reply_timeout_seconds/)
|
|
2463
3510
|
})
|
|
2464
3511
|
|
|
2465
|
-
it('
|
|
3512
|
+
it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
|
|
2466
3513
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
|
|
2467
3514
|
const io = new CapturedIo()
|
|
2468
|
-
const
|
|
2469
|
-
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3515
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2470
3516
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
2471
3517
|
EXIT.ok,
|
|
2472
3518
|
)
|
|
2473
3519
|
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
2474
|
-
writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds =
|
|
3520
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.local.toml'), 'ask_grace_seconds = 300\n')
|
|
2475
3521
|
io.outLines = []
|
|
2476
3522
|
|
|
2477
|
-
|
|
2478
|
-
expect(io.outLines.join('\n')).toMatch(/
|
|
2479
|
-
|
|
3523
|
+
await doctorCommand(deps, {})
|
|
3524
|
+
expect(io.outLines.join('\n')).toMatch(/ok\s+hooks \(stop shape\)/)
|
|
3525
|
+
})
|
|
3526
|
+
|
|
3527
|
+
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
3528
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
3529
|
+
const io = new CapturedIo()
|
|
3530
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
3531
|
+
// Exactly what every build before the asynchronous waiter wrote: a blocking
|
|
3532
|
+
// handler with the old ceiling. It looks installed and it fires, but the
|
|
3533
|
+
// waiter would hold the turn and then be killed at Claude's silent default.
|
|
3534
|
+
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
3535
|
+
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
3536
|
+
hooks: {
|
|
3537
|
+
Stop: [
|
|
3538
|
+
{
|
|
3539
|
+
hooks: [
|
|
3540
|
+
{
|
|
3541
|
+
type: 'command',
|
|
3542
|
+
command: `'${path.join(cwd, 'home', '.notifai', 'bin', 'hook-adapter')}' hook stop --owner notifai --harness claude-code`,
|
|
3543
|
+
timeout: 540,
|
|
3544
|
+
},
|
|
3545
|
+
],
|
|
3546
|
+
},
|
|
3547
|
+
],
|
|
3548
|
+
},
|
|
3549
|
+
})
|
|
3550
|
+
|
|
3551
|
+
await doctorCommand(deps, {})
|
|
3552
|
+
const out = io.outLines.join('\n')
|
|
3553
|
+
expect(out).toMatch(/FAIL\s+hooks \(stop shape\)/)
|
|
3554
|
+
expect(out).toContain('needs `async: true`')
|
|
3555
|
+
expect(out).toContain('kills the backgrounded waiter at its 600s default')
|
|
3556
|
+
})
|
|
3557
|
+
|
|
3558
|
+
/** A configured Claude Code project with one live session claiming `pid`. */
|
|
3559
|
+
function claudeWakeDeps(
|
|
3560
|
+
io: CapturedIo,
|
|
3561
|
+
descriptor: (pid: number) => unknown,
|
|
3562
|
+
): { deps: CommandDeps; cwd: string } {
|
|
3563
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-wake-'))
|
|
3564
|
+
const base = {
|
|
3565
|
+
...makeDeps(io, {} as ApiClient),
|
|
3566
|
+
cwd,
|
|
3567
|
+
env: { ...isolatedEnv(cwd), CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'sess-live' },
|
|
3568
|
+
claudeSourcePid: 4242,
|
|
3569
|
+
claudeWake: { readDescriptor: descriptor } as unknown as ClaudeWakeAdapters,
|
|
3570
|
+
}
|
|
3571
|
+
expect(hooksInstallCommand(base, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
3572
|
+
EXIT.ok,
|
|
3573
|
+
)
|
|
3574
|
+
io.outLines = []
|
|
3575
|
+
return { deps: base, cwd }
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
it('reports a live Claude session as reachable without connecting to its socket', async () => {
|
|
3579
|
+
const io = new CapturedIo()
|
|
3580
|
+
const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
|
|
3581
|
+
writeFileSync(socket, '')
|
|
3582
|
+
const { deps } = claudeWakeDeps(io, (pid) => ({
|
|
3583
|
+
pid,
|
|
3584
|
+
sessionId: 'sess-live',
|
|
3585
|
+
cwd: '/tmp',
|
|
3586
|
+
startedAt: 1,
|
|
3587
|
+
procStart: 'x',
|
|
3588
|
+
version: '2.1.228',
|
|
3589
|
+
peerProtocol: 1,
|
|
3590
|
+
messagingSocketPath: socket,
|
|
3591
|
+
status: 'idle',
|
|
3592
|
+
}))
|
|
3593
|
+
|
|
3594
|
+
await doctorCommand(deps, {})
|
|
3595
|
+
const out = io.outLines.join('\n')
|
|
3596
|
+
expect(out).toMatch(/ok\s+hooks \(wake route\)/)
|
|
3597
|
+
expect(out).toContain(socket)
|
|
3598
|
+
expect(out).toContain('an answer can start a turn here without you')
|
|
3599
|
+
// Doctor never widens the user's inbound policy to make Notifai work: the
|
|
3600
|
+
// poster is the session's own hook child and needs no such permission.
|
|
3601
|
+
expect(out).not.toContain('crossSessionInbound')
|
|
3602
|
+
})
|
|
3603
|
+
|
|
3604
|
+
it('names a --bare session as the reason an answer would wait for the next turn', async () => {
|
|
3605
|
+
const io = new CapturedIo()
|
|
3606
|
+
const { deps } = claudeWakeDeps(io, () => {
|
|
3607
|
+
throw new Error('ENOENT: no such file or directory')
|
|
3608
|
+
})
|
|
3609
|
+
|
|
3610
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed) // unreachable test server
|
|
3611
|
+
const out = io.outLines.join('\n')
|
|
3612
|
+
// Honest, and not a blocker: nothing is lost, it simply arrives later.
|
|
3613
|
+
expect(out).toMatch(/--\s+hooks \(wake route\)/)
|
|
3614
|
+
expect(out).toContain('`--bare` binds no inbox socket')
|
|
3615
|
+
expect(out).toContain("at this session's next turn")
|
|
3616
|
+
expect(out).not.toMatch(/FAIL\s+hooks \(wake route\)/)
|
|
3617
|
+
})
|
|
3618
|
+
|
|
3619
|
+
it('fails closed on an inbox protocol it does not recognise', async () => {
|
|
3620
|
+
const io = new CapturedIo()
|
|
3621
|
+
const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
|
|
3622
|
+
writeFileSync(socket, '')
|
|
3623
|
+
const { deps } = claudeWakeDeps(io, (pid) => ({
|
|
3624
|
+
pid,
|
|
3625
|
+
sessionId: 'sess-live',
|
|
3626
|
+
cwd: '/tmp',
|
|
3627
|
+
startedAt: 1,
|
|
3628
|
+
procStart: 'x',
|
|
3629
|
+
version: '2.1.228',
|
|
3630
|
+
peerProtocol: 7,
|
|
3631
|
+
messagingSocketPath: socket,
|
|
3632
|
+
status: 'idle',
|
|
3633
|
+
}))
|
|
3634
|
+
|
|
3635
|
+
await doctorCommand(deps, {})
|
|
3636
|
+
const out = io.outLines.join('\n')
|
|
3637
|
+
expect(out).toContain('speaks inbox protocol 7')
|
|
3638
|
+
expect(out).toContain('refusing to guess at an undocumented wire format')
|
|
3639
|
+
})
|
|
3640
|
+
|
|
3641
|
+
it('reports Codex resume readiness from its writer-lock directory alone', async () => {
|
|
3642
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
|
|
3643
|
+
const io = new CapturedIo()
|
|
3644
|
+
const env = {
|
|
3645
|
+
...isolatedEnv(cwd),
|
|
3646
|
+
CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
|
|
3647
|
+
}
|
|
3648
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3649
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3650
|
+
|
|
3651
|
+
io.outLines = []
|
|
3652
|
+
await doctorCommand(deps, {})
|
|
3653
|
+
expect(io.outLines.join('\n')).toContain('nothing can be resumed: no thread-writer-lock')
|
|
3654
|
+
|
|
3655
|
+
mkdirSync(path.join(env['CODEX_HOME']!, 'thread-writer-locks'), { recursive: true })
|
|
3656
|
+
io.outLines = []
|
|
3657
|
+
await doctorCommand(deps, {})
|
|
3658
|
+
const out = io.outLines.join('\n')
|
|
3659
|
+
if (process.platform === 'darwin') {
|
|
3660
|
+
expect(out).toMatch(/ok\s+hooks \(wake route\)/)
|
|
3661
|
+
expect(out).toContain('can prove a stopped thread unowned before resuming it')
|
|
3662
|
+
} else {
|
|
3663
|
+
// Linux has no non-blocking `flock` through Node's `open(2)` flags, so
|
|
3664
|
+
// the gate stays shut and doctor says so rather than implying a wake.
|
|
3665
|
+
expect(out).toContain('nothing can be resumed')
|
|
3666
|
+
}
|
|
2480
3667
|
})
|
|
2481
3668
|
|
|
2482
3669
|
it('documents the failing exit contract in doctor JSON', async () => {
|
|
@@ -2494,10 +3681,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2494
3681
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
2495
3682
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
2496
3683
|
hooks: buildHookConfig({
|
|
2497
|
-
|
|
2498
|
-
scriptPath: '/opt/notifai/main.js',
|
|
2499
|
-
replyTimeoutSeconds: 180,
|
|
2500
|
-
graceSeconds: 300,
|
|
3684
|
+
adapterPath: path.join(cwd, 'notifai', 'hook-adapter'),
|
|
2501
3685
|
}),
|
|
2502
3686
|
})
|
|
2503
3687
|
|
|
@@ -2526,7 +3710,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2526
3710
|
expect(io.errLines.join(' ')).toMatch(/Cursor: send one prompt, then run `notifai doctor`/)
|
|
2527
3711
|
})
|
|
2528
3712
|
|
|
2529
|
-
it('keeps OpenCode activation separate from answer continuation', () => {
|
|
3713
|
+
it('keeps OpenCode activation separate from unsupported answer continuation', () => {
|
|
2530
3714
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
|
|
2531
3715
|
const io = new CapturedIo()
|
|
2532
3716
|
const deps = {
|
|
@@ -2544,7 +3728,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
2544
3728
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2545
3729
|
const said = io.errLines.join(' ')
|
|
2546
3730
|
expect(said).toMatch(/OpenCode: restart it, then send one prompt/)
|
|
2547
|
-
expect(said).toContain('
|
|
3731
|
+
expect(said).toContain('non-blocking question continuation is intentionally unsupported')
|
|
2548
3732
|
})
|
|
2549
3733
|
|
|
2550
3734
|
it('says to install when nothing is installed at all', () => {
|