@raidiant/notifai 0.1.7 → 0.1.8
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 +9 -2
- package/dist/atomic-file.d.ts +12 -0
- package/dist/atomic-file.js +55 -0
- package/dist/atomic-file.js.map +1 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -4
- package/dist/commands.js +643 -212
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +20 -7
- package/dist/hooks.js +472 -149
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +8 -4
- package/dist/install-hooks.js +45 -64
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +45 -33
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +31 -0
- package/dist/native-skills.js +117 -0
- package/dist/native-skills.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -0
- package/dist/opencode-plugin.js +37 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +0 -3
- package/dist/send.js +28 -13
- package/dist/send.js.map +1 -1
- package/package.json +12 -12
- package/src/atomic-file.ts +73 -0
- package/src/client.ts +3 -0
- package/src/commands.test.ts +932 -63
- package/src/commands.ts +780 -225
- package/src/config.test.ts +68 -6
- package/src/hooks.test.ts +398 -5
- package/src/hooks.ts +557 -161
- package/src/install-hooks.test.ts +36 -1
- package/src/install-hooks.ts +50 -68
- package/src/main.ts +54 -37
- package/src/native-skills.ts +177 -0
- package/src/opencode-plugin.ts +38 -5
- package/src/send.ts +32 -12
- package/src/skill.test.ts +43 -0
package/src/config.test.ts
CHANGED
|
@@ -141,9 +141,61 @@ describe('draft building', () => {
|
|
|
141
141
|
presentation: { title: 'T', body: 'B' },
|
|
142
142
|
targets: { mode: 'all' },
|
|
143
143
|
delivery: { ttl_seconds: 86400, collapse_key: null },
|
|
144
|
+
platform: {
|
|
145
|
+
ios: { sound: null, interruption_level: 'passive' },
|
|
146
|
+
macos: { sound: null, interruption_level: 'passive' },
|
|
147
|
+
},
|
|
144
148
|
})
|
|
145
149
|
})
|
|
146
150
|
|
|
151
|
+
it.each([
|
|
152
|
+
{ flags: {}, sound: null, level: 'passive' },
|
|
153
|
+
{ flags: { kind: 'done' }, sound: 'done', level: 'passive' },
|
|
154
|
+
{ flags: { reply: true }, sound: 'attention', level: 'active' },
|
|
155
|
+
])('derives the $sound / $level kind profile from $flags', ({ flags, sound, level }) => {
|
|
156
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
157
|
+
const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
|
|
158
|
+
if (!build.ok) throw new Error(build.error)
|
|
159
|
+
expect(build.draft.platform).toEqual({
|
|
160
|
+
ios: { sound, interruption_level: level },
|
|
161
|
+
macos: { sound, interruption_level: level },
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('orders explicit flags over user config over the kind profile', () => {
|
|
166
|
+
const { env, cwd } = setup({
|
|
167
|
+
projectToml: 'sound = "alert"\ninterruption_level = "active"\n',
|
|
168
|
+
})
|
|
169
|
+
const config = loadConfig({ cwd, env })
|
|
170
|
+
const configured = buildDraft(config, { title: 'T', body: 'B', kind: 'done' })
|
|
171
|
+
if (!configured.ok) throw new Error(configured.error)
|
|
172
|
+
expect(configured.draft.platform?.ios).toEqual({
|
|
173
|
+
sound: 'alert',
|
|
174
|
+
interruption_level: 'active',
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const explicit = buildDraft(config, {
|
|
178
|
+
title: 'T',
|
|
179
|
+
body: 'B',
|
|
180
|
+
kind: 'done',
|
|
181
|
+
sound: 'none',
|
|
182
|
+
level: 'time_sensitive',
|
|
183
|
+
})
|
|
184
|
+
if (!explicit.ok) throw new Error(explicit.error)
|
|
185
|
+
expect(explicit.draft.platform?.ios).toEqual({
|
|
186
|
+
sound: null,
|
|
187
|
+
interruption_level: 'time_sensitive',
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps a blocking reply answerable for one hour by default', () => {
|
|
192
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
193
|
+
const build = buildDraft(config, { title: 'Question', body: 'Deploy?', reply: true })
|
|
194
|
+
if (!build.ok) throw new Error(build.error)
|
|
195
|
+
|
|
196
|
+
expect(build.draft.reply?.expires_in_seconds).toBe(3_600)
|
|
197
|
+
})
|
|
198
|
+
|
|
147
199
|
it('stamps the project from flag or project config', () => {
|
|
148
200
|
const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
|
|
149
201
|
const config = loadConfig({ cwd, env })
|
|
@@ -200,6 +252,7 @@ describe('draft building', () => {
|
|
|
200
252
|
interruption_level: 'time_sensitive',
|
|
201
253
|
custom_data: { run_id: '42', branch: 'main' },
|
|
202
254
|
})
|
|
255
|
+
expect(build.draft.platform?.macos).toEqual(build.draft.platform?.ios)
|
|
203
256
|
})
|
|
204
257
|
|
|
205
258
|
it('maps optional fields into the selected macOS platform slot', () => {
|
|
@@ -209,11 +262,8 @@ describe('draft building', () => {
|
|
|
209
262
|
body: 'B',
|
|
210
263
|
platform: 'macos',
|
|
211
264
|
sound: 'none',
|
|
212
|
-
badge: 7,
|
|
213
265
|
threadId: 'desktop-builds',
|
|
214
266
|
level: 'passive',
|
|
215
|
-
relevance: 0.8,
|
|
216
|
-
targetContentId: 'build-detail',
|
|
217
267
|
data: ['run_id=42'],
|
|
218
268
|
})
|
|
219
269
|
if (!build.ok) throw new Error(build.error)
|
|
@@ -222,16 +272,28 @@ describe('draft building', () => {
|
|
|
222
272
|
expect(build.draft.platform).toEqual({
|
|
223
273
|
macos: {
|
|
224
274
|
sound: null,
|
|
225
|
-
badge: 7,
|
|
226
275
|
thread_id: 'desktop-builds',
|
|
227
276
|
interruption_level: 'passive',
|
|
228
|
-
relevance_score: 0.8,
|
|
229
|
-
target_content_id: 'build-detail',
|
|
230
277
|
custom_data: { run_id: '42' },
|
|
231
278
|
},
|
|
232
279
|
})
|
|
233
280
|
})
|
|
234
281
|
|
|
282
|
+
it('limits collapse keys by UTF-8 bytes', () => {
|
|
283
|
+
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
284
|
+
expect(buildDraft(config, { title: 'T', body: 'B', collapseKey: '😀'.repeat(16) }).ok).toBe(
|
|
285
|
+
true,
|
|
286
|
+
)
|
|
287
|
+
const oversized = buildDraft(config, {
|
|
288
|
+
title: 'T',
|
|
289
|
+
body: 'B',
|
|
290
|
+
collapseKey: '😀'.repeat(17),
|
|
291
|
+
})
|
|
292
|
+
expect(oversized.ok).toBe(false)
|
|
293
|
+
if (oversized.ok) throw new Error('expected rejection')
|
|
294
|
+
expect(oversized.error).toContain('64 UTF-8 bytes')
|
|
295
|
+
})
|
|
296
|
+
|
|
235
297
|
it('rejects malformed inputs with usage errors', () => {
|
|
236
298
|
const config = loadConfig({ cwd: base.cwd, env: base.env })
|
|
237
299
|
expect(buildDraft(config, { title: '', body: 'B' }).ok).toBe(false)
|
package/src/hooks.test.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
utimesSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
2
11
|
import os from 'node:os'
|
|
3
12
|
import path from 'node:path'
|
|
4
13
|
import type {
|
|
@@ -10,9 +19,11 @@ import type {
|
|
|
10
19
|
import { describe, expect, it } from 'vitest'
|
|
11
20
|
import type { ApiClient } from './client.js'
|
|
12
21
|
import { EXIT, askCommand, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
|
|
13
|
-
import { loadConfig, sanitizeSessionId } from './config.js'
|
|
22
|
+
import { loadConfig, projectSessionPointerPath, sanitizeSessionId } from './config.js'
|
|
14
23
|
import {
|
|
15
24
|
claimQuestionPush,
|
|
25
|
+
clearSessionState,
|
|
26
|
+
drainRetirements,
|
|
16
27
|
drainOrphanRetirements,
|
|
17
28
|
isUserAway,
|
|
18
29
|
orphanRetirements,
|
|
@@ -21,6 +32,7 @@ import {
|
|
|
21
32
|
readProjectSession,
|
|
22
33
|
readSessionState,
|
|
23
34
|
registerQuestion,
|
|
35
|
+
writeProjectSession,
|
|
24
36
|
writeSessionState,
|
|
25
37
|
} from './hooks.js'
|
|
26
38
|
|
|
@@ -46,6 +58,8 @@ interface Recorder {
|
|
|
46
58
|
closed: string[]
|
|
47
59
|
/** Simulates an offline machine; retirement has to survive one. */
|
|
48
60
|
failSubmits?: boolean
|
|
61
|
+
/** Runs while an agent_question submit is in flight, before onSubmitted. */
|
|
62
|
+
beforeQuestionSubmit?: () => void
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
@@ -81,6 +95,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
81
95
|
health: async () => true,
|
|
82
96
|
submit: async (body) => {
|
|
83
97
|
if (recorder.failSubmits === true) throw new Error('offline')
|
|
98
|
+
if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
|
|
84
99
|
recorder.submitted.push(body)
|
|
85
100
|
submissions += 1
|
|
86
101
|
recorder.receipts.push(`req_hook_${submissions}`)
|
|
@@ -224,6 +239,30 @@ describe('presence gate', () => {
|
|
|
224
239
|
mode: 'selected',
|
|
225
240
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
226
241
|
})
|
|
242
|
+
expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
|
|
243
|
+
title: 'Question',
|
|
244
|
+
body: 'Ship it?',
|
|
245
|
+
})
|
|
246
|
+
expect(h.recorder.submitted[0]?.draft.platform).toEqual({
|
|
247
|
+
ios: { sound: 'attention', interruption_level: 'active' },
|
|
248
|
+
macos: { sound: 'attention', interruption_level: 'active' },
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('names the resolved project in the pushed question title', async () => {
|
|
253
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
254
|
+
const projectDir = path.join(h.deps.cwd, '.notifai')
|
|
255
|
+
mkdirSync(projectDir, { recursive: true })
|
|
256
|
+
writeFileSync(
|
|
257
|
+
path.join(projectDir, 'config.toml'),
|
|
258
|
+
'project = "notifai-cli"\nask_grace_seconds = 0\n',
|
|
259
|
+
)
|
|
260
|
+
writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
|
|
261
|
+
registerQuestion('project-title', h.env, { question: 'Ship it?' }, NOW)
|
|
262
|
+
|
|
263
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
|
|
264
|
+
|
|
265
|
+
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
|
|
227
266
|
})
|
|
228
267
|
|
|
229
268
|
it('falls back to elapsed time where no idle source exists', () => {
|
|
@@ -438,6 +477,142 @@ describe('nagging guards', () => {
|
|
|
438
477
|
expect(h.recorder.submitted).toEqual([])
|
|
439
478
|
expect(h.io.outLines).toEqual([])
|
|
440
479
|
})
|
|
480
|
+
|
|
481
|
+
it('delivers a new question registered during an answer continuation', async () => {
|
|
482
|
+
const h = harness([reply({ text: 'First answer' })], 900)
|
|
483
|
+
writeSessionState('n3', h.env, { last_prompt_at: AWAY })
|
|
484
|
+
registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
|
|
485
|
+
|
|
486
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3' }))
|
|
487
|
+
await h.deps.sleep?.(1)
|
|
488
|
+
registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
|
|
489
|
+
h.io.outLines = []
|
|
490
|
+
|
|
491
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3', stop_hook_active: true }))
|
|
492
|
+
|
|
493
|
+
const questions = h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')
|
|
494
|
+
expect(questions.map((entry) => entry.draft.presentation.body)).toEqual([
|
|
495
|
+
'First question?',
|
|
496
|
+
'Follow-up question?',
|
|
497
|
+
])
|
|
498
|
+
expect(h.io.outLines.join('\n')).toContain('First answer')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('stops chained questions at the consecutive continuation cap', async () => {
|
|
502
|
+
const h = harness([], 900)
|
|
503
|
+
writeSessionState('n4', h.env, {
|
|
504
|
+
last_prompt_at: AWAY,
|
|
505
|
+
continuation: { answered_at: NOW - 1, count: 3 },
|
|
506
|
+
})
|
|
507
|
+
registerQuestion('n4', h.env, { question: 'One more?' }, NOW)
|
|
508
|
+
|
|
509
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n4', stop_hook_active: true }))
|
|
510
|
+
|
|
511
|
+
expect(h.recorder.submitted).toEqual([])
|
|
512
|
+
expect(readSessionState('n4', h.env).pending?.question).toBe('One more?')
|
|
513
|
+
expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
describe('late answer collection', () => {
|
|
518
|
+
it('collects a late answer on Stop and resumes with it instead of asking twice', async () => {
|
|
519
|
+
const answers: ReplyView[] = []
|
|
520
|
+
const h = harness(answers, 900)
|
|
521
|
+
writeSessionState('late-stop', h.env, { last_prompt_at: AWAY })
|
|
522
|
+
registerQuestion('late-stop', h.env, { question: 'Ship it?' }, NOW)
|
|
523
|
+
|
|
524
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
525
|
+
expect(readSessionState('late-stop', h.env).pending?.request_id).toBe('req_hook_1')
|
|
526
|
+
answers.push(reply({ text: 'Ship it' }))
|
|
527
|
+
h.io.outLines = []
|
|
528
|
+
|
|
529
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
530
|
+
|
|
531
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
532
|
+
expect(h.recorder.closed).toContain('req_hook_1')
|
|
533
|
+
expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
|
|
534
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
535
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
536
|
+
decision: 'block',
|
|
537
|
+
reason: expect.stringContaining('Ship it'),
|
|
538
|
+
})
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
|
|
542
|
+
const answers: ReplyView[] = []
|
|
543
|
+
const h = harness(answers, 900)
|
|
544
|
+
writeSessionState('late-prompt', h.env, { last_prompt_at: AWAY })
|
|
545
|
+
registerQuestion('late-prompt', h.env, { question: 'Ship it?' }, NOW)
|
|
546
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
|
|
547
|
+
answers.push(reply({ text: 'Hold' }))
|
|
548
|
+
h.io.outLines = []
|
|
549
|
+
|
|
550
|
+
await hookRunCommand(
|
|
551
|
+
h.deps,
|
|
552
|
+
'user-prompt-submit',
|
|
553
|
+
stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const retirement = h.recorder.submitted.find(
|
|
557
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
558
|
+
)?.draft
|
|
559
|
+
expect(retirement?.lifecycle).toMatchObject({
|
|
560
|
+
state: 'answered',
|
|
561
|
+
retires_request_id: 'req_hook_1',
|
|
562
|
+
})
|
|
563
|
+
expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
|
|
564
|
+
expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
|
|
565
|
+
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toEqual({
|
|
566
|
+
hookSpecificOutput: {
|
|
567
|
+
hookEventName: 'UserPromptSubmit',
|
|
568
|
+
additionalContext: expect.stringContaining('Hold'),
|
|
569
|
+
},
|
|
570
|
+
})
|
|
571
|
+
})
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
describe('OpenCode answer preservation', () => {
|
|
575
|
+
it('pushes without waiting and leaves the answerable request intact', async () => {
|
|
576
|
+
const h = harness([reply({ text: 'Approve' })], 900)
|
|
577
|
+
writeSessionState('open1', h.env, { last_prompt_at: AWAY })
|
|
578
|
+
registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
|
|
579
|
+
|
|
580
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
|
|
581
|
+
|
|
582
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
583
|
+
expect(h.recorder.closed).toEqual([])
|
|
584
|
+
expect(
|
|
585
|
+
h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
|
|
586
|
+
).toEqual([])
|
|
587
|
+
expect(readSessionState('open1', h.env).pending).toMatchObject({
|
|
588
|
+
question: 'Deploy?',
|
|
589
|
+
request_id: 'req_hook_1',
|
|
590
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
591
|
+
})
|
|
592
|
+
expect(h.io.outLines).toEqual([])
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe('reply-wait presence monitoring', () => {
|
|
597
|
+
it('returns the terminal when the user comes back after the push', async () => {
|
|
598
|
+
const h = harness([], 900)
|
|
599
|
+
let checks = 0
|
|
600
|
+
h.deps.idleSeconds = () => {
|
|
601
|
+
checks += 1
|
|
602
|
+
return checks >= 4 ? 1 : 900
|
|
603
|
+
}
|
|
604
|
+
writeSessionState('returned', h.env, { last_prompt_at: AWAY })
|
|
605
|
+
registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
|
|
606
|
+
|
|
607
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
|
|
608
|
+
|
|
609
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
610
|
+
expect(h.recorder.closed).toEqual([])
|
|
611
|
+
expect(readSessionState('returned', h.env).pending?.request_id).toBe('req_hook_1')
|
|
612
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
|
|
613
|
+
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
614
|
+
expect(h.io.errLines.join('\n')).toContain('notifai replies --pending')
|
|
615
|
+
})
|
|
441
616
|
})
|
|
442
617
|
|
|
443
618
|
describe('Cursor stop output', () => {
|
|
@@ -460,7 +635,7 @@ describe('Cursor stop output', () => {
|
|
|
460
635
|
expect(h.io.outLines).toHaveLength(1)
|
|
461
636
|
const output = JSON.parse(h.io.outLines[0] ?? '{}') as Record<string, unknown>
|
|
462
637
|
expect(output['followup_message']).toContain(
|
|
463
|
-
'
|
|
638
|
+
'Notifai — the user answered from Furankuphone: "Ship it". Continue with that answer.',
|
|
464
639
|
)
|
|
465
640
|
expect(output).not.toHaveProperty('decision')
|
|
466
641
|
})
|
|
@@ -760,7 +935,12 @@ describe('ask registration', () => {
|
|
|
760
935
|
// harness's own id, so the pointer must outrank the env var.
|
|
761
936
|
const h = harness()
|
|
762
937
|
h.deps.env['NOTIFAI_SESSION'] = 'my-label'
|
|
763
|
-
await hookRunCommand(
|
|
938
|
+
await hookRunCommand(
|
|
939
|
+
h.deps,
|
|
940
|
+
'user-prompt-submit',
|
|
941
|
+
stdin({ session_id: 'real1', cwd: h.deps.cwd }),
|
|
942
|
+
'claude-code',
|
|
943
|
+
)
|
|
764
944
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
765
945
|
expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
|
|
766
946
|
expect(readSessionState('my-label', h.env).pending).toBeUndefined()
|
|
@@ -798,12 +978,34 @@ describe('user-prompt-submit hook', () => {
|
|
|
798
978
|
expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
|
|
799
979
|
})
|
|
800
980
|
|
|
981
|
+
it('diagnoses malformed or truncated hook input instead of silently doing nothing', async () => {
|
|
982
|
+
const h = harness()
|
|
983
|
+
|
|
984
|
+
expect(await hookRunCommand(h.deps, 'user-prompt-submit', async () => '{"session_id":')).toBe(
|
|
985
|
+
EXIT.ok,
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
it('explains when a missing machine credential makes a hook skip routing', async () => {
|
|
992
|
+
const h = harness()
|
|
993
|
+
h.deps.store.load = () => null
|
|
994
|
+
|
|
995
|
+
expect(
|
|
996
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'unpaired' })),
|
|
997
|
+
).toBe(EXIT.ok)
|
|
998
|
+
|
|
999
|
+
expect(h.io.errLines.join('\n')).toMatch(/not paired|credential/i)
|
|
1000
|
+
})
|
|
1001
|
+
|
|
801
1002
|
it('preserves a delivered question through ask replacement and prompt transition', async () => {
|
|
802
1003
|
const h = harness([], 900)
|
|
803
1004
|
await hookRunCommand(
|
|
804
1005
|
h.deps,
|
|
805
1006
|
'user-prompt-submit',
|
|
806
1007
|
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
1008
|
+
'claude-code',
|
|
807
1009
|
)
|
|
808
1010
|
expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
|
|
809
1011
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
@@ -834,6 +1036,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
834
1036
|
h.deps,
|
|
835
1037
|
'user-prompt-submit',
|
|
836
1038
|
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
1039
|
+
'claude-code',
|
|
837
1040
|
)
|
|
838
1041
|
|
|
839
1042
|
const retirement = h.recorder.submitted.find(
|
|
@@ -919,12 +1122,20 @@ describe('user-prompt-submit hook', () => {
|
|
|
919
1122
|
const project = realpathSync(viaSymlink)
|
|
920
1123
|
if (project === viaSymlink) return // no symlink on this platform; nothing to prove
|
|
921
1124
|
|
|
922
|
-
await hookRunCommand(
|
|
1125
|
+
await hookRunCommand(
|
|
1126
|
+
h.deps,
|
|
1127
|
+
'user-prompt-submit',
|
|
1128
|
+
stdin({ session_id: 's14', cwd: viaSymlink }),
|
|
1129
|
+
'claude-code',
|
|
1130
|
+
)
|
|
923
1131
|
|
|
924
1132
|
expect(readProjectSession(project, h.env, NOW)).toBe('s14')
|
|
925
1133
|
expect(readProjectSession(viaSymlink, h.env, NOW)).toBe('s14')
|
|
926
1134
|
// A pointer older than a day is not evidence of a live session.
|
|
927
1135
|
expect(readProjectSession(project, h.env, NOW + 2 * 24 * 3600 * 1000)).toBeNull()
|
|
1136
|
+
|
|
1137
|
+
clearSessionState('s14', h.env)
|
|
1138
|
+
expect(readProjectSession(project, h.env, NOW)).toBeNull()
|
|
928
1139
|
})
|
|
929
1140
|
})
|
|
930
1141
|
|
|
@@ -940,6 +1151,26 @@ describe('session-end hook', () => {
|
|
|
940
1151
|
expect(h.recorder.closed).toEqual([])
|
|
941
1152
|
})
|
|
942
1153
|
|
|
1154
|
+
it('clears only the ending session project pointer', async () => {
|
|
1155
|
+
const h = harness()
|
|
1156
|
+
await hookRunCommand(
|
|
1157
|
+
h.deps,
|
|
1158
|
+
'user-prompt-submit',
|
|
1159
|
+
stdin({ session_id: 'ending', cwd: h.deps.cwd }),
|
|
1160
|
+
'claude-code',
|
|
1161
|
+
)
|
|
1162
|
+
expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
|
|
1163
|
+
|
|
1164
|
+
await hookRunCommand(
|
|
1165
|
+
h.deps,
|
|
1166
|
+
'session-end',
|
|
1167
|
+
stdin({ session_id: 'ending', cwd: h.deps.cwd }),
|
|
1168
|
+
'claude-code',
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBeNull()
|
|
1172
|
+
})
|
|
1173
|
+
|
|
943
1174
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
944
1175
|
const h = harness()
|
|
945
1176
|
writeSessionState('s-incomplete', h.env, {
|
|
@@ -1106,6 +1337,113 @@ describe('pruning abandoned session state', () => {
|
|
|
1106
1337
|
})
|
|
1107
1338
|
})
|
|
1108
1339
|
|
|
1340
|
+
describe('durable state writes', () => {
|
|
1341
|
+
function sessionFile(h: Harness, sessionId: string): string {
|
|
1342
|
+
return path.join(
|
|
1343
|
+
h.env['XDG_STATE_HOME'] as string,
|
|
1344
|
+
'notifai',
|
|
1345
|
+
'sessions',
|
|
1346
|
+
`${sanitizeSessionId(sessionId)}.json`,
|
|
1347
|
+
)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
it('recovers a truncated state file with one complete atomic document', () => {
|
|
1351
|
+
const h = harness()
|
|
1352
|
+
const file = sessionFile(h, 'truncated')
|
|
1353
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
1354
|
+
writeFileSync(file, '{"pending":')
|
|
1355
|
+
expect(readSessionState('truncated', h.env)).toEqual({})
|
|
1356
|
+
|
|
1357
|
+
writeSessionState('truncated', h.env, { last_prompt_at: NOW })
|
|
1358
|
+
|
|
1359
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({ last_prompt_at: NOW })
|
|
1360
|
+
expect(readdirSync(path.dirname(file)).filter((name) => name.includes('.tmp'))).toEqual([])
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
it('refuses a session-state symlink instead of writing through it', () => {
|
|
1364
|
+
const h = harness()
|
|
1365
|
+
const file = sessionFile(h, 'linked')
|
|
1366
|
+
const target = path.join(path.dirname(file), 'target.json')
|
|
1367
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
1368
|
+
writeFileSync(target, '{"untouched":true}\n')
|
|
1369
|
+
symlinkSync(target, file)
|
|
1370
|
+
|
|
1371
|
+
expect(() => writeSessionState('linked', h.env, { last_prompt_at: NOW })).toThrow(/symlink/)
|
|
1372
|
+
expect(readFileSync(target, 'utf8')).toBe('{"untouched":true}\n')
|
|
1373
|
+
})
|
|
1374
|
+
|
|
1375
|
+
it('refuses project-pointer and retirement-queue symlinks too', () => {
|
|
1376
|
+
const h = harness()
|
|
1377
|
+
writeSessionState('linked-pointer', h.env, { last_prompt_at: NOW })
|
|
1378
|
+
const target = path.join(h.env['XDG_STATE_HOME'] as string, 'pointer-target.json')
|
|
1379
|
+
writeFileSync(target, '{"untouched":true}\n')
|
|
1380
|
+
const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
1381
|
+
mkdirSync(path.dirname(pointer), { recursive: true })
|
|
1382
|
+
symlinkSync(target, pointer)
|
|
1383
|
+
|
|
1384
|
+
expect(() =>
|
|
1385
|
+
writeProjectSession(h.deps.cwd, h.env, 'linked-pointer', NOW, 'claude-code'),
|
|
1386
|
+
).toThrow(/symlink/)
|
|
1387
|
+
expect(readFileSync(target, 'utf8')).toBe('{"untouched":true}\n')
|
|
1388
|
+
|
|
1389
|
+
const queueTarget = path.join(h.env['XDG_STATE_HOME'] as string, 'queue-target.json')
|
|
1390
|
+
const queue = path.join(h.env['XDG_STATE_HOME'] as string, 'notifai', 'retire-queue.json')
|
|
1391
|
+
writeFileSync(queueTarget, '[]\n')
|
|
1392
|
+
symlinkSync(queueTarget, queue)
|
|
1393
|
+
expect(() =>
|
|
1394
|
+
orphanRetirements(
|
|
1395
|
+
h.env,
|
|
1396
|
+
[
|
|
1397
|
+
{
|
|
1398
|
+
request_id: 'req_linked',
|
|
1399
|
+
collapse_key: 'collapse-linked',
|
|
1400
|
+
device_ids: ['dev_iphone'],
|
|
1401
|
+
question: 'Still there?',
|
|
1402
|
+
state: 'expired',
|
|
1403
|
+
},
|
|
1404
|
+
],
|
|
1405
|
+
'linked-pointer',
|
|
1406
|
+
NOW,
|
|
1407
|
+
),
|
|
1408
|
+
).toThrow(/symlink/)
|
|
1409
|
+
expect(readFileSync(queueTarget, 'utf8')).toBe('[]\n')
|
|
1410
|
+
})
|
|
1411
|
+
|
|
1412
|
+
it('persists each successful retirement before a later corrupt entry interrupts the drain', async () => {
|
|
1413
|
+
const h = harness()
|
|
1414
|
+
writeSessionState('partial-drain', h.env, {
|
|
1415
|
+
retiring: [
|
|
1416
|
+
{
|
|
1417
|
+
request_id: 'req_good',
|
|
1418
|
+
collapse_key: 'collapse-good',
|
|
1419
|
+
device_ids: ['dev_iphone'],
|
|
1420
|
+
question: 'First?',
|
|
1421
|
+
state: 'expired',
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
request_id: 'req_bad',
|
|
1425
|
+
collapse_key: 'collapse-bad',
|
|
1426
|
+
device_ids: [],
|
|
1427
|
+
question: 'Second?',
|
|
1428
|
+
state: 'expired',
|
|
1429
|
+
},
|
|
1430
|
+
],
|
|
1431
|
+
})
|
|
1432
|
+
const ctx = {
|
|
1433
|
+
client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'),
|
|
1434
|
+
config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
await expect(drainRetirements(ctx, 'partial-drain', h.env)).rejects.toThrow(
|
|
1438
|
+
/missing its device identifiers/,
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
expect(readSessionState('partial-drain', h.env).retiring?.map((entry) => entry.request_id)).toEqual([
|
|
1442
|
+
'req_bad',
|
|
1443
|
+
])
|
|
1444
|
+
})
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1109
1447
|
/**
|
|
1110
1448
|
* Path-independent ownership stops the usual cause
|
|
1111
1449
|
* of two handlers firing; this stops the consequence when something else does.
|
|
@@ -1134,6 +1472,19 @@ describe('two hooks racing one question', () => {
|
|
|
1134
1472
|
expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
|
|
1135
1473
|
})
|
|
1136
1474
|
|
|
1475
|
+
it('serializes a contender that arrives while a stale claim is being replaced', () => {
|
|
1476
|
+
const h = harness()
|
|
1477
|
+
claimQuestionPush('race-break', h.env, REAL - 10 * 60_000)
|
|
1478
|
+
let contender: ReturnType<typeof claimQuestionPush> | undefined
|
|
1479
|
+
|
|
1480
|
+
const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
|
|
1481
|
+
contender = claimQuestionPush('race-break', h.env, REAL)
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1484
|
+
expect(contender).toBeDefined()
|
|
1485
|
+
expect([recovered, contender].filter(Boolean)).toHaveLength(1)
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1137
1488
|
it('does not let two sessions block each other', () => {
|
|
1138
1489
|
const h = harness()
|
|
1139
1490
|
expect(claimQuestionPush('race4a', h.env, REAL)).toBe(true)
|
|
@@ -1153,3 +1504,45 @@ describe('two hooks racing one question', () => {
|
|
|
1153
1504
|
expect(h.io.errLines.join(" ")).toContain('already handling')
|
|
1154
1505
|
})
|
|
1155
1506
|
})
|
|
1507
|
+
|
|
1508
|
+
describe('question registration racing a Stop submission', () => {
|
|
1509
|
+
it('preserves a newer question when the older submit publishes its request id', async () => {
|
|
1510
|
+
const h = harness([], 900)
|
|
1511
|
+
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
1512
|
+
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
1513
|
+
h.recorder.beforeQuestionSubmit = () => {
|
|
1514
|
+
h.recorder.beforeQuestionSubmit = undefined
|
|
1515
|
+
registerQuestion('submit-race', h.env, { question: 'New question?' }, NOW + 1)
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
|
|
1519
|
+
|
|
1520
|
+
const state = readSessionState('submit-race', h.env)
|
|
1521
|
+
expect(state.pending?.question).toBe('New question?')
|
|
1522
|
+
expect(state.retiring).toContainEqual(
|
|
1523
|
+
expect.objectContaining({ request_id: 'req_hook_1', state: 'superseded' }),
|
|
1524
|
+
)
|
|
1525
|
+
})
|
|
1526
|
+
|
|
1527
|
+
it('keeps the newer question when the older in-flight question receives an answer', async () => {
|
|
1528
|
+
const h = harness([reply({ text: 'Old answer' })], 900)
|
|
1529
|
+
writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
|
|
1530
|
+
registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
|
|
1531
|
+
h.recorder.beforeQuestionSubmit = () => {
|
|
1532
|
+
h.recorder.beforeQuestionSubmit = undefined
|
|
1533
|
+
registerQuestion('answer-race', h.env, { question: 'New question?' }, NOW + 1)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-race' }))
|
|
1537
|
+
|
|
1538
|
+
const state = readSessionState('answer-race', h.env)
|
|
1539
|
+
expect(state.pending?.question).toBe('New question?')
|
|
1540
|
+
expect(
|
|
1541
|
+
h.recorder.submitted.find(
|
|
1542
|
+
(entry) =>
|
|
1543
|
+
entry.draft.event === 'question_retired' &&
|
|
1544
|
+
entry.draft.lifecycle?.retires_request_id === 'req_hook_1',
|
|
1545
|
+
)?.draft.lifecycle?.state,
|
|
1546
|
+
).toBe('answered')
|
|
1547
|
+
})
|
|
1548
|
+
})
|