@raidiant/notifai 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +17 -7
- package/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +151 -54
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +183 -12
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +50 -21
- package/dist/install-hooks.js +141 -57
- package/dist/install-hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +4 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +14 -3
- package/dist/main.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +11 -10
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +351 -30
- package/src/commands.ts +207 -49
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +175 -1
- package/src/hooks.ts +241 -12
- package/src/install-hooks.test.ts +174 -33
- package/src/install-hooks.ts +186 -54
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/skill.test.ts +32 -1
- package/src/ui/config-view.ts +5 -5
package/src/commands.test.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
2
11
|
import os from 'node:os'
|
|
3
12
|
import path from 'node:path'
|
|
4
13
|
import { fileURLToPath } from 'node:url'
|
|
@@ -10,15 +19,17 @@ import type {
|
|
|
10
19
|
SubmissionReceipt,
|
|
11
20
|
SubmitNotificationRequestT,
|
|
12
21
|
} from '@raidiant/notifai-protocol'
|
|
13
|
-
import { describe, expect, it } from 'vitest'
|
|
22
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
23
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
15
24
|
import type { ClaudeWakeAdapters } from './claude-wake.js'
|
|
16
25
|
import {
|
|
26
|
+
acknowledgeCommand,
|
|
17
27
|
askCommand,
|
|
18
28
|
accessStatusCommand,
|
|
19
29
|
buildQuestions,
|
|
20
30
|
assessReadiness,
|
|
21
31
|
capabilitiesCommand,
|
|
32
|
+
closeCommand,
|
|
22
33
|
configExplainCommand,
|
|
23
34
|
configSetCommand,
|
|
24
35
|
configShowCommand,
|
|
@@ -51,7 +62,7 @@ import {
|
|
|
51
62
|
} from './install-hooks.js'
|
|
52
63
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
53
64
|
import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
|
|
54
|
-
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath } from './config.js'
|
|
65
|
+
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
|
|
55
66
|
import { createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
56
67
|
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
57
68
|
import type { Tone } from './ui/theme.js'
|
|
@@ -161,6 +172,29 @@ class InteractiveIo extends CapturedIo {
|
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
|
|
175
|
+
const home = env['HOME']
|
|
176
|
+
if (home === undefined || home === '') {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'trustInstalledCodexHooks requires env.HOME; refusing to use the real home directory',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
const resolvedHome = path.resolve(home)
|
|
182
|
+
const tmpRoot = path.resolve(os.tmpdir())
|
|
183
|
+
const underTmp = resolvedHome === tmpRoot || resolvedHome.startsWith(`${tmpRoot}${path.sep}`)
|
|
184
|
+
if (!underTmp) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`trustInstalledCodexHooks will only write under ${tmpRoot}, not ${resolvedHome}`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
const codexHome = env['CODEX_HOME'] !== undefined && env['CODEX_HOME'] !== ''
|
|
190
|
+
? path.resolve(env['CODEX_HOME'])
|
|
191
|
+
: path.join(resolvedHome, '.codex')
|
|
192
|
+
if (codexHome !== tmpRoot && !codexHome.startsWith(`${tmpRoot}${path.sep}`)) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`trustInstalledCodexHooks will only write under ${tmpRoot}, not ${codexHome}`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
const file = path.join(codexHome, 'config.toml')
|
|
164
198
|
const installations = findInstallations(cwd, env).filter(
|
|
165
199
|
(installation) => installation.harness === 'codex',
|
|
166
200
|
)
|
|
@@ -170,9 +204,7 @@ function trustInstalledCodexHooks(cwd: string, env: NodeJS.ProcessEnv): void {
|
|
|
170
204
|
return `[hooks.state.${JSON.stringify(key)}]\ntrusted_hash = ${JSON.stringify(codexHookIdentityHash(handler))}\n`
|
|
171
205
|
}),
|
|
172
206
|
)
|
|
173
|
-
|
|
174
|
-
mkdirSync(codexHome, { recursive: true })
|
|
175
|
-
const file = path.join(codexHome, 'config.toml')
|
|
207
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
176
208
|
// Trust lives in the same config.toml as inline [hooks]. Overwriting the
|
|
177
209
|
// file would delete the global handlers this helper is trying to trust.
|
|
178
210
|
const existing = existsSync(file) ? readFileSync(file, 'utf8').trimEnd() : ''
|
|
@@ -219,6 +251,7 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
|
|
|
219
251
|
HOME: home,
|
|
220
252
|
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
|
221
253
|
CODEX_HOME: path.join(home, '.codex'),
|
|
254
|
+
OPENCODE_CONFIG_DIR: path.join(home, '.config', 'opencode'),
|
|
222
255
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
223
256
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
224
257
|
}
|
|
@@ -226,6 +259,8 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
|
|
|
226
259
|
|
|
227
260
|
const receipt: SubmissionReceipt = {
|
|
228
261
|
request_id: 'req_reply_test',
|
|
262
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
263
|
+
agent_acknowledgement_required: true,
|
|
229
264
|
replayed: false,
|
|
230
265
|
overall: 'provider_accepted_all',
|
|
231
266
|
deliveries: [
|
|
@@ -254,10 +289,18 @@ const reply: ReplyView = {
|
|
|
254
289
|
created_at: '2026-08-01T18:01:00.000Z',
|
|
255
290
|
}
|
|
256
291
|
|
|
257
|
-
function replyResponse(
|
|
292
|
+
function replyResponse(
|
|
293
|
+
replies: ReplyView[] = [],
|
|
294
|
+
options: {
|
|
295
|
+
required?: boolean
|
|
296
|
+
acknowledgement?: ListRepliesResponse['agent_acknowledgement']
|
|
297
|
+
} = {},
|
|
298
|
+
): ListRepliesResponse {
|
|
258
299
|
return {
|
|
259
300
|
request_id: receipt.request_id,
|
|
260
301
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
302
|
+
agent_acknowledgement_required: options.required ?? true,
|
|
303
|
+
agent_acknowledgement: options.acknowledgement ?? null,
|
|
261
304
|
replies,
|
|
262
305
|
}
|
|
263
306
|
}
|
|
@@ -604,7 +647,11 @@ describe('command contracts', () => {
|
|
|
604
647
|
).toBe(EXIT.ok)
|
|
605
648
|
expect(polls).toHaveLength(3)
|
|
606
649
|
expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
|
|
607
|
-
expect(io.outLines
|
|
650
|
+
expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
|
|
651
|
+
expect(io.outLines).toContain('Agent Acknowledgement required.')
|
|
652
|
+
expect(io.outLines.join('\n')).toContain(
|
|
653
|
+
`notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
654
|
+
)
|
|
608
655
|
const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
|
|
609
656
|
expect(received).toHaveLength(1)
|
|
610
657
|
expect(received[0]?.data).toMatchObject({
|
|
@@ -687,7 +734,11 @@ describe('command contracts', () => {
|
|
|
687
734
|
}),
|
|
688
735
|
).toBe(EXIT.ok)
|
|
689
736
|
expect(replyCalls).toBe(3)
|
|
690
|
-
expect(io.outLines
|
|
737
|
+
expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
|
|
738
|
+
expect(io.outLines).toContain('Agent Acknowledgement required.')
|
|
739
|
+
expect(io.outLines.join('\n')).toContain(
|
|
740
|
+
`notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
741
|
+
)
|
|
691
742
|
expect(io.errLines.join('\n')).not.toContain('internal_error')
|
|
692
743
|
})
|
|
693
744
|
|
|
@@ -768,9 +819,14 @@ describe('command contracts', () => {
|
|
|
768
819
|
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
769
820
|
type: 'reply_result',
|
|
770
821
|
request_id: receipt.request_id,
|
|
822
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
771
823
|
replies: [],
|
|
824
|
+
agent_acknowledgement_required: true,
|
|
825
|
+
agent_acknowledgement: null,
|
|
826
|
+
acknowledgement_command: null,
|
|
772
827
|
degraded: false,
|
|
773
828
|
})
|
|
829
|
+
expect(io.outLines).not.toContain('Agent Acknowledgement required.')
|
|
774
830
|
expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
|
|
775
831
|
expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
|
|
776
832
|
})
|
|
@@ -796,11 +852,227 @@ describe('command contracts', () => {
|
|
|
796
852
|
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
797
853
|
type: 'reply_result',
|
|
798
854
|
request_id: receipt.request_id,
|
|
855
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
799
856
|
replies: [reply],
|
|
857
|
+
agent_acknowledgement_required: true,
|
|
858
|
+
agent_acknowledgement: null,
|
|
859
|
+
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
800
860
|
degraded: false,
|
|
801
861
|
})
|
|
802
862
|
})
|
|
803
863
|
|
|
864
|
+
it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
|
|
865
|
+
const io = new CapturedIo()
|
|
866
|
+
let submitted: { requestId: string; text: string } | undefined
|
|
867
|
+
const client = {
|
|
868
|
+
putAgentAcknowledgement: async (requestId: string, body: { text: string }) => {
|
|
869
|
+
submitted = { requestId, text: body.text }
|
|
870
|
+
return {
|
|
871
|
+
status: 'recorded' as const,
|
|
872
|
+
agent_acknowledgement: {
|
|
873
|
+
text: body.text,
|
|
874
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
875
|
+
},
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
} as unknown as ApiClient
|
|
879
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-acknowledge-log-'))
|
|
880
|
+
const deps = makeDeps(io, client)
|
|
881
|
+
deps.env = {
|
|
882
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
883
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
884
|
+
}
|
|
885
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'acknowledge' })
|
|
886
|
+
|
|
887
|
+
expect(
|
|
888
|
+
await acknowledgeCommand(deps, receipt.request_id, {
|
|
889
|
+
text: ' I will deploy staging now. ',
|
|
890
|
+
json: true,
|
|
891
|
+
}),
|
|
892
|
+
).toBe(EXIT.ok)
|
|
893
|
+
expect(submitted).toEqual({
|
|
894
|
+
requestId: receipt.request_id,
|
|
895
|
+
text: 'I will deploy staging now.',
|
|
896
|
+
})
|
|
897
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
898
|
+
request_id: receipt.request_id,
|
|
899
|
+
outcome: 'recorded',
|
|
900
|
+
acknowledgement: {
|
|
901
|
+
text: 'I will deploy staging now.',
|
|
902
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
903
|
+
},
|
|
904
|
+
agent_acknowledgement_required: true,
|
|
905
|
+
})
|
|
906
|
+
const events = readLogRecords(deps.env, {
|
|
907
|
+
request: receipt.request_id,
|
|
908
|
+
event: ['acknowledgement.attempted', 'acknowledgement.outcome'],
|
|
909
|
+
}).records
|
|
910
|
+
expect(events.map((event) => event.event)).toEqual([
|
|
911
|
+
'acknowledgement.attempted',
|
|
912
|
+
'acknowledgement.outcome',
|
|
913
|
+
])
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
it('clears a matching active-session obligation after acknowledgement success', async () => {
|
|
917
|
+
const io = new CapturedIo()
|
|
918
|
+
const client = {
|
|
919
|
+
putAgentAcknowledgement: async () => ({
|
|
920
|
+
status: 'recorded' as const,
|
|
921
|
+
agent_acknowledgement: {
|
|
922
|
+
text: 'I will deploy staging now.',
|
|
923
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
} as unknown as ApiClient
|
|
927
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-clear-'))
|
|
928
|
+
const deps = makeDeps(io, client)
|
|
929
|
+
deps.env = {
|
|
930
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
931
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
932
|
+
}
|
|
933
|
+
const now = 1_800_000_000_000
|
|
934
|
+
deps.now = () => now
|
|
935
|
+
writeSessionState('ack-session', deps.env, {
|
|
936
|
+
acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
|
|
937
|
+
})
|
|
938
|
+
writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
|
|
939
|
+
|
|
940
|
+
expect(
|
|
941
|
+
await acknowledgeCommand(deps, receipt.request_id, {
|
|
942
|
+
text: 'I will deploy staging now.',
|
|
943
|
+
}),
|
|
944
|
+
).toBe(EXIT.ok)
|
|
945
|
+
expect(readSessionState('ack-session', deps.env).acknowledgement_due).toBeUndefined()
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('reports idempotent acknowledgement replay in concise human output', async () => {
|
|
949
|
+
const io = new CapturedIo()
|
|
950
|
+
const client = {
|
|
951
|
+
putAgentAcknowledgement: async () => ({
|
|
952
|
+
status: 'replayed' as const,
|
|
953
|
+
agent_acknowledgement: {
|
|
954
|
+
text: 'I will deploy staging now.',
|
|
955
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
956
|
+
},
|
|
957
|
+
}),
|
|
958
|
+
} as unknown as ApiClient
|
|
959
|
+
|
|
960
|
+
expect(
|
|
961
|
+
await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {
|
|
962
|
+
text: 'I will deploy staging now.',
|
|
963
|
+
}),
|
|
964
|
+
).toBe(EXIT.ok)
|
|
965
|
+
expect(io.outLines).toEqual([
|
|
966
|
+
`Agent Acknowledgement replayed for ${receipt.request_id} at 2026-08-13T12:01:00.000Z.`,
|
|
967
|
+
])
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
it.each([
|
|
971
|
+
{ text: undefined, message: '--text is required' },
|
|
972
|
+
{ text: ' ', message: '--text is required' },
|
|
973
|
+
{ text: 'x'.repeat(1001), message: 'at most 1000 characters' },
|
|
974
|
+
])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
|
|
975
|
+
const io = new CapturedIo()
|
|
976
|
+
let calls = 0
|
|
977
|
+
const client = {
|
|
978
|
+
putAgentAcknowledgement: async () => {
|
|
979
|
+
calls += 1
|
|
980
|
+
throw new Error('should not be reached')
|
|
981
|
+
},
|
|
982
|
+
} as unknown as ApiClient
|
|
983
|
+
|
|
984
|
+
expect(
|
|
985
|
+
await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text }),
|
|
986
|
+
).toBe(EXIT.usage)
|
|
987
|
+
expect(calls).toBe(0)
|
|
988
|
+
expect(io.errLines.join('\n')).toContain(message)
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
it.each([
|
|
992
|
+
[new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
|
|
993
|
+
[new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
|
|
994
|
+
[new ApiCallError(409, 'conflict', 'No user reply has been recorded yet.'), EXIT.failed, 'No user reply'],
|
|
995
|
+
[new ApiCallError(404, 'not_found', 'This request is unavailable or its content was purged.'), EXIT.failed, 'purged'],
|
|
996
|
+
[new ApiCallError(401, 'machine_revoked', 'This machine was revoked.'), EXIT.auth, 'machine_revoked'],
|
|
997
|
+
[new NetworkError('Could not reach the service'), EXIT.network, 'Could not reach'],
|
|
998
|
+
] as const)(
|
|
999
|
+
'maps stable acknowledgement failure %#',
|
|
1000
|
+
async (error, expectedExit, expectedCopy) => {
|
|
1001
|
+
const io = new CapturedIo()
|
|
1002
|
+
const client = {
|
|
1003
|
+
putAgentAcknowledgement: async () => {
|
|
1004
|
+
throw error
|
|
1005
|
+
},
|
|
1006
|
+
} as unknown as ApiClient
|
|
1007
|
+
|
|
1008
|
+
expect(
|
|
1009
|
+
await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text: 'Next work.' }),
|
|
1010
|
+
).toBe(expectedExit)
|
|
1011
|
+
expect(io.errLines.join('\n')).toContain(expectedCopy)
|
|
1012
|
+
},
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
it('reports required-but-not-yet-due state before any user reply', async () => {
|
|
1016
|
+
const io = new CapturedIo()
|
|
1017
|
+
const client = {
|
|
1018
|
+
replies: async () => replyResponse([]),
|
|
1019
|
+
} as unknown as ApiClient
|
|
1020
|
+
|
|
1021
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.noReply)
|
|
1022
|
+
expect(io.outLines).toContain(
|
|
1023
|
+
'Agent Acknowledgement: required after a user reply; no reply is recorded yet.',
|
|
1024
|
+
)
|
|
1025
|
+
expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
it('exposes disabled acknowledgement state without a follow-up command', async () => {
|
|
1029
|
+
const io = new CapturedIo()
|
|
1030
|
+
const client = {
|
|
1031
|
+
replies: async () => replyResponse([reply], { required: false }),
|
|
1032
|
+
} as unknown as ApiClient
|
|
1033
|
+
|
|
1034
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
|
|
1035
|
+
expect(io.outLines).toContain('Agent Acknowledgement: not required for this request.')
|
|
1036
|
+
expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
it('exposes an existing acknowledgement without repeating the follow-up command', async () => {
|
|
1040
|
+
const io = new CapturedIo()
|
|
1041
|
+
const acknowledgement = {
|
|
1042
|
+
text: 'I will deploy staging now.',
|
|
1043
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
1044
|
+
}
|
|
1045
|
+
const client = {
|
|
1046
|
+
replies: async () => replyResponse([reply], { acknowledgement }),
|
|
1047
|
+
} as unknown as ApiClient
|
|
1048
|
+
|
|
1049
|
+
expect(
|
|
1050
|
+
await repliesCommand(makeDeps(io, client), receipt.request_id, { json: true }),
|
|
1051
|
+
).toBe(EXIT.ok)
|
|
1052
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1053
|
+
agent_acknowledgement_required: true,
|
|
1054
|
+
agent_acknowledgement: acknowledgement,
|
|
1055
|
+
acknowledgement_command: null,
|
|
1056
|
+
})
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
it('close exposes the acknowledgement requirement and exact follow-up command', async () => {
|
|
1060
|
+
const io = new CapturedIo()
|
|
1061
|
+
const client = {
|
|
1062
|
+
closeReplies: async () => replyResponse([reply]),
|
|
1063
|
+
} as unknown as ApiClient
|
|
1064
|
+
|
|
1065
|
+
expect(await closeCommand(makeDeps(io, client), receipt.request_id, { json: true })).toBe(
|
|
1066
|
+
EXIT.ok,
|
|
1067
|
+
)
|
|
1068
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1069
|
+
request_id: receipt.request_id,
|
|
1070
|
+
agent_acknowledgement_required: true,
|
|
1071
|
+
agent_acknowledgement: null,
|
|
1072
|
+
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
1073
|
+
})
|
|
1074
|
+
})
|
|
1075
|
+
|
|
804
1076
|
it('passes the replies cursor and prints replies for later retrieval', async () => {
|
|
805
1077
|
const io = new CapturedIo()
|
|
806
1078
|
let requested: { waitSeconds: number; afterSeq: number } | undefined
|
|
@@ -820,7 +1092,11 @@ describe('command contracts', () => {
|
|
|
820
1092
|
|
|
821
1093
|
expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
|
|
822
1094
|
expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
|
|
823
|
-
expect(io.outLines).toEqual([
|
|
1095
|
+
expect(io.outLines).toEqual([
|
|
1096
|
+
'reply from iPhone: yes, after the migration',
|
|
1097
|
+
'Agent Acknowledgement required.',
|
|
1098
|
+
`next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
|
|
1099
|
+
])
|
|
824
1100
|
expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
|
|
825
1101
|
})
|
|
826
1102
|
|
|
@@ -845,6 +1121,8 @@ describe('command contracts', () => {
|
|
|
845
1121
|
expect(io.outLines).toEqual([
|
|
846
1122
|
`pending request ${receipt.request_id}`,
|
|
847
1123
|
'reply from iPhone: yes, after the migration',
|
|
1124
|
+
'Agent Acknowledgement required.',
|
|
1125
|
+
`next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
|
|
848
1126
|
])
|
|
849
1127
|
})
|
|
850
1128
|
|
|
@@ -1225,6 +1503,7 @@ describe('Cursor hook commands', () => {
|
|
|
1225
1503
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
1226
1504
|
CODEX_HOME: path.join(cwd, 'codex'),
|
|
1227
1505
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
1506
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode'),
|
|
1228
1507
|
},
|
|
1229
1508
|
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
1230
1509
|
}
|
|
@@ -1303,7 +1582,7 @@ describe('Codex hook representation', () => {
|
|
|
1303
1582
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
1304
1583
|
})
|
|
1305
1584
|
|
|
1306
|
-
it('
|
|
1585
|
+
it('preserves a foreign inline Stop while collapsing our duplicate definition', async () => {
|
|
1307
1586
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-collapse-'))
|
|
1308
1587
|
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1309
1588
|
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
@@ -1485,7 +1764,11 @@ describe('harness activation guidance', () => {
|
|
|
1485
1764
|
const deps = {
|
|
1486
1765
|
...makeDeps(io, {} as ApiClient),
|
|
1487
1766
|
cwd,
|
|
1488
|
-
env: {
|
|
1767
|
+
env: {
|
|
1768
|
+
HOME: path.join(cwd, 'home'),
|
|
1769
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
1770
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode-home'),
|
|
1771
|
+
},
|
|
1489
1772
|
}
|
|
1490
1773
|
|
|
1491
1774
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(
|
|
@@ -2120,6 +2403,7 @@ describe('init', () => {
|
|
|
2120
2403
|
platform: 'ios' as const,
|
|
2121
2404
|
permission_status: 'authorized',
|
|
2122
2405
|
registration_healthy: true,
|
|
2406
|
+
reply_protocol_version: 2,
|
|
2123
2407
|
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2124
2408
|
}
|
|
2125
2409
|
|
|
@@ -2492,8 +2776,8 @@ describe('init', () => {
|
|
|
2492
2776
|
)
|
|
2493
2777
|
expect(wired).toEqual(['claude-code', 'codex'])
|
|
2494
2778
|
expect(existsSync(path.join(cwd, '.claude', 'settings.local.json'))).toBe(true)
|
|
2495
|
-
expect(existsSync(path.join(cwd, '.codex', '
|
|
2496
|
-
expect(existsSync(path.join(cwd, '.codex', '
|
|
2779
|
+
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(true)
|
|
2780
|
+
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
2497
2781
|
expect(io.outLines.join('\n')).toContain('Installed claude-code hooks')
|
|
2498
2782
|
expect(io.outLines.join('\n')).toContain('Installed codex hooks')
|
|
2499
2783
|
})
|
|
@@ -2664,6 +2948,7 @@ describe('init', () => {
|
|
|
2664
2948
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
2665
2949
|
CODEX_HOME: path.join(cwd, 'codex'),
|
|
2666
2950
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'),
|
|
2951
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'opencode'),
|
|
2667
2952
|
},
|
|
2668
2953
|
}
|
|
2669
2954
|
|
|
@@ -2753,6 +3038,9 @@ describe('init', () => {
|
|
|
2753
3038
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
2754
3039
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
2755
3040
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
3041
|
+
expect(io.outLines.join('\n')).toContain(
|
|
3042
|
+
"Companion Receipt (the app's delivery confirmation) observed from iPhone.",
|
|
3043
|
+
)
|
|
2756
3044
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
2757
3045
|
expect(submitCalls).toBe(1)
|
|
2758
3046
|
expect(submittedDraft?.draft.event).toBe('setup_verified')
|
|
@@ -2895,6 +3183,8 @@ describe('init', () => {
|
|
|
2895
3183
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
2896
3184
|
expect(io.outLines.join('\n')).toContain('Provider accepted the notification')
|
|
2897
3185
|
expect(io.outLines.join('\n')).toContain('Proof may still arrive')
|
|
3186
|
+
const proofDir = path.join(stateDir(deps.env), 'setup-proofs')
|
|
3187
|
+
expect(readdirSync(proofDir).some((name) => name.endsWith('.json'))).toBe(true)
|
|
2898
3188
|
|
|
2899
3189
|
io.outLines = []
|
|
2900
3190
|
io.errLines = []
|
|
@@ -3304,6 +3594,27 @@ describe('an outage is not an answer', () => {
|
|
|
3304
3594
|
describe('asking before the hooks have ever run', () => {
|
|
3305
3595
|
const execPath = process.execPath
|
|
3306
3596
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
3597
|
+
const scratch: string[] = []
|
|
3598
|
+
|
|
3599
|
+
function scratchDir(prefix: string): string {
|
|
3600
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
|
3601
|
+
scratch.push(dir)
|
|
3602
|
+
return dir
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
afterEach(() => {
|
|
3606
|
+
while (scratch.length > 0) {
|
|
3607
|
+
rmSync(scratch.pop()!, { recursive: true, force: true })
|
|
3608
|
+
}
|
|
3609
|
+
})
|
|
3610
|
+
|
|
3611
|
+
it('refuses to write Codex trust outside the isolated test account', () => {
|
|
3612
|
+
expect(() => trustInstalledCodexHooks(os.tmpdir(), {})).toThrow(/requires env.HOME/)
|
|
3613
|
+
expect(() => trustInstalledCodexHooks(os.tmpdir(), { HOME: path.dirname(os.tmpdir()) })).toThrow(
|
|
3614
|
+
/will only write under/,
|
|
3615
|
+
)
|
|
3616
|
+
expect(os.homedir()).toBe(process.env['NOTIFAI_TEST_HOME'])
|
|
3617
|
+
})
|
|
3307
3618
|
|
|
3308
3619
|
it('names the active Codex harness instead of unrelated installed adapters', () => {
|
|
3309
3620
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-codex-missing-'))
|
|
@@ -3311,6 +3622,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3311
3622
|
const env = {
|
|
3312
3623
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3313
3624
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3625
|
+
HOME: path.join(cwd, 'home'),
|
|
3314
3626
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3315
3627
|
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3316
3628
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
@@ -3335,6 +3647,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3335
3647
|
const env = {
|
|
3336
3648
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3337
3649
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3650
|
+
HOME: path.join(cwd, 'home'),
|
|
3338
3651
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3339
3652
|
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3340
3653
|
}
|
|
@@ -3353,11 +3666,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3353
3666
|
})
|
|
3354
3667
|
|
|
3355
3668
|
it('registers only when the active Codex thread owns the project pointer', () => {
|
|
3356
|
-
const cwd =
|
|
3669
|
+
const cwd = scratchDir('notifai-active-codex-matching-')
|
|
3357
3670
|
const io = new CapturedIo()
|
|
3358
3671
|
const env = {
|
|
3359
3672
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3360
3673
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3674
|
+
HOME: path.join(cwd, 'home'),
|
|
3361
3675
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3362
3676
|
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3363
3677
|
}
|
|
@@ -3497,11 +3811,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3497
3811
|
})
|
|
3498
3812
|
|
|
3499
3813
|
it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
|
|
3500
|
-
const cwd =
|
|
3814
|
+
const cwd = scratchDir('notifai-ask-commitment-')
|
|
3501
3815
|
const io = new CapturedIo()
|
|
3502
3816
|
const env = {
|
|
3503
3817
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3504
3818
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3819
|
+
HOME: path.join(cwd, 'home'),
|
|
3505
3820
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3506
3821
|
CODEX_THREAD_ID: 'codex-commitment-thread',
|
|
3507
3822
|
}
|
|
@@ -3532,11 +3847,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3532
3847
|
})
|
|
3533
3848
|
|
|
3534
3849
|
it('requires a concrete fallback for free-text answers before the turn ends', () => {
|
|
3535
|
-
const cwd =
|
|
3850
|
+
const cwd = scratchDir('notifai-ask-free-text-')
|
|
3536
3851
|
const io = new CapturedIo()
|
|
3537
3852
|
const env = {
|
|
3538
3853
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3539
3854
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3855
|
+
HOME: path.join(cwd, 'home'),
|
|
3540
3856
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3541
3857
|
CODEX_THREAD_ID: 'codex-free-text-thread',
|
|
3542
3858
|
}
|
|
@@ -3555,11 +3871,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3555
3871
|
})
|
|
3556
3872
|
|
|
3557
3873
|
it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
|
|
3558
|
-
const cwd =
|
|
3874
|
+
const cwd = scratchDir('notifai-codex-stale-trust-')
|
|
3559
3875
|
const io = new CapturedIo()
|
|
3560
3876
|
const env = {
|
|
3561
3877
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3562
3878
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3879
|
+
HOME: path.join(cwd, 'home'),
|
|
3563
3880
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3564
3881
|
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3565
3882
|
}
|
|
@@ -3570,7 +3887,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3570
3887
|
.find((installation) => installation.harness === 'codex')
|
|
3571
3888
|
?.handlers.find((handler) => handler.event === 'Stop')
|
|
3572
3889
|
expect(stop).toBeDefined()
|
|
3573
|
-
const configFile = path.join(env.CODEX_HOME
|
|
3890
|
+
const configFile = path.join(env.CODEX_HOME!, 'config.toml')
|
|
3574
3891
|
writeFileSync(
|
|
3575
3892
|
configFile,
|
|
3576
3893
|
readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
|
|
@@ -3594,6 +3911,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3594
3911
|
const env = {
|
|
3595
3912
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3596
3913
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3914
|
+
HOME: path.join(cwd, 'home'),
|
|
3597
3915
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3598
3916
|
CODEX_THREAD_ID: 'codex-current-thread',
|
|
3599
3917
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
@@ -3702,7 +4020,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3702
4020
|
})
|
|
3703
4021
|
|
|
3704
4022
|
it('rejects duplicate active Codex definitions before registration', () => {
|
|
3705
|
-
const cwd =
|
|
4023
|
+
const cwd = scratchDir('notifai-active-codex-duplicate-')
|
|
3706
4024
|
const io = new CapturedIo()
|
|
3707
4025
|
const env = {
|
|
3708
4026
|
HOME: path.join(cwd, 'home'),
|
|
@@ -3713,7 +4031,8 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3713
4031
|
}
|
|
3714
4032
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
3715
4033
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3716
|
-
|
|
4034
|
+
mkdirSync(env.CODEX_HOME!, { recursive: true })
|
|
4035
|
+
applyPlan(path.join(env.CODEX_HOME!, 'config.toml'), {
|
|
3717
4036
|
hooks: buildHookConfig({
|
|
3718
4037
|
adapterPath: hookAdapterPath(deps.hookAdapterHome),
|
|
3719
4038
|
harness: 'codex',
|
|
@@ -3822,6 +4141,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3822
4141
|
const env = {
|
|
3823
4142
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3824
4143
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
4144
|
+
HOME: path.join(cwd, 'home'),
|
|
3825
4145
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3826
4146
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
3827
4147
|
CLAUDECODE: '1',
|
|
@@ -3844,6 +4164,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
3844
4164
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
3845
4165
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
3846
4166
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'claude-home'),
|
|
4167
|
+
HOME: path.join(cwd, 'home'),
|
|
3847
4168
|
CODEX_HOME: path.join(cwd, 'codex-home'),
|
|
3848
4169
|
CLAUDECODE: '1',
|
|
3849
4170
|
CLAUDE_CODE_SESSION_ID: 'claude-current',
|
|
@@ -4026,7 +4347,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4026
4347
|
...isolatedEnv(cwd),
|
|
4027
4348
|
CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
|
|
4028
4349
|
}
|
|
4029
|
-
const deps = {
|
|
4350
|
+
const deps = {
|
|
4351
|
+
...makeDeps(io, {} as ApiClient),
|
|
4352
|
+
cwd,
|
|
4353
|
+
env,
|
|
4354
|
+
hookPlatform: 'darwin' as NodeJS.Platform,
|
|
4355
|
+
}
|
|
4030
4356
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
4031
4357
|
|
|
4032
4358
|
io.outLines = []
|
|
@@ -4037,14 +4363,8 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4037
4363
|
io.outLines = []
|
|
4038
4364
|
await doctorCommand(deps, {})
|
|
4039
4365
|
const out = io.outLines.join('\n')
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
expect(out).toContain('can prove a stopped thread unowned before resuming it')
|
|
4043
|
-
} else {
|
|
4044
|
-
// Linux has no non-blocking `flock` through Node's `open(2)` flags, so
|
|
4045
|
-
// the gate stays shut and doctor says so rather than implying a wake.
|
|
4046
|
-
expect(out).toContain('nothing can be resumed')
|
|
4047
|
-
}
|
|
4366
|
+
expect(out).toMatch(/ok\s+hooks \(wake route\)/)
|
|
4367
|
+
expect(out).toContain('can prove a stopped thread unowned before resuming it')
|
|
4048
4368
|
})
|
|
4049
4369
|
|
|
4050
4370
|
it('documents the failing exit contract in doctor JSON', async () => {
|
|
@@ -4123,6 +4443,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4123
4443
|
XDG_STATE_HOME: cwd,
|
|
4124
4444
|
CODEX_HOME: path.join(cwd, 'none'),
|
|
4125
4445
|
CLAUDE_CONFIG_DIR: path.join(cwd, 'none'),
|
|
4446
|
+
OPENCODE_CONFIG_DIR: path.join(cwd, 'none'),
|
|
4126
4447
|
},
|
|
4127
4448
|
}
|
|
4128
4449
|
|