@raidiant/notifai 0.1.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/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, realpathSync, utimesSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import type {
|
|
5
|
+
ListRepliesResponse,
|
|
6
|
+
ReplyView,
|
|
7
|
+
SubmissionReceipt,
|
|
8
|
+
SubmitNotificationRequestT,
|
|
9
|
+
} from '@raidiant/notifai-protocol'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import type { ApiClient } from './client.js'
|
|
12
|
+
import { EXIT, askCommand, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
|
|
13
|
+
import { loadConfig, sanitizeSessionId } from './config.js'
|
|
14
|
+
import {
|
|
15
|
+
claimQuestionPush,
|
|
16
|
+
drainOrphanRetirements,
|
|
17
|
+
isUserAway,
|
|
18
|
+
orphanRetirements,
|
|
19
|
+
pruneAbandonedSessions,
|
|
20
|
+
releaseQuestionPush,
|
|
21
|
+
readProjectSession,
|
|
22
|
+
readSessionState,
|
|
23
|
+
registerQuestion,
|
|
24
|
+
writeSessionState,
|
|
25
|
+
} from './hooks.js'
|
|
26
|
+
|
|
27
|
+
class CapturedIo implements CommandIo {
|
|
28
|
+
outLines: string[] = []
|
|
29
|
+
errLines: string[] = []
|
|
30
|
+
out(line: string) {
|
|
31
|
+
this.outLines.push(line)
|
|
32
|
+
}
|
|
33
|
+
err(line: string) {
|
|
34
|
+
this.errLines.push(line)
|
|
35
|
+
}
|
|
36
|
+
async confirm() {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
openUrl() {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Recorder {
|
|
43
|
+
submitted: SubmitNotificationRequestT[]
|
|
44
|
+
/** `submitted[i]`'s request id, so a test can name what it just sent. */
|
|
45
|
+
receipts: string[]
|
|
46
|
+
closed: string[]
|
|
47
|
+
/** Simulates an offline machine; retirement has to survive one. */
|
|
48
|
+
failSubmits?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
52
|
+
let submissions = 0
|
|
53
|
+
return {
|
|
54
|
+
beginPairing: notUsed,
|
|
55
|
+
pollPairing: notUsed,
|
|
56
|
+
// Both current companion apps register reply categories.
|
|
57
|
+
listDevices: async () => ({
|
|
58
|
+
devices: [
|
|
59
|
+
{
|
|
60
|
+
device_id: 'dev_iphone',
|
|
61
|
+
display_name: 'Furankuphone',
|
|
62
|
+
platform: 'ios' as const,
|
|
63
|
+
permission_status: 'authorized',
|
|
64
|
+
registration_healthy: true,
|
|
65
|
+
last_seen_at: null,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
device_id: 'dev_mac',
|
|
69
|
+
display_name: 'FurankuMac',
|
|
70
|
+
platform: 'macos' as const,
|
|
71
|
+
permission_status: 'authorized',
|
|
72
|
+
registration_healthy: true,
|
|
73
|
+
last_seen_at: null,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
capabilities: notUsed,
|
|
78
|
+
evidence: notUsed,
|
|
79
|
+
createMediaUpload: notUsed,
|
|
80
|
+
uploadMedia: notUsed,
|
|
81
|
+
health: async () => true,
|
|
82
|
+
submit: async (body) => {
|
|
83
|
+
if (recorder.failSubmits === true) throw new Error('offline')
|
|
84
|
+
recorder.submitted.push(body)
|
|
85
|
+
submissions += 1
|
|
86
|
+
recorder.receipts.push(`req_hook_${submissions}`)
|
|
87
|
+
return {
|
|
88
|
+
request_id: `req_hook_${submissions}`,
|
|
89
|
+
replayed: false,
|
|
90
|
+
overall: 'provider_accepted_all',
|
|
91
|
+
deliveries: [],
|
|
92
|
+
warnings: [],
|
|
93
|
+
} satisfies SubmissionReceipt
|
|
94
|
+
},
|
|
95
|
+
replies: async (requestId) =>
|
|
96
|
+
({ request_id: requestId, reply_expires_at: null, replies }) satisfies ListRepliesResponse,
|
|
97
|
+
closeReplies: async (requestId) => {
|
|
98
|
+
recorder.closed.push(requestId)
|
|
99
|
+
},
|
|
100
|
+
} as ApiClient
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function notUsed(): never {
|
|
104
|
+
throw new Error('not used in these tests')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reply(overrides: Partial<ReplyView> = {}): ReplyView {
|
|
108
|
+
return {
|
|
109
|
+
reply_id: 'rpl_test',
|
|
110
|
+
seq: 1,
|
|
111
|
+
delivery_id: 'del_test',
|
|
112
|
+
device_id: 'dev_test',
|
|
113
|
+
device_name: 'Furankuphone',
|
|
114
|
+
text: 'Allow',
|
|
115
|
+
choice_id: 'allow',
|
|
116
|
+
created_at: new Date().toISOString(),
|
|
117
|
+
...overrides,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Harness {
|
|
122
|
+
deps: CommandDeps
|
|
123
|
+
io: CapturedIo
|
|
124
|
+
recorder: Recorder
|
|
125
|
+
env: NodeJS.ProcessEnv
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const NOW = 1_800_000_000_000
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `idleSeconds` defaults to null — "this machine has no idle source" — so these
|
|
132
|
+
* cases exercise the degraded path and stay independent of whether the person
|
|
133
|
+
* running the suite is touching their own keyboard.
|
|
134
|
+
*/
|
|
135
|
+
function harness(replies: ReplyView[] = [], idleSeconds: number | null = null): Harness {
|
|
136
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-'))
|
|
137
|
+
const env: NodeJS.ProcessEnv = {
|
|
138
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
139
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
140
|
+
}
|
|
141
|
+
const io = new CapturedIo()
|
|
142
|
+
const recorder: Recorder = { submitted: [], receipts: [], closed: [] }
|
|
143
|
+
// Virtual clock: sleeps advance it instead of costing wall time. A frozen
|
|
144
|
+
// clock would make the reply poll's deadline unreachable and spin forever.
|
|
145
|
+
let clock = NOW
|
|
146
|
+
return {
|
|
147
|
+
io,
|
|
148
|
+
recorder,
|
|
149
|
+
env,
|
|
150
|
+
deps: {
|
|
151
|
+
io,
|
|
152
|
+
store: {
|
|
153
|
+
load: () => ({
|
|
154
|
+
machineId: 'mac_test',
|
|
155
|
+
secret: 'test-secret',
|
|
156
|
+
baseUrl: 'https://test.notifai.invalid',
|
|
157
|
+
machineName: 'test-machine',
|
|
158
|
+
}),
|
|
159
|
+
save: () => {},
|
|
160
|
+
clear: () => {},
|
|
161
|
+
describe: () => 'test credential store',
|
|
162
|
+
},
|
|
163
|
+
env,
|
|
164
|
+
cwd: root,
|
|
165
|
+
clientFactory: () => fakeClient(recorder, replies),
|
|
166
|
+
now: () => clock,
|
|
167
|
+
idleSeconds: () => idleSeconds,
|
|
168
|
+
sleep: async (milliseconds: number) => {
|
|
169
|
+
clock += milliseconds
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function stdin(payload: unknown): () => Promise<string> {
|
|
176
|
+
return async () => JSON.stringify(payload)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Long enough ago to be away under the 120s default. */
|
|
180
|
+
const AWAY = NOW - 600_000
|
|
181
|
+
const PRESENT = NOW - 5_000
|
|
182
|
+
|
|
183
|
+
describe('presence gate', () => {
|
|
184
|
+
it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
|
|
185
|
+
const { env, deps } = harness()
|
|
186
|
+
const config = loadConfig({ cwd: deps.cwd, env })
|
|
187
|
+
expect(isUserAway({}, config, NOW, null)).toBe(false)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('is away only once the configured silence has elapsed', () => {
|
|
191
|
+
const { env, deps } = harness()
|
|
192
|
+
const config = loadConfig({ cwd: deps.cwd, env })
|
|
193
|
+
expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
|
|
194
|
+
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// The case that motivated this: "run the full test suite", then three
|
|
198
|
+
// minutes of watching. Elapsed time alone said away; the machine knows better.
|
|
199
|
+
it('keeps a user who is watching a long turn present, however long the turn ran', () => {
|
|
200
|
+
const { env, deps } = harness()
|
|
201
|
+
const config = loadConfig({ cwd: deps.cwd, env })
|
|
202
|
+
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Found by a live Claude Code session: a spawned agent's session
|
|
206
|
+
// always has a just-set last_prompt_at, so requiring session silence too meant
|
|
207
|
+
// its FIRST question could never escalate — the "kick off agents and walk
|
|
208
|
+
// away" case the feature is for.
|
|
209
|
+
it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
|
|
210
|
+
const { env, deps } = harness()
|
|
211
|
+
const config = loadConfig({ cwd: deps.cwd, env })
|
|
212
|
+
expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
|
|
213
|
+
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('pushes a spawned session first question once the machine has gone quiet', async () => {
|
|
217
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
218
|
+
// Prompt 20s ago, exactly as a just-spawned agent has.
|
|
219
|
+
writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
|
|
220
|
+
registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
|
|
221
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'spawn1' }))
|
|
222
|
+
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
223
|
+
expect(h.recorder.submitted[0]?.draft.targets).toEqual({
|
|
224
|
+
mode: 'selected',
|
|
225
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('falls back to elapsed time where no idle source exists', () => {
|
|
230
|
+
const { env, deps } = harness()
|
|
231
|
+
const config = loadConfig({ cwd: deps.cwd, env })
|
|
232
|
+
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not push a question to the phone while the user is at the keyboard', async () => {
|
|
236
|
+
// Silent for ten minutes by the session clock, but active by the machine's.
|
|
237
|
+
const h = harness([reply({ text: 'Yes' })], 2)
|
|
238
|
+
writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
|
|
239
|
+
registerQuestion('idle1', h.env, { question: 'Ship it?' })
|
|
240
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
|
|
241
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
242
|
+
expect(h.io.errLines.join('\n')).toContain('at the keyboard')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Wanting notifications while still at the machine is a legitimate setting —
|
|
248
|
+
* nobody should have to stop using their computer in order to be reached.
|
|
249
|
+
* Presence is a precondition only while the user wants it to be, and switching
|
|
250
|
+
* it off must not disturb the grace timer.
|
|
251
|
+
*/
|
|
252
|
+
describe('presence gating is optional (require_idle)', () => {
|
|
253
|
+
function writeGlobalConfig(h: Harness, toml: string): void {
|
|
254
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
255
|
+
mkdirSync(dir, { recursive: true })
|
|
256
|
+
writeFileSync(path.join(dir, 'config.toml'), toml)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
it('counts a user actively typing as reachable', () => {
|
|
260
|
+
// One second of idle: as present as it is possible to be.
|
|
261
|
+
const config = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
|
|
262
|
+
expect(isUserAway({ last_prompt_at: NOW }, config, NOW, 1)).toBe(false)
|
|
263
|
+
|
|
264
|
+
const off = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
|
|
265
|
+
off.require_idle = { value: false, source: 'default' }
|
|
266
|
+
expect(isUserAway({ last_prompt_at: NOW }, off, NOW, 1)).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('escalates a question while the user is at the keyboard', async () => {
|
|
270
|
+
const h = harness([], 1)
|
|
271
|
+
writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 0\n')
|
|
272
|
+
writeSessionState('present1', h.env, { last_prompt_at: NOW })
|
|
273
|
+
registerQuestion('present1', h.env, { question: 'Ship it?' })
|
|
274
|
+
|
|
275
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
|
|
276
|
+
|
|
277
|
+
expect(h.recorder.submitted).toHaveLength(1)
|
|
278
|
+
expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('still gives the terminal its grace window first', async () => {
|
|
282
|
+
// The two knobs are independent: not needing the user to leave does not
|
|
283
|
+
// mean skipping the wait that offers the question to the terminal.
|
|
284
|
+
const h = harness([], 1)
|
|
285
|
+
writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 300\n')
|
|
286
|
+
writeSessionState('present2', h.env, { last_prompt_at: NOW })
|
|
287
|
+
registerQuestion('present2', h.env, { question: 'Ship it?' }, NOW)
|
|
288
|
+
|
|
289
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
|
|
290
|
+
|
|
291
|
+
expect(h.recorder.submitted).toHaveLength(1)
|
|
292
|
+
// The clock only advances when the hook sleeps, so this is proof the wait
|
|
293
|
+
// actually happened rather than being skipped.
|
|
294
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('honours the grace window on a machine with no idle source at all', async () => {
|
|
298
|
+
// With presence gating on, no idle source means refusing to wait
|
|
299
|
+
// ('no-signal'). With it off there is nothing to watch for, so the timer
|
|
300
|
+
// is just a timer and works everywhere.
|
|
301
|
+
const h = harness([], null)
|
|
302
|
+
writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 120\n')
|
|
303
|
+
registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
|
|
304
|
+
|
|
305
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
306
|
+
|
|
307
|
+
expect(h.recorder.submitted).toHaveLength(1)
|
|
308
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
309
|
+
expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('is not a way to switch escalation on when the user has switched it off', async () => {
|
|
313
|
+
// ask_notifications is the "do not reach me" switch and outranks this one;
|
|
314
|
+
// wanting to be reachable while working is a different question entirely.
|
|
315
|
+
const h = harness([], 1)
|
|
316
|
+
writeGlobalConfig(h, 'require_idle = false\nask_notifications = false\n')
|
|
317
|
+
registerQuestion('present4', h.env, { question: 'Ship it?' })
|
|
318
|
+
|
|
319
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
|
|
320
|
+
|
|
321
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('terminal-first grace window', () => {
|
|
326
|
+
/** Registers a question asked `agoMs` ago, with the user long since silent. */
|
|
327
|
+
function pending(h: Harness, session: string, agoMs: number): void {
|
|
328
|
+
writeSessionState(session, h.env, { last_prompt_at: AWAY })
|
|
329
|
+
registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
it('holds the question in the terminal until the window elapses', async () => {
|
|
333
|
+
// Idle 900s: the user is gone, so the wait runs to completion rather than
|
|
334
|
+
// being abandoned. Sleeps advance the virtual clock.
|
|
335
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
336
|
+
pending(h, 'g1', 0)
|
|
337
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
|
|
338
|
+
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
339
|
+
// Nothing was pushed before the 300s default had passed.
|
|
340
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('counts the window from when the question was sent, not from the turn end', async () => {
|
|
344
|
+
// Asked 290s ago while the agent kept working: only 10s of wait remains.
|
|
345
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
346
|
+
pending(h, 'g2', 290_000)
|
|
347
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g2' }))
|
|
348
|
+
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
349
|
+
expect(h.deps.now?.()).toBeLessThan(NOW + 60_000)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('sends nothing if the user comes back to the keyboard during the window', async () => {
|
|
353
|
+
let idle = 900
|
|
354
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
355
|
+
// Machine goes active on the second poll: the user sat down.
|
|
356
|
+
h.deps.idleSeconds = () => {
|
|
357
|
+
const current = idle
|
|
358
|
+
idle = 1
|
|
359
|
+
return current
|
|
360
|
+
}
|
|
361
|
+
pending(h, 'g3', 0)
|
|
362
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g3' }))
|
|
363
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
364
|
+
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('refuses to hold a terminal it cannot monitor', async () => {
|
|
368
|
+
// No idle source: waiting would block the prompt with no way to notice the
|
|
369
|
+
// user returning, so it asks immediately instead.
|
|
370
|
+
const h = harness([reply({ text: 'Yes' })], null)
|
|
371
|
+
pending(h, 'g4', 0)
|
|
372
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g4' }))
|
|
373
|
+
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
374
|
+
expect(h.deps.now?.()).toBe(NOW)
|
|
375
|
+
expect(h.io.errLines.join('\n')).toContain('no idle signal')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('never lets the window crowd out the reply wait past the hook budget', async () => {
|
|
379
|
+
// Both dials at maximum would be 540 + 540 — nearly twice the 600s ceiling
|
|
380
|
+
// the harness kills a hook at, and a killed hook loses an answer already
|
|
381
|
+
// given. The reply wait wins, so the window yields to nothing at all.
|
|
382
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
383
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
384
|
+
mkdirSync(dir, { recursive: true })
|
|
385
|
+
writeFileSync(
|
|
386
|
+
path.join(dir, 'config.toml'),
|
|
387
|
+
'ask_grace_seconds = 540\nhook_reply_timeout_seconds = 540\n',
|
|
388
|
+
)
|
|
389
|
+
pending(h, 'g5', 0)
|
|
390
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
|
|
391
|
+
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
392
|
+
expect(h.deps.now?.()).toBe(NOW)
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
describe('config resolution inside a hook', () => {
|
|
397
|
+
it('reads project config from the session cwd, not the hook process cwd', async () => {
|
|
398
|
+
const h = harness([reply({ choice_id: 'allow' })])
|
|
399
|
+
// A project that has turned the feature off.
|
|
400
|
+
const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-proj-'))
|
|
401
|
+
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
402
|
+
writeFileSync(
|
|
403
|
+
path.join(project, '.notifai', 'config.local.toml'),
|
|
404
|
+
'ask_notifications = false\n',
|
|
405
|
+
)
|
|
406
|
+
writeSessionState('c1', h.env, { last_prompt_at: AWAY })
|
|
407
|
+
registerQuestion('c1', h.env, { question: 'Ship it?' })
|
|
408
|
+
|
|
409
|
+
// deps.cwd is elsewhere; only envelope.cwd points at the project. The
|
|
410
|
+
// payload's cwd is the harness's statement of which project this is, and
|
|
411
|
+
// must win over whatever directory we were spawned in.
|
|
412
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'c1', cwd: project }))
|
|
413
|
+
|
|
414
|
+
expect(h.recorder.submitted).toEqual([])
|
|
415
|
+
expect(h.io.outLines).toEqual([])
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
describe('nagging guards', () => {
|
|
420
|
+
it('does not ask twice for one question across successive Stops', async () => {
|
|
421
|
+
const h = harness([])
|
|
422
|
+
writeSessionState('n1', h.env, { last_prompt_at: AWAY })
|
|
423
|
+
registerQuestion('n1', h.env, { question: 'Ship it?' })
|
|
424
|
+
|
|
425
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
426
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
427
|
+
|
|
428
|
+
expect(h.recorder.submitted).toHaveLength(1)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('respects the harness recursion guard', async () => {
|
|
432
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
433
|
+
writeSessionState('n2', h.env, { last_prompt_at: AWAY })
|
|
434
|
+
registerQuestion('n2', h.env, { question: 'Ship it?' })
|
|
435
|
+
|
|
436
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n2', stop_hook_active: true }))
|
|
437
|
+
|
|
438
|
+
expect(h.recorder.submitted).toEqual([])
|
|
439
|
+
expect(h.io.outLines).toEqual([])
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
describe('Cursor stop output', () => {
|
|
444
|
+
it('maps the phone answer to one native followup_message', async () => {
|
|
445
|
+
const h = harness([reply({ text: 'Ship it' })], 900)
|
|
446
|
+
writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
|
|
447
|
+
registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
|
|
448
|
+
|
|
449
|
+
await hookRunCommand(
|
|
450
|
+
h.deps,
|
|
451
|
+
'stop',
|
|
452
|
+
stdin({
|
|
453
|
+
conversation_id: 'cursor-conversation',
|
|
454
|
+
workspace_roots: [h.deps.cwd],
|
|
455
|
+
loop_count: 0,
|
|
456
|
+
}),
|
|
457
|
+
'cursor',
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
461
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as Record<string, unknown>
|
|
462
|
+
expect(output['followup_message']).toContain(
|
|
463
|
+
'NotifAI — the user answered from Furankuphone: "Ship it". Continue with that answer.',
|
|
464
|
+
)
|
|
465
|
+
expect(output).not.toHaveProperty('decision')
|
|
466
|
+
})
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Stale questions piling up on a phone are worse than no questions at all:
|
|
471
|
+
* they teach the user to ignore the surface. Every one of them was a delivered
|
|
472
|
+
* question whose ids had been thrown away, so nothing could reach it again.
|
|
473
|
+
*/
|
|
474
|
+
describe('superseding a live question', () => {
|
|
475
|
+
/** The lifecycle state of each retirement push the recorder captured. */
|
|
476
|
+
function retirements(h: Harness): { state: unknown; retires: unknown }[] {
|
|
477
|
+
return h.recorder.submitted
|
|
478
|
+
.filter((s) => s.draft.event === 'question_retired')
|
|
479
|
+
.map((s) => ({
|
|
480
|
+
state: s.draft.lifecycle?.state,
|
|
481
|
+
retires: s.draft.lifecycle?.retires_request_id,
|
|
482
|
+
}))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
it('retires the first question instead of orphaning it', async () => {
|
|
486
|
+
const h = harness([])
|
|
487
|
+
writeSessionState('sup1', h.env, { last_prompt_at: AWAY })
|
|
488
|
+
registerQuestion('sup1', h.env, { question: 'Ship it?' })
|
|
489
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
490
|
+
const first = h.recorder.receipts[0]
|
|
491
|
+
expect(first).toBeDefined()
|
|
492
|
+
|
|
493
|
+
// The agent carried on and asked something else. The first question is
|
|
494
|
+
// dead the moment this returns, whether or not anyone answers the second.
|
|
495
|
+
registerQuestion('sup1', h.env, { question: 'Deploy it?' })
|
|
496
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
497
|
+
|
|
498
|
+
expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
|
|
499
|
+
// And the replacement was still asked; superseding is not suppressing.
|
|
500
|
+
expect(
|
|
501
|
+
h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
|
|
502
|
+
).toHaveLength(1)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
|
|
506
|
+
const h = harness([])
|
|
507
|
+
writeSessionState('sup2', h.env, { last_prompt_at: AWAY })
|
|
508
|
+
registerQuestion('sup2', h.env, { question: 'Ship it?' })
|
|
509
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
510
|
+
const first = h.recorder.receipts[0]
|
|
511
|
+
|
|
512
|
+
registerQuestion('sup2', h.env, { question: 'Deploy it?' })
|
|
513
|
+
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
514
|
+
|
|
515
|
+
// Offline: the drain runs, fails, and must not forget what it was for.
|
|
516
|
+
h.recorder.failSubmits = true
|
|
517
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
518
|
+
expect(readSessionState('sup2', h.env).retiring).toHaveLength(1)
|
|
519
|
+
|
|
520
|
+
h.recorder.failSubmits = false
|
|
521
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
522
|
+
expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
|
|
523
|
+
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
527
|
+
// stop_hook_active short-circuits the escalation path, and a superseding
|
|
528
|
+
// turn is very often exactly this turn.
|
|
529
|
+
const h = harness([])
|
|
530
|
+
writeSessionState('sup3', h.env, { last_prompt_at: AWAY })
|
|
531
|
+
registerQuestion('sup3', h.env, { question: 'Ship it?' })
|
|
532
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
|
|
533
|
+
const first = h.recorder.receipts[0]
|
|
534
|
+
|
|
535
|
+
registerQuestion('sup3', h.env, { question: 'Deploy it?' })
|
|
536
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3', stop_hook_active: true }))
|
|
537
|
+
|
|
538
|
+
expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('does not lose a queued retirement when the user comes back to the terminal', async () => {
|
|
542
|
+
// UserPromptSubmit resets session state to record presence, and that reset
|
|
543
|
+
// used to take the retirement queue with it.
|
|
544
|
+
const h = harness([])
|
|
545
|
+
writeSessionState('sup4', h.env, { last_prompt_at: AWAY })
|
|
546
|
+
registerQuestion('sup4', h.env, { question: 'Ship it?' })
|
|
547
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
|
|
548
|
+
const first = h.recorder.receipts[0]
|
|
549
|
+
|
|
550
|
+
registerQuestion('sup4', h.env, { question: 'Deploy it?' })
|
|
551
|
+
h.recorder.failSubmits = true
|
|
552
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
|
|
553
|
+
h.recorder.failSubmits = false
|
|
554
|
+
|
|
555
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup4' }))
|
|
556
|
+
|
|
557
|
+
expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
|
|
558
|
+
expect(readSessionState('sup4', h.env).retiring).toEqual([])
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('parks nothing for a question that never reached a device', async () => {
|
|
562
|
+
// No request_id means there is no notification anywhere to retire, and a
|
|
563
|
+
// retirement push for one would be pure noise.
|
|
564
|
+
const h = harness([])
|
|
565
|
+
registerQuestion('sup5', h.env, { question: 'Ship it?' })
|
|
566
|
+
registerQuestion('sup5', h.env, { question: 'Deploy it?' })
|
|
567
|
+
|
|
568
|
+
expect(readSessionState('sup5', h.env).retiring ?? []).toEqual([])
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
describe('a question that outlives its session', () => {
|
|
573
|
+
function retirements(h: Harness): { state: unknown; retires: unknown }[] {
|
|
574
|
+
return h.recorder.submitted
|
|
575
|
+
.filter((s) => s.draft.event === 'question_retired')
|
|
576
|
+
.map((s) => ({
|
|
577
|
+
state: s.draft.lifecycle?.state,
|
|
578
|
+
retires: s.draft.lifecycle?.retires_request_id,
|
|
579
|
+
}))
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Escalate a question that nobody answers, so it is live on the devices. */
|
|
583
|
+
async function pushUnanswered(h: Harness, sessionId: string): Promise<string> {
|
|
584
|
+
writeSessionState(sessionId, h.env, { last_prompt_at: AWAY })
|
|
585
|
+
registerQuestion(sessionId, h.env, { question: 'Ship it?' })
|
|
586
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: sessionId }))
|
|
587
|
+
const requestId = h.recorder.receipts[0]
|
|
588
|
+
expect(requestId).toBeDefined()
|
|
589
|
+
return requestId!
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
it('retires a question the harness exited on, from a later session', async () => {
|
|
593
|
+
const h = harness([])
|
|
594
|
+
const first = await pushUnanswered(h, 'dead1')
|
|
595
|
+
|
|
596
|
+
// The user quits the harness. SessionEnd cannot reach the network, so the
|
|
597
|
+
// question must survive the state file it used to die with.
|
|
598
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead1' }))
|
|
599
|
+
expect(readSessionState('dead1', h.env)).toEqual({})
|
|
600
|
+
|
|
601
|
+
// A different session's next hook holds a client and inherits the debt.
|
|
602
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'next1' }))
|
|
603
|
+
expect(h.recorder.closed).toContain(first)
|
|
604
|
+
expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
it('carries parked retirements across SessionEnd too', async () => {
|
|
608
|
+
const h = harness([])
|
|
609
|
+
const first = await pushUnanswered(h, 'dead2')
|
|
610
|
+
|
|
611
|
+
// Superseded while offline: the retirement is parked, not sent.
|
|
612
|
+
registerQuestion('dead2', h.env, { question: 'Deploy it?' })
|
|
613
|
+
h.recorder.failSubmits = true
|
|
614
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'dead2' }))
|
|
615
|
+
h.recorder.failSubmits = false
|
|
616
|
+
|
|
617
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead2' }))
|
|
618
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next2' }))
|
|
619
|
+
expect(retirements(h)).toContainEqual({ state: 'superseded', retires: first })
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('keeps the debt when the drain fails, and drops entries past the TTL', async () => {
|
|
623
|
+
const h = harness([])
|
|
624
|
+
const first = await pushUnanswered(h, 'dead3')
|
|
625
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead3' }))
|
|
626
|
+
|
|
627
|
+
// Still offline: the queue must survive a failed drain.
|
|
628
|
+
h.recorder.failSubmits = true
|
|
629
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
630
|
+
h.recorder.failSubmits = false
|
|
631
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
632
|
+
expect(retirements(h)).toContainEqual({ state: 'expired', retires: first })
|
|
633
|
+
|
|
634
|
+
// And a second drain does not send it twice.
|
|
635
|
+
const count = retirements(h).filter((r) => r.retires === first).length
|
|
636
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next3' }))
|
|
637
|
+
expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('gives up on an orphan older than a day instead of queueing it for ever', async () => {
|
|
641
|
+
const h = harness([])
|
|
642
|
+
orphanRetirements(
|
|
643
|
+
h.env,
|
|
644
|
+
[{ request_id: 'req_old', collapse_key: 'ck_old', question: 'Old?', state: 'expired' }],
|
|
645
|
+
undefined,
|
|
646
|
+
NOW - 25 * 3600 * 1000,
|
|
647
|
+
)
|
|
648
|
+
const drained = await drainOrphanRetirements(
|
|
649
|
+
{ client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
|
|
650
|
+
h.env,
|
|
651
|
+
NOW,
|
|
652
|
+
)
|
|
653
|
+
// Dropped as handled, but no retirement push was spent on it.
|
|
654
|
+
expect(drained).toContain('req_old')
|
|
655
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it('queues nothing for a session with nothing live on the devices', async () => {
|
|
659
|
+
const h = harness([])
|
|
660
|
+
registerQuestion('dead4', h.env, { question: 'Ship it?' })
|
|
661
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 'dead4' }))
|
|
662
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'next4' }))
|
|
663
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
664
|
+
expect(h.recorder.closed).toHaveLength(0)
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
describe('hostile input', () => {
|
|
669
|
+
it('never sends the credential to a base_url a repository asked for', async () => {
|
|
670
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
671
|
+
const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-evil-'))
|
|
672
|
+
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
673
|
+
writeFileSync(
|
|
674
|
+
path.join(project, '.notifai', 'config.toml'),
|
|
675
|
+
'base_url = "https://attacker.example"\n',
|
|
676
|
+
)
|
|
677
|
+
writeSessionState('h1', h.env, { last_prompt_at: AWAY })
|
|
678
|
+
registerQuestion('h1', h.env, { question: 'Ship it?' })
|
|
679
|
+
|
|
680
|
+
let seen: string | null = null
|
|
681
|
+
h.deps.clientFactory = (baseUrl) => {
|
|
682
|
+
seen = baseUrl
|
|
683
|
+
return fakeClient(h.recorder, [reply({ text: 'Yes' })])
|
|
684
|
+
}
|
|
685
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'h1', cwd: project }))
|
|
686
|
+
|
|
687
|
+
expect(seen).toBe('https://test.notifai.invalid')
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('clamps an out-of-range away threshold instead of trusting it', () => {
|
|
691
|
+
const h = harness()
|
|
692
|
+
const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-bounds-'))
|
|
693
|
+
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
694
|
+
writeFileSync(path.join(project, '.notifai', 'config.toml'), 'away_after_seconds = -1\n')
|
|
695
|
+
const config = loadConfig({ cwd: project, env: h.env })
|
|
696
|
+
// -1 would make someone who just typed count as absent.
|
|
697
|
+
expect(config.away_after_seconds.value).toBeGreaterThanOrEqual(5)
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
describe('ask registration', () => {
|
|
702
|
+
it('rejects a malformed choice set at registration, not at push time', () => {
|
|
703
|
+
const h = harness()
|
|
704
|
+
// Inside a hook, a rejection is only a stderr note the agent never reads —
|
|
705
|
+
// so it would look registered and then silently never ask.
|
|
706
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: 'Yes|No', session: 'a1' })).toBe(EXIT.usage)
|
|
707
|
+
expect(readSessionState('a1', h.env).pending).toBeUndefined()
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it('stores validated labels verbatim, commas and all', () => {
|
|
711
|
+
const h = harness()
|
|
712
|
+
expect(
|
|
713
|
+
askCommand(h.deps, 'Ship it?', {
|
|
714
|
+
choice: ['Yes, ship it', 'No, hold'],
|
|
715
|
+
session: 'a2',
|
|
716
|
+
}),
|
|
717
|
+
).toBe(EXIT.ok)
|
|
718
|
+
expect(readSessionState('a2', h.env).pending?.choices).toEqual(['Yes, ship it', 'No, hold'])
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it('resolves the session as flag, then hook pointer, then NOTIFAI_SESSION', async () => {
|
|
722
|
+
// The exported id is often a chosen label while hook state is keyed by the
|
|
723
|
+
// harness's own id, so the pointer must outrank the env var.
|
|
724
|
+
const h = harness()
|
|
725
|
+
h.deps.env['NOTIFAI_SESSION'] = 'my-label'
|
|
726
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'real1', cwd: h.deps.cwd }))
|
|
727
|
+
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
728
|
+
expect(readSessionState('real1', h.env).pending?.question).toBe('Ship it?')
|
|
729
|
+
expect(readSessionState('my-label', h.env).pending).toBeUndefined()
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('falls back to NOTIFAI_SESSION where no hook has spoken', () => {
|
|
733
|
+
const h = harness()
|
|
734
|
+
h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
|
|
735
|
+
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
736
|
+
expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
|
|
737
|
+
})
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
describe('user-prompt-submit hook', () => {
|
|
741
|
+
it('records presence', async () => {
|
|
742
|
+
const h = harness()
|
|
743
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's10' }))
|
|
744
|
+
expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
it('retires a question a real timed-out Stop left live on the devices', async () => {
|
|
748
|
+
// Drives the actual flow rather than hand-writing state: the previous
|
|
749
|
+
// version of this test fabricated a shape production never wrote, so it
|
|
750
|
+
// passed while the retirement path was unreachable.
|
|
751
|
+
const h = harness([])
|
|
752
|
+
writeSessionState('s11', h.env, { last_prompt_at: AWAY })
|
|
753
|
+
registerQuestion('s11', h.env, { question: 'Which environment?' })
|
|
754
|
+
|
|
755
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
756
|
+
const live = readSessionState('s11', h.env).pending
|
|
757
|
+
expect(live?.request_id).toBe('req_hook_1')
|
|
758
|
+
expect(live?.collapse_key).toBeDefined()
|
|
759
|
+
|
|
760
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
|
|
761
|
+
|
|
762
|
+
expect(h.recorder.closed).toEqual(['req_hook_1'])
|
|
763
|
+
const retirement = h.recorder.submitted.at(-1)?.draft
|
|
764
|
+
expect(retirement?.presentation.title).toBe('Answered in the terminal')
|
|
765
|
+
expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
|
|
766
|
+
expect(retirement?.reply).toBeUndefined()
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
|
|
770
|
+
// A state change is not news: the retirement must ride the wire as a
|
|
771
|
+
// lifecycle update, which the server renders as a background push — the
|
|
772
|
+
// old "Answered" tombstone alert told the user what they just did.
|
|
773
|
+
const h = harness([])
|
|
774
|
+
writeSessionState('s15', h.env, { last_prompt_at: AWAY })
|
|
775
|
+
registerQuestion('s15', h.env, { question: 'Which environment?' })
|
|
776
|
+
|
|
777
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's15' }))
|
|
778
|
+
expect(h.recorder.submitted[0]?.draft.lifecycle).toEqual({ tier: 'needs_you' })
|
|
779
|
+
|
|
780
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
|
|
781
|
+
|
|
782
|
+
const retirement = h.recorder.submitted.at(-1)?.draft
|
|
783
|
+
expect(retirement?.lifecycle).toEqual({
|
|
784
|
+
tier: 'done',
|
|
785
|
+
state: 'answered_elsewhere',
|
|
786
|
+
// The history-entry correlation id: companions key entries by request
|
|
787
|
+
// id and never persisted the collapse key.
|
|
788
|
+
retires_request_id: 'req_hook_1',
|
|
789
|
+
})
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
it('retires as done/answered when the answer came from a device', async () => {
|
|
793
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
794
|
+
registerQuestion('s16', h.env, { question: 'Ship it?' })
|
|
795
|
+
|
|
796
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
|
|
797
|
+
|
|
798
|
+
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
799
|
+
expect(retirement?.draft.lifecycle).toEqual({
|
|
800
|
+
tier: 'done',
|
|
801
|
+
state: 'answered',
|
|
802
|
+
retires_request_id: 'req_hook_1',
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
it('publishes a session pointer so a plain `notifai ask` can find itself', async () => {
|
|
807
|
+
const h = harness()
|
|
808
|
+
// A real directory reachable by two names. os.tmpdir() is /var/folders on
|
|
809
|
+
// macOS, so create under /tmp explicitly to get the symlinked pair that
|
|
810
|
+
// broke this live: the harness reports cwd unresolved, a shell resolved.
|
|
811
|
+
const viaSymlink = mkdtempSync('/tmp/notifai-ptr-')
|
|
812
|
+
const project = realpathSync(viaSymlink)
|
|
813
|
+
if (project === viaSymlink) return // no symlink on this platform; nothing to prove
|
|
814
|
+
|
|
815
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's14', cwd: viaSymlink }))
|
|
816
|
+
|
|
817
|
+
expect(readProjectSession(project, h.env, NOW)).toBe('s14')
|
|
818
|
+
expect(readProjectSession(viaSymlink, h.env, NOW)).toBe('s14')
|
|
819
|
+
// A pointer older than a day is not evidence of a live session.
|
|
820
|
+
expect(readProjectSession(project, h.env, NOW + 2 * 24 * 3600 * 1000)).toBeNull()
|
|
821
|
+
})
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
describe('session-end hook', () => {
|
|
825
|
+
it('drops local state without touching the network, inside its ~1s budget', async () => {
|
|
826
|
+
const h = harness()
|
|
827
|
+
writeSessionState('s12', h.env, { last_prompt_at: NOW })
|
|
828
|
+
|
|
829
|
+
const code = await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's12' }))
|
|
830
|
+
|
|
831
|
+
expect(code).toBe(EXIT.ok)
|
|
832
|
+
expect(readSessionState('s12', h.env)).toEqual({})
|
|
833
|
+
expect(h.recorder.closed).toEqual([])
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
describe('malformed input', () => {
|
|
838
|
+
it('never fails the harness on unparseable hook JSON', async () => {
|
|
839
|
+
const h = harness()
|
|
840
|
+
const code = await hookRunCommand(h.deps, 'stop', async () => 'not json{')
|
|
841
|
+
expect(code).toBe(EXIT.ok)
|
|
842
|
+
expect(h.io.outLines).toEqual([])
|
|
843
|
+
})
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
describe('telling concurrent agents apart', () => {
|
|
847
|
+
it('stamps the harness session on the question it pushes', async () => {
|
|
848
|
+
// The hook has always known session_id and never passed it on, so two
|
|
849
|
+
// agents in separate worktrees produced identical notifications and the
|
|
850
|
+
// user could answer the wrong one's question.
|
|
851
|
+
const h = harness([], 900)
|
|
852
|
+
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
853
|
+
|
|
854
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
855
|
+
|
|
856
|
+
expect(h.recorder.submitted[0]?.draft.session).toBe('sess-abc')
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
|
|
860
|
+
const h = harness([reply({ text: 'Yes' })], 900)
|
|
861
|
+
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
862
|
+
|
|
863
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
864
|
+
|
|
865
|
+
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
866
|
+
expect(retirement?.draft.session).toBe('sess-abc')
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('prefers a name the user chose over the harness UUID', async () => {
|
|
870
|
+
const h = harness([], 900)
|
|
871
|
+
h.env['NOTIFAI_SESSION'] = 'migration-worktree'
|
|
872
|
+
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
873
|
+
|
|
874
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
875
|
+
|
|
876
|
+
expect(h.recorder.submitted[0]?.draft.session).toBe('migration-worktree')
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
describe('clock jumps', () => {
|
|
881
|
+
const config = loadConfig({ cwd: '/nowhere', env: {} })
|
|
882
|
+
|
|
883
|
+
it('does not hijack a terminal because the clock jumped forward', () => {
|
|
884
|
+
// NTP correction or a VM resume moves `now` without any time passing for
|
|
885
|
+
// the person sitting at the keyboard. Without an idle source there is
|
|
886
|
+
// nothing to check the delta against, so a huge one is not evidence.
|
|
887
|
+
const state = { last_prompt_at: NOW - 400 * 24 * 3600 * 1000 }
|
|
888
|
+
expect(isUserAway(state, config, NOW, null)).toBe(false)
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
it('does not read a backward jump as the user being present either', () => {
|
|
892
|
+
// A negative delta is nonsense, not freshness. It resolves the same way:
|
|
893
|
+
// no evidence, so leave the terminal alone.
|
|
894
|
+
expect(isUserAway({ last_prompt_at: NOW + 60_000 }, config, NOW, null)).toBe(false)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('still escalates on an ordinary long silence', () => {
|
|
898
|
+
expect(isUserAway({ last_prompt_at: NOW - 3600_000 }, config, NOW, null)).toBe(true)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('lets the OS idle signal decide regardless of the wall clock', () => {
|
|
902
|
+
// The idle probe measures elapsed time directly, so it is unaffected — and
|
|
903
|
+
// it outranks the proxy anyway.
|
|
904
|
+
const nonsense = { last_prompt_at: NOW + 999_999_999 }
|
|
905
|
+
expect(isUserAway(nonsense, config, NOW, 900)).toBe(true)
|
|
906
|
+
expect(isUserAway(nonsense, config, NOW, 1)).toBe(false)
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
it('does not let a stamp from the future hold the terminal past the budget', async () => {
|
|
910
|
+
// asked_at is wall-clock too. A future stamp used to make the grace window
|
|
911
|
+
// unreachable, blocking Stop until the harness killed it.
|
|
912
|
+
const h = harness([], 900)
|
|
913
|
+
registerQuestion('sess-jump', h.env, { question: 'Ship it?' }, NOW + 30 * 24 * 3600 * 1000)
|
|
914
|
+
|
|
915
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-jump' }))
|
|
916
|
+
|
|
917
|
+
// It escalated rather than spinning: the window restarted from now.
|
|
918
|
+
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
919
|
+
})
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* SessionEnd cleans up, but a crashed harness never reaches it,
|
|
924
|
+
* and roughly a hundred sessions a day is tens of thousands of dead files a
|
|
925
|
+
* year that nothing reads.
|
|
926
|
+
*/
|
|
927
|
+
describe('pruning abandoned session state', () => {
|
|
928
|
+
function sessionFile(h: Harness, name: string): string {
|
|
929
|
+
return path.join(h.env['XDG_STATE_HOME'] as string, 'notifai', 'sessions', name)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Real wall-clock, because this reasons about file mtimes.
|
|
933
|
+
const REAL = Date.now()
|
|
934
|
+
|
|
935
|
+
it('removes state a crashed harness left behind, and keeps live state', () => {
|
|
936
|
+
const h = harness()
|
|
937
|
+
writeSessionState('alive', h.env, { last_prompt_at: NOW })
|
|
938
|
+
writeSessionState('abandoned', h.env, { last_prompt_at: NOW })
|
|
939
|
+
const old = new Date(REAL - 30 * 24 * 3600 * 1000)
|
|
940
|
+
utimesSync(sessionFile(h, `${sanitizeSessionId('abandoned')}.json`), old, old)
|
|
941
|
+
|
|
942
|
+
expect(pruneAbandonedSessions(h.env, REAL)).toBe(1)
|
|
943
|
+
|
|
944
|
+
expect(readSessionState('alive', h.env).last_prompt_at).toBe(NOW)
|
|
945
|
+
expect(readSessionState('abandoned', h.env)).toEqual({})
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('does not walk the directory again for a day', () => {
|
|
949
|
+
const h = harness()
|
|
950
|
+
writeSessionState('a', h.env, { last_prompt_at: NOW })
|
|
951
|
+
pruneAbandonedSessions(h.env, REAL)
|
|
952
|
+
|
|
953
|
+
const old = new Date(REAL - 30 * 24 * 3600 * 1000)
|
|
954
|
+
utimesSync(sessionFile(h, `${sanitizeSessionId('a')}.json`), old, old)
|
|
955
|
+
|
|
956
|
+
// Same day: skipped entirely, so the stale file survives.
|
|
957
|
+
expect(pruneAbandonedSessions(h.env, REAL + 3600_000)).toBe(0)
|
|
958
|
+
expect(readSessionState('a', h.env).last_prompt_at).toBe(NOW)
|
|
959
|
+
// Next day: swept.
|
|
960
|
+
expect(pruneAbandonedSessions(h.env, REAL + 25 * 3600 * 1000)).toBe(1)
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('does not read a clock jump as a directory full of dead sessions', () => {
|
|
964
|
+
// A backward jump makes every file look like it came from the future.
|
|
965
|
+
// Deleting live state there would lose a question already on the phone.
|
|
966
|
+
const h = harness()
|
|
967
|
+
writeSessionState('jumped', h.env, { last_prompt_at: NOW })
|
|
968
|
+
|
|
969
|
+
expect(pruneAbandonedSessions(h.env, REAL - 400 * 24 * 3600 * 1000)).toBe(0)
|
|
970
|
+
expect(readSessionState('jumped', h.env).last_prompt_at).toBe(NOW)
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
it('never fails a hook because housekeeping could not run', () => {
|
|
974
|
+
expect(pruneAbandonedSessions({ XDG_STATE_HOME: '/proc/nonexistent/nope' }, REAL)).toBe(0)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it('bounds what one runaway question can write to disk', () => {
|
|
978
|
+
const h = harness()
|
|
979
|
+
registerQuestion('big', h.env, { question: 'x'.repeat(50_000) })
|
|
980
|
+
expect(readSessionState('big', h.env).pending?.question.length).toBeLessThanOrEqual(2000)
|
|
981
|
+
})
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Path-independent ownership stops the usual cause
|
|
986
|
+
* of two handlers firing; this stops the consequence when something else does.
|
|
987
|
+
*/
|
|
988
|
+
describe('two hooks racing one question', () => {
|
|
989
|
+
const REAL = Date.now()
|
|
990
|
+
|
|
991
|
+
it('lets exactly one process push', () => {
|
|
992
|
+
const h = harness()
|
|
993
|
+
expect(claimQuestionPush('race1', h.env, REAL)).toBe(true)
|
|
994
|
+
expect(claimQuestionPush('race1', h.env, REAL)).toBe(false)
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
it('frees the claim for the next turn', () => {
|
|
998
|
+
const h = harness()
|
|
999
|
+
claimQuestionPush('race2', h.env, REAL)
|
|
1000
|
+
releaseQuestionPush('race2', h.env)
|
|
1001
|
+
expect(claimQuestionPush('race2', h.env, REAL)).toBe(true)
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
it('breaks a claim whose holder cannot still be running', () => {
|
|
1005
|
+
// A crashed hook must not suppress every question for this session for
|
|
1006
|
+
// ever — that is worse than the duplicate the claim prevents.
|
|
1007
|
+
const h = harness()
|
|
1008
|
+
claimQuestionPush('race3', h.env, REAL - 10 * 60_000)
|
|
1009
|
+
expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
it('does not let two sessions block each other', () => {
|
|
1013
|
+
const h = harness()
|
|
1014
|
+
expect(claimQuestionPush('race4a', h.env, REAL)).toBe(true)
|
|
1015
|
+
expect(claimQuestionPush('race4b', h.env, REAL)).toBe(true)
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it('sends one notification when a second Stop arrives mid-flight', async () => {
|
|
1019
|
+
const h = harness([], 900)
|
|
1020
|
+
writeSessionState('race5', h.env, { last_prompt_at: AWAY })
|
|
1021
|
+
registerQuestion('race5', h.env, { question: 'Ship it?' })
|
|
1022
|
+
// Standing in for the other process: the claim is already held.
|
|
1023
|
+
claimQuestionPush('race5', h.env, Date.now())
|
|
1024
|
+
|
|
1025
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race5' }))
|
|
1026
|
+
|
|
1027
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
1028
|
+
expect(h.io.errLines.join(" ")).toContain('already handling')
|
|
1029
|
+
})
|
|
1030
|
+
})
|