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