@raidiant/notifai 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +48 -0
- package/src/harnesses.ts +59 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/hooks.test.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
1
2
|
import {
|
|
3
|
+
existsSync,
|
|
2
4
|
mkdirSync,
|
|
3
5
|
mkdtempSync,
|
|
4
6
|
readFileSync,
|
|
5
7
|
readdirSync,
|
|
6
8
|
realpathSync,
|
|
9
|
+
rmSync,
|
|
7
10
|
symlinkSync,
|
|
8
11
|
utimesSync,
|
|
9
12
|
writeFileSync,
|
|
10
13
|
} from 'node:fs'
|
|
11
14
|
import os from 'node:os'
|
|
12
15
|
import path from 'node:path'
|
|
16
|
+
import { PassThrough } from 'node:stream'
|
|
13
17
|
import type {
|
|
14
18
|
ListRepliesResponse,
|
|
15
19
|
ReplyView,
|
|
@@ -17,9 +21,23 @@ import type {
|
|
|
17
21
|
SubmitNotificationRequestT,
|
|
18
22
|
} from '@raidiant/notifai-protocol'
|
|
19
23
|
import { describe, expect, it } from 'vitest'
|
|
20
|
-
import type
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
24
|
+
import { ApiCallError, type ApiClient } from './client.js'
|
|
25
|
+
import { readStdinWithTimeout } from './hook-input.js'
|
|
26
|
+
import {
|
|
27
|
+
activeLogPath,
|
|
28
|
+
bootstrapLogger,
|
|
29
|
+
createLogger,
|
|
30
|
+
readLogRecords,
|
|
31
|
+
type LogRecord,
|
|
32
|
+
} from './logging.js'
|
|
33
|
+
import { EXIT, askCommand, buildQuestions, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
|
|
34
|
+
import {
|
|
35
|
+
loadConfig,
|
|
36
|
+
projectSessionPointerPath,
|
|
37
|
+
sanitizeSessionId,
|
|
38
|
+
sessionConfigPath,
|
|
39
|
+
stateDir,
|
|
40
|
+
} from './config.js'
|
|
23
41
|
import {
|
|
24
42
|
claimQuestionPush,
|
|
25
43
|
clearSessionState,
|
|
@@ -29,6 +47,7 @@ import {
|
|
|
29
47
|
orphanRetirements,
|
|
30
48
|
pruneAbandonedSessions,
|
|
31
49
|
releaseQuestionPush,
|
|
50
|
+
readMatchingProjectSessionPointer,
|
|
32
51
|
readProjectSession,
|
|
33
52
|
readSessionState,
|
|
34
53
|
registerQuestion,
|
|
@@ -55,15 +74,31 @@ interface Recorder {
|
|
|
55
74
|
submitted: SubmitNotificationRequestT[]
|
|
56
75
|
/** `submitted[i]`'s request id, so a test can name what it just sent. */
|
|
57
76
|
receipts: string[]
|
|
77
|
+
/** Stable test aliases survive the fresh client created for each hook. */
|
|
78
|
+
aliases: Map<string, number>
|
|
58
79
|
closed: string[]
|
|
59
80
|
/** Simulates an offline machine; retirement has to survive one. */
|
|
60
81
|
failSubmits?: boolean
|
|
61
82
|
/** Runs while an agent_question submit is in flight, before onSubmitted. */
|
|
62
83
|
beforeQuestionSubmit?: () => void
|
|
84
|
+
/** Lets race tests hold every replies call until all contenders are polling. */
|
|
85
|
+
beforeReplies?: () => Promise<void>
|
|
86
|
+
/** Per-request answer stream for multi-question ownership tests. */
|
|
87
|
+
repliesFor?: (requestId: string) => ReplyView[]
|
|
63
88
|
}
|
|
64
89
|
|
|
65
90
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
66
91
|
let submissions = 0
|
|
92
|
+
const recordedReplies = (requestId: string): ReplyView[] => {
|
|
93
|
+
const direct = recorder.repliesFor?.(requestId)
|
|
94
|
+
if (direct !== undefined && direct.length > 0) return direct
|
|
95
|
+
const ordinal = recorder.aliases.get(requestId)
|
|
96
|
+
if (ordinal !== undefined) {
|
|
97
|
+
const legacyAlias = recorder.repliesFor?.(`req_hook_${ordinal}`)
|
|
98
|
+
if (legacyAlias !== undefined) return legacyAlias
|
|
99
|
+
}
|
|
100
|
+
return direct ?? replies
|
|
101
|
+
}
|
|
67
102
|
return {
|
|
68
103
|
beginPairing: notUsed,
|
|
69
104
|
pollPairing: notUsed,
|
|
@@ -98,19 +133,33 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
98
133
|
if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
|
|
99
134
|
recorder.submitted.push(body)
|
|
100
135
|
submissions += 1
|
|
101
|
-
|
|
136
|
+
const requestId = body.request_id ?? `req_hook_${submissions}`
|
|
137
|
+
recorder.aliases.set(requestId, recorder.receipts.length + 1)
|
|
138
|
+
recorder.receipts.push(requestId)
|
|
102
139
|
return {
|
|
103
|
-
request_id:
|
|
140
|
+
request_id: requestId,
|
|
141
|
+
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
104
142
|
replayed: false,
|
|
105
143
|
overall: 'provider_accepted_all',
|
|
106
144
|
deliveries: [],
|
|
107
145
|
warnings: [],
|
|
108
146
|
} satisfies SubmissionReceipt
|
|
109
147
|
},
|
|
110
|
-
replies: async (requestId) =>
|
|
111
|
-
(
|
|
148
|
+
replies: async (requestId) => {
|
|
149
|
+
await recorder.beforeReplies?.()
|
|
150
|
+
return ({
|
|
151
|
+
request_id: requestId,
|
|
152
|
+
reply_expires_at: null,
|
|
153
|
+
replies: recordedReplies(requestId),
|
|
154
|
+
}) satisfies ListRepliesResponse
|
|
155
|
+
},
|
|
112
156
|
closeReplies: async (requestId) => {
|
|
113
157
|
recorder.closed.push(requestId)
|
|
158
|
+
return {
|
|
159
|
+
request_id: requestId,
|
|
160
|
+
reply_expires_at: new Date(NOW).toISOString(),
|
|
161
|
+
replies: recordedReplies(requestId),
|
|
162
|
+
} satisfies ListRepliesResponse
|
|
114
163
|
},
|
|
115
164
|
} as ApiClient
|
|
116
165
|
}
|
|
@@ -139,6 +188,7 @@ interface Harness {
|
|
|
139
188
|
io: CapturedIo
|
|
140
189
|
recorder: Recorder
|
|
141
190
|
env: NodeJS.ProcessEnv
|
|
191
|
+
advanceClock(milliseconds: number): void
|
|
142
192
|
}
|
|
143
193
|
|
|
144
194
|
const NOW = 1_800_000_000_000
|
|
@@ -155,7 +205,7 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
|
|
|
155
205
|
XDG_STATE_HOME: path.join(root, 'state'),
|
|
156
206
|
}
|
|
157
207
|
const io = new CapturedIo()
|
|
158
|
-
const recorder: Recorder = { submitted: [], receipts: [], closed: [] }
|
|
208
|
+
const recorder: Recorder = { submitted: [], receipts: [], closed: [], aliases: new Map() }
|
|
159
209
|
// Virtual clock: sleeps advance it instead of costing wall time. A frozen
|
|
160
210
|
// clock would make the reply poll's deadline unreachable and spin forever.
|
|
161
211
|
let clock = NOW
|
|
@@ -163,6 +213,9 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
|
|
|
163
213
|
io,
|
|
164
214
|
recorder,
|
|
165
215
|
env,
|
|
216
|
+
advanceClock(milliseconds: number) {
|
|
217
|
+
clock += milliseconds
|
|
218
|
+
},
|
|
166
219
|
deps: {
|
|
167
220
|
io,
|
|
168
221
|
store: {
|
|
@@ -192,6 +245,140 @@ function stdin(payload: unknown): () => Promise<string> {
|
|
|
192
245
|
return async () => JSON.stringify(payload)
|
|
193
246
|
}
|
|
194
247
|
|
|
248
|
+
async function waitUntil(
|
|
249
|
+
predicate: () => boolean,
|
|
250
|
+
timeoutMs: number,
|
|
251
|
+
failure: string,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const deadline = Date.now() + timeoutMs
|
|
254
|
+
while (!predicate()) {
|
|
255
|
+
if (Date.now() >= deadline) throw new Error(failure)
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface SessionEndLogLockHolder {
|
|
261
|
+
child: ChildProcess
|
|
262
|
+
readyPath: string
|
|
263
|
+
releasePath: string
|
|
264
|
+
done: Promise<void>
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function runSessionEndLogLockHolder(
|
|
268
|
+
root: string,
|
|
269
|
+
env: NodeJS.ProcessEnv,
|
|
270
|
+
lockPath: string,
|
|
271
|
+
): SessionEndLogLockHolder {
|
|
272
|
+
const readyPath = path.join(root, 'session-end-log-lock-ready')
|
|
273
|
+
const releasePath = path.join(root, 'session-end-log-lock-release')
|
|
274
|
+
const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
|
|
275
|
+
const child = spawn(
|
|
276
|
+
process.execPath,
|
|
277
|
+
[
|
|
278
|
+
vitest,
|
|
279
|
+
'run',
|
|
280
|
+
'src/session-end-log-lock-worker.test.ts',
|
|
281
|
+
'--reporter=dot',
|
|
282
|
+
'--maxWorkers=1',
|
|
283
|
+
],
|
|
284
|
+
{
|
|
285
|
+
cwd: process.cwd(),
|
|
286
|
+
env: {
|
|
287
|
+
...process.env,
|
|
288
|
+
...env,
|
|
289
|
+
NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE: 'hold',
|
|
290
|
+
NOTIFAI_SESSION_END_LOG_LOCK_PATH: lockPath,
|
|
291
|
+
NOTIFAI_SESSION_END_LOG_LOCK_READY: readyPath,
|
|
292
|
+
NOTIFAI_SESSION_END_LOG_LOCK_RELEASE: releasePath,
|
|
293
|
+
},
|
|
294
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
const stdout: Buffer[] = []
|
|
298
|
+
const stderr: Buffer[] = []
|
|
299
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
300
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
301
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
302
|
+
child.once('error', reject)
|
|
303
|
+
child.once('exit', (code) => {
|
|
304
|
+
if (code === 0) resolve()
|
|
305
|
+
else {
|
|
306
|
+
reject(
|
|
307
|
+
new Error(
|
|
308
|
+
`SessionEnd log lock worker exited ${String(code)}\n${Buffer.concat(stdout).toString()}\n${Buffer.concat(stderr).toString()}`,
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
void done.catch(() => undefined)
|
|
315
|
+
return { child, readyPath, releasePath, done }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface CliProcessResult {
|
|
319
|
+
code: number | null
|
|
320
|
+
signal: NodeJS.Signals | null
|
|
321
|
+
stdout: string
|
|
322
|
+
stderr: string
|
|
323
|
+
durationMs: number
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function runSessionEndCli(
|
|
327
|
+
env: NodeJS.ProcessEnv,
|
|
328
|
+
cwd: string,
|
|
329
|
+
sessionId: string,
|
|
330
|
+
): { child: ChildProcess; done: Promise<CliProcessResult> } {
|
|
331
|
+
const startedAt = Date.now()
|
|
332
|
+
const child = spawn(
|
|
333
|
+
process.execPath,
|
|
334
|
+
[
|
|
335
|
+
path.join(process.cwd(), 'dist', 'main.js'),
|
|
336
|
+
'hook',
|
|
337
|
+
'session-end',
|
|
338
|
+
'--owner',
|
|
339
|
+
'notifai',
|
|
340
|
+
'--harness',
|
|
341
|
+
'codex',
|
|
342
|
+
],
|
|
343
|
+
{
|
|
344
|
+
cwd,
|
|
345
|
+
env: { ...process.env, ...env, NOTIFAI_CREDENTIALS: 'file' },
|
|
346
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
const stdout: Buffer[] = []
|
|
350
|
+
const stderr: Buffer[] = []
|
|
351
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
352
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
353
|
+
child.stdin!.end(JSON.stringify({ session_id: sessionId, cwd }))
|
|
354
|
+
const done = new Promise<CliProcessResult>((resolve, reject) => {
|
|
355
|
+
child.once('error', reject)
|
|
356
|
+
child.once('exit', (code, signal) => {
|
|
357
|
+
resolve({
|
|
358
|
+
code,
|
|
359
|
+
signal,
|
|
360
|
+
stdout: Buffer.concat(stdout).toString(),
|
|
361
|
+
stderr: Buffer.concat(stderr).toString(),
|
|
362
|
+
durationMs: Date.now() - startedAt,
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
void done.catch(() => undefined)
|
|
367
|
+
return { child, done }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function writeGlobalConfig(h: Harness, toml: string): void {
|
|
371
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
372
|
+
mkdirSync(dir, { recursive: true })
|
|
373
|
+
writeFileSync(path.join(dir, 'config.toml'), toml)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function presenceGatedConfig(cwd: string, env: NodeJS.ProcessEnv) {
|
|
377
|
+
const config = loadConfig({ cwd, env })
|
|
378
|
+
config.require_idle = { value: true, source: 'global:test' }
|
|
379
|
+
return config
|
|
380
|
+
}
|
|
381
|
+
|
|
195
382
|
/** Long enough ago to be away under the 120s default. */
|
|
196
383
|
const AWAY = NOW - 600_000
|
|
197
384
|
const PRESENT = NOW - 5_000
|
|
@@ -199,13 +386,13 @@ const PRESENT = NOW - 5_000
|
|
|
199
386
|
describe('presence gate', () => {
|
|
200
387
|
it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
|
|
201
388
|
const { env, deps } = harness()
|
|
202
|
-
const config =
|
|
389
|
+
const config = presenceGatedConfig(deps.cwd, env)
|
|
203
390
|
expect(isUserAway({}, config, NOW, null)).toBe(false)
|
|
204
391
|
})
|
|
205
392
|
|
|
206
393
|
it('is away only once the configured silence has elapsed', () => {
|
|
207
394
|
const { env, deps } = harness()
|
|
208
|
-
const config =
|
|
395
|
+
const config = presenceGatedConfig(deps.cwd, env)
|
|
209
396
|
expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
|
|
210
397
|
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
|
|
211
398
|
})
|
|
@@ -214,7 +401,7 @@ describe('presence gate', () => {
|
|
|
214
401
|
// minutes of watching. Elapsed time alone said away; the machine knows better.
|
|
215
402
|
it('keeps a user who is watching a long turn present, however long the turn ran', () => {
|
|
216
403
|
const { env, deps } = harness()
|
|
217
|
-
const config =
|
|
404
|
+
const config = presenceGatedConfig(deps.cwd, env)
|
|
218
405
|
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
|
|
219
406
|
})
|
|
220
407
|
|
|
@@ -224,13 +411,14 @@ describe('presence gate', () => {
|
|
|
224
411
|
// away" case the feature is for.
|
|
225
412
|
it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
|
|
226
413
|
const { env, deps } = harness()
|
|
227
|
-
const config =
|
|
414
|
+
const config = presenceGatedConfig(deps.cwd, env)
|
|
228
415
|
expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
|
|
229
416
|
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
|
|
230
417
|
})
|
|
231
418
|
|
|
232
419
|
it('pushes a spawned session first question once the machine has gone quiet', async () => {
|
|
233
420
|
const h = harness([reply({ text: 'Yes' })], 900)
|
|
421
|
+
writeGlobalConfig(h, 'require_idle = true\n')
|
|
234
422
|
// Prompt 20s ago, exactly as a just-spawned agent has.
|
|
235
423
|
writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
|
|
236
424
|
registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
|
|
@@ -268,13 +456,14 @@ describe('presence gate', () => {
|
|
|
268
456
|
|
|
269
457
|
it('falls back to elapsed time where no idle source exists', () => {
|
|
270
458
|
const { env, deps } = harness()
|
|
271
|
-
const config =
|
|
459
|
+
const config = presenceGatedConfig(deps.cwd, env)
|
|
272
460
|
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
|
|
273
461
|
})
|
|
274
462
|
|
|
275
463
|
it('does not push a question to the phone while the user is at the keyboard', async () => {
|
|
276
464
|
// Silent for ten minutes by the session clock, but active by the machine's.
|
|
277
465
|
const h = harness([reply({ text: 'Yes' })], 2)
|
|
466
|
+
writeGlobalConfig(h, 'require_idle = true\n')
|
|
278
467
|
writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
|
|
279
468
|
registerQuestion('idle1', h.env, { question: 'Ship it?' })
|
|
280
469
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
|
|
@@ -283,6 +472,199 @@ describe('presence gate', () => {
|
|
|
283
472
|
})
|
|
284
473
|
})
|
|
285
474
|
|
|
475
|
+
/**
|
|
476
|
+
* A hook's decisions are invisible from everywhere else: its stderr belongs to
|
|
477
|
+
* the harness, and its usual outcome is to do nothing at all. These are the
|
|
478
|
+
* tests that the local log closes that gap — that "my question never reached my
|
|
479
|
+
* phone" has an answer rather than a silence.
|
|
480
|
+
*/
|
|
481
|
+
describe('what the hook leaves behind', () => {
|
|
482
|
+
function recording(h: Harness): CommandDeps {
|
|
483
|
+
return { ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function gates(h: Harness): LogRecord[] {
|
|
487
|
+
return readLogRecords(h.env, { event: ['hook.gate'], limit: 50 }).records
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
it('records why a question was held, in a name a filter can match', async () => {
|
|
491
|
+
const h = harness([], 2)
|
|
492
|
+
writeGlobalConfig(h, 'require_idle = true\n')
|
|
493
|
+
writeSessionState('held', h.env, { last_prompt_at: AWAY })
|
|
494
|
+
registerQuestion('held', h.env, { question: 'Ship it?' })
|
|
495
|
+
|
|
496
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'held', cwd: h.deps.cwd }))
|
|
497
|
+
|
|
498
|
+
const held = gates(h).find((record) => record.data?.['reason'] === 'user-present')
|
|
499
|
+
expect(held).toBeDefined()
|
|
500
|
+
expect(held!.data).toMatchObject({ verdict: 'held', idle_seconds: 2, away_after_seconds: 120 })
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('records the switch being off, which the user is deliberately never told', async () => {
|
|
504
|
+
// Silent to the user by design, so from outside "you turned it off" and "a
|
|
505
|
+
// bug ate my question" look identical. This is what tells them apart.
|
|
506
|
+
const h = harness([], 600)
|
|
507
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
508
|
+
mkdirSync(dir, { recursive: true })
|
|
509
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_notifications = false\n')
|
|
510
|
+
writeSessionState('off', h.env, { last_prompt_at: AWAY })
|
|
511
|
+
registerQuestion('off', h.env, { question: 'Ship it?' })
|
|
512
|
+
|
|
513
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'off', cwd: h.deps.cwd }))
|
|
514
|
+
|
|
515
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
516
|
+
// Nothing was printed, and the log is the only account of it.
|
|
517
|
+
expect(h.io.errLines.join('\n')).not.toContain('notifications')
|
|
518
|
+
expect(gates(h).map((record) => record.data?.['reason'])).toContain('notifications-off')
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('records a turn with no question at all, so "did the hook run" is answerable', async () => {
|
|
522
|
+
const h = harness()
|
|
523
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'quiet', cwd: h.deps.cwd }))
|
|
524
|
+
const events = readLogRecords(h.env, { limit: 50 }).records.map((record) => record.event)
|
|
525
|
+
expect(events).toContain('hook.start')
|
|
526
|
+
expect(events).toContain('hook.end')
|
|
527
|
+
expect(gates(h).map((record) => record.data?.['reason'])).toContain('no-question')
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('lets the hook project re-enable a bootstrap logger and records resolved config', async () => {
|
|
531
|
+
const h = harness()
|
|
532
|
+
writeGlobalConfig(h, 'log_level = "off"\n')
|
|
533
|
+
const startup = path.join(h.deps.cwd, 'startup')
|
|
534
|
+
const project = path.join(h.deps.cwd, 'project')
|
|
535
|
+
mkdirSync(startup, { recursive: true })
|
|
536
|
+
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
537
|
+
writeFileSync(
|
|
538
|
+
path.join(project, '.notifai', 'config.toml'),
|
|
539
|
+
'project = "hook-project"\nlog_level = "debug"\n',
|
|
540
|
+
)
|
|
541
|
+
const deps = { ...h.deps, cwd: startup, logger: bootstrapLogger({ env: h.env, cwd: startup }) }
|
|
542
|
+
|
|
543
|
+
await hookRunCommand(deps, 'stop', stdin({ session_id: 'reenabled', cwd: project }))
|
|
544
|
+
|
|
545
|
+
const records = readLogRecords(h.env, { limit: 50 }).records
|
|
546
|
+
expect(records.map((record) => record.event)).toEqual(
|
|
547
|
+
expect.arrayContaining(['config.resolved', 'hook.start', 'hook.end']),
|
|
548
|
+
)
|
|
549
|
+
expect(records.find((record) => record.event === 'config.resolved')).toMatchObject({
|
|
550
|
+
project: 'hook-project',
|
|
551
|
+
session: 'reenabled',
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('persists an answer once while still reporting it to the harness', async () => {
|
|
556
|
+
const h = harness([reply({ text: 'Allow exactly once' })], 900)
|
|
557
|
+
writeSessionState('answer-log', h.env, { last_prompt_at: AWAY })
|
|
558
|
+
registerQuestion('answer-log', h.env, { question: 'Ship it?' })
|
|
559
|
+
|
|
560
|
+
await hookRunCommand(
|
|
561
|
+
recording(h),
|
|
562
|
+
'stop',
|
|
563
|
+
stdin({ session_id: 'answer-log', cwd: h.deps.cwd }),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
567
|
+
expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
|
|
568
|
+
expect(readLogRecords(h.env, { event: ['hook.answer'] }).records).toHaveLength(1)
|
|
569
|
+
expect(h.io.errLines.join('\n')).toContain('Allow exactly once')
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('records the push and ties it to the request id the server knows', async () => {
|
|
573
|
+
const h = harness([], 600)
|
|
574
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
575
|
+
mkdirSync(dir, { recursive: true })
|
|
576
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
577
|
+
writeSessionState('pushed', h.env, { last_prompt_at: AWAY })
|
|
578
|
+
registerQuestion('pushed', h.env, { question: 'Ship it?' })
|
|
579
|
+
|
|
580
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'pushed', cwd: h.deps.cwd }))
|
|
581
|
+
|
|
582
|
+
const pushed = readLogRecords(h.env, { event: ['hook.pushed'] }).records
|
|
583
|
+
expect(pushed).toHaveLength(1)
|
|
584
|
+
expect(pushed[0]!.data).toMatchObject({ ok: true })
|
|
585
|
+
// The local record and the server's evidence trail share one id, which is
|
|
586
|
+
// what makes `notifai logs --request <id>` and `notifai status <id>` two
|
|
587
|
+
// views of one thing.
|
|
588
|
+
expect(pushed[0]!.data!['request_id']).toBe(h.recorder.receipts[0])
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('stores every request in a multi-question wait as a structured identity', async () => {
|
|
592
|
+
const h = harness([], 900)
|
|
593
|
+
writeSessionState('request-log', h.env, { last_prompt_at: AWAY })
|
|
594
|
+
registerQuestion('request-log', h.env, { question: 'Ship it?' }, NOW)
|
|
595
|
+
registerQuestion('request-log', h.env, { question: 'Deploy it?' }, NOW + 1)
|
|
596
|
+
|
|
597
|
+
await hookRunCommand(
|
|
598
|
+
recording(h),
|
|
599
|
+
'stop',
|
|
600
|
+
stdin({ session_id: 'request-log', cwd: h.deps.cwd }),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
const unanswered = readLogRecords(h.env, { event: ['hook.answer'] }).records.at(-1)
|
|
604
|
+
expect(unanswered?.data?.['request_ids']).toEqual(h.recorder.receipts)
|
|
605
|
+
expect(readSessionState('request-log', h.env).pending).toBeUndefined()
|
|
606
|
+
|
|
607
|
+
for (const requestId of h.recorder.receipts) {
|
|
608
|
+
const matching = readLogRecords(h.env, { request: requestId }).records
|
|
609
|
+
expect(matching).toContainEqual(unanswered)
|
|
610
|
+
}
|
|
611
|
+
expect(readLogRecords(h.env, { request: 'req_hook' }).records).not.toContainEqual(unanswered)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('records a config parse failure as a complete fail-open lifecycle', async () => {
|
|
615
|
+
const h = harness()
|
|
616
|
+
mkdirSync(path.join(h.deps.cwd, '.notifai'), { recursive: true })
|
|
617
|
+
writeFileSync(path.join(h.deps.cwd, '.notifai', 'config.toml'), 'not valid = [toml')
|
|
618
|
+
|
|
619
|
+
await hookRunCommand(
|
|
620
|
+
{ ...h.deps, logger: createLogger({ env: h.env }) },
|
|
621
|
+
'stop',
|
|
622
|
+
stdin({ session_id: 'bad-config', cwd: h.deps.cwd }),
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
626
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
627
|
+
expect(records.at(-1)?.data).toMatchObject({ outcome: 'failed', reason: 'config-failed' })
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('records a hook failure with the server own words, not just that it failed', async () => {
|
|
631
|
+
const h = harness()
|
|
632
|
+
const deps: CommandDeps = {
|
|
633
|
+
...recording(h),
|
|
634
|
+
clientFactory: () => {
|
|
635
|
+
throw new ApiCallError(422, 'validation_failed', 'draft rejected', null, [
|
|
636
|
+
{ path: '/draft/lifecycle' },
|
|
637
|
+
])
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
registerQuestion('broken', h.env, { question: 'Ship it?' })
|
|
641
|
+
writeSessionState('broken', h.env, { last_prompt_at: AWAY })
|
|
642
|
+
|
|
643
|
+
await hookRunCommand(deps, 'stop', stdin({ session_id: 'broken', cwd: h.deps.cwd }))
|
|
644
|
+
|
|
645
|
+
const failures = readLogRecords(h.env, { level: 'error' }).records
|
|
646
|
+
expect(failures.length).toBeGreaterThan(0)
|
|
647
|
+
expect(JSON.stringify(failures)).toContain('/draft/lifecycle')
|
|
648
|
+
expect(JSON.stringify(failures)).toContain('validation_failed')
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('never writes the machine credential, whatever the hook did', async () => {
|
|
652
|
+
const h = harness([], 600)
|
|
653
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
654
|
+
mkdirSync(dir, { recursive: true })
|
|
655
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
656
|
+
writeSessionState('secret', h.env, { last_prompt_at: AWAY })
|
|
657
|
+
registerQuestion('secret', h.env, { question: 'Ship it?' })
|
|
658
|
+
|
|
659
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'secret', cwd: h.deps.cwd }))
|
|
660
|
+
|
|
661
|
+
// The log is the artefact most likely to be pasted into a conversation.
|
|
662
|
+
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
663
|
+
expect(raw).not.toContain('test-secret')
|
|
664
|
+
expect(raw).not.toContain('nfm_mac_test')
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
|
|
286
668
|
/**
|
|
287
669
|
* Wanting notifications while still at the machine is a legitimate setting —
|
|
288
670
|
* nobody should have to stop using their computer in order to be reached.
|
|
@@ -290,31 +672,29 @@ describe('presence gate', () => {
|
|
|
290
672
|
* it off must not disturb the grace timer.
|
|
291
673
|
*/
|
|
292
674
|
describe('presence gating is optional (require_idle)', () => {
|
|
293
|
-
|
|
294
|
-
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
295
|
-
mkdirSync(dir, { recursive: true })
|
|
296
|
-
writeFileSync(path.join(dir, 'config.toml'), toml)
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
it('counts a user actively typing as reachable', () => {
|
|
675
|
+
it('consults active typing only when presence gating is explicitly enabled', () => {
|
|
300
676
|
// One second of idle: as present as it is possible to be.
|
|
301
|
-
const
|
|
302
|
-
expect(isUserAway({ last_prompt_at: NOW },
|
|
677
|
+
const ungated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
|
|
678
|
+
expect(isUserAway({ last_prompt_at: NOW }, ungated, NOW, 1)).toBe(true)
|
|
303
679
|
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
expect(isUserAway({ last_prompt_at: NOW },
|
|
680
|
+
const gated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
|
|
681
|
+
gated.require_idle = { value: true, source: 'global:test' }
|
|
682
|
+
expect(isUserAway({ last_prompt_at: NOW }, gated, NOW, 1)).toBe(false)
|
|
307
683
|
})
|
|
308
684
|
|
|
309
|
-
it('escalates
|
|
685
|
+
it('escalates immediately by default while the user is at the keyboard', async () => {
|
|
310
686
|
const h = harness([], 1)
|
|
311
|
-
|
|
687
|
+
let submittedAt: number | undefined
|
|
688
|
+
h.recorder.beforeQuestionSubmit = () => {
|
|
689
|
+
submittedAt = h.deps.now?.()
|
|
690
|
+
}
|
|
312
691
|
writeSessionState('present1', h.env, { last_prompt_at: NOW })
|
|
313
692
|
registerQuestion('present1', h.env, { question: 'Ship it?' })
|
|
314
693
|
|
|
315
694
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
|
|
316
695
|
|
|
317
|
-
expect(h.recorder.submitted).toHaveLength(1)
|
|
696
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
697
|
+
expect(submittedAt).toBe(NOW)
|
|
318
698
|
expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
|
|
319
699
|
})
|
|
320
700
|
|
|
@@ -328,7 +708,7 @@ describe('presence gating is optional (require_idle)', () => {
|
|
|
328
708
|
|
|
329
709
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
|
|
330
710
|
|
|
331
|
-
expect(h.recorder.submitted).toHaveLength(1)
|
|
711
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
332
712
|
// The clock only advances when the hook sleeps, so this is proof the wait
|
|
333
713
|
// actually happened rather than being skipped.
|
|
334
714
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
|
|
@@ -344,7 +724,7 @@ describe('presence gating is optional (require_idle)', () => {
|
|
|
344
724
|
|
|
345
725
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
346
726
|
|
|
347
|
-
expect(h.recorder.submitted).toHaveLength(1)
|
|
727
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
348
728
|
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
349
729
|
expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
|
|
350
730
|
})
|
|
@@ -373,10 +753,11 @@ describe('terminal-first grace window', () => {
|
|
|
373
753
|
// Idle 900s: the user is gone, so the wait runs to completion rather than
|
|
374
754
|
// being abandoned. Sleeps advance the virtual clock.
|
|
375
755
|
const h = harness([reply({ text: 'Yes' })], 900)
|
|
756
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
376
757
|
pending(h, 'g1', 0)
|
|
377
758
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
|
|
378
759
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
379
|
-
// Nothing was pushed before the 300s
|
|
760
|
+
// Nothing was pushed before the explicitly configured 300s had passed.
|
|
380
761
|
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
|
|
381
762
|
})
|
|
382
763
|
|
|
@@ -392,6 +773,7 @@ describe('terminal-first grace window', () => {
|
|
|
392
773
|
it('sends nothing if the user comes back to the keyboard during the window', async () => {
|
|
393
774
|
let idle = 900
|
|
394
775
|
const h = harness([reply({ text: 'Yes' })], 900)
|
|
776
|
+
writeGlobalConfig(h, 'require_idle = true\n')
|
|
395
777
|
// Machine goes active on the second poll: the user sat down.
|
|
396
778
|
h.deps.idleSeconds = () => {
|
|
397
779
|
const current = idle
|
|
@@ -408,6 +790,7 @@ describe('terminal-first grace window', () => {
|
|
|
408
790
|
// No idle source: waiting would block the prompt with no way to notice the
|
|
409
791
|
// user returning, so it asks immediately instead.
|
|
410
792
|
const h = harness([reply({ text: 'Yes' })], null)
|
|
793
|
+
writeGlobalConfig(h, 'require_idle = true\n')
|
|
411
794
|
pending(h, 'g4', 0)
|
|
412
795
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g4' }))
|
|
413
796
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
@@ -416,20 +799,73 @@ describe('terminal-first grace window', () => {
|
|
|
416
799
|
})
|
|
417
800
|
|
|
418
801
|
it('never lets the window crowd out the reply wait past the hook budget', async () => {
|
|
419
|
-
// Both
|
|
420
|
-
//
|
|
421
|
-
//
|
|
802
|
+
// Both preferences can be large, but the shared budget honors maximum
|
|
803
|
+
// grace while retaining a protocol-valid answer window and scheduling
|
|
804
|
+
// slack. Real timers do not wake on the exact requested millisecond.
|
|
422
805
|
const h = harness([reply({ text: 'Yes' })], 900)
|
|
806
|
+
const virtualSleep = h.deps.sleep!
|
|
807
|
+
h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
|
|
423
808
|
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
424
809
|
mkdirSync(dir, { recursive: true })
|
|
425
810
|
writeFileSync(
|
|
426
811
|
path.join(dir, 'config.toml'),
|
|
427
|
-
'ask_grace_seconds =
|
|
812
|
+
'ask_grace_seconds = 334\nhook_reply_timeout_seconds = 540\n',
|
|
428
813
|
)
|
|
429
814
|
pending(h, 'g5', 0)
|
|
430
815
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
|
|
431
816
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
432
|
-
expect(h.deps.now?.()).
|
|
817
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 334_000)
|
|
818
|
+
expect(h.deps.now?.()).toBeLessThan(NOW + 335_000)
|
|
819
|
+
expect(
|
|
820
|
+
h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
|
|
821
|
+
?.expires_in_seconds,
|
|
822
|
+
).toBeGreaterThanOrEqual(60)
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('reserves transport overrun and close time inside the whole Stop budget', async () => {
|
|
826
|
+
const h = harness([], 900)
|
|
827
|
+
const factory = h.deps.clientFactory
|
|
828
|
+
h.deps.clientFactory = () => {
|
|
829
|
+
const client = factory!()
|
|
830
|
+
return {
|
|
831
|
+
...client,
|
|
832
|
+
listDevices: async () => {
|
|
833
|
+
h.advanceClock(20_000)
|
|
834
|
+
return client.listDevices()
|
|
835
|
+
},
|
|
836
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
|
|
837
|
+
h.advanceClock(20_000)
|
|
838
|
+
return client.submit(body, waitSeconds)
|
|
839
|
+
},
|
|
840
|
+
replies: async (
|
|
841
|
+
requestId: string,
|
|
842
|
+
options: { waitSeconds: number; afterSeq: number },
|
|
843
|
+
) => {
|
|
844
|
+
// Server hold plus the client's 20s AbortSignal allowance.
|
|
845
|
+
h.advanceClock((options.waitSeconds + 20) * 1000)
|
|
846
|
+
return {
|
|
847
|
+
request_id: requestId,
|
|
848
|
+
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
849
|
+
replies: [],
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
closeReplies: async (requestId: string) => {
|
|
853
|
+
h.advanceClock(20_000)
|
|
854
|
+
return client.closeReplies(requestId)
|
|
855
|
+
},
|
|
856
|
+
} as ApiClient
|
|
857
|
+
}
|
|
858
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 250\nhook_reply_timeout_seconds = 540\n')
|
|
859
|
+
pending(h, 'transport-budget', 0)
|
|
860
|
+
|
|
861
|
+
await hookRunCommand(h.deps, 'stop', async () => {
|
|
862
|
+
// stdin/config/setup belong to the same invocation budget as polling.
|
|
863
|
+
h.advanceClock(30_000)
|
|
864
|
+
return JSON.stringify({ session_id: 'transport-budget' })
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
868
|
+
expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
|
|
433
869
|
})
|
|
434
870
|
})
|
|
435
871
|
|
|
@@ -465,7 +901,7 @@ describe('nagging guards', () => {
|
|
|
465
901
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
466
902
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
467
903
|
|
|
468
|
-
expect(h.recorder.submitted).toHaveLength(1)
|
|
904
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
469
905
|
})
|
|
470
906
|
|
|
471
907
|
it('respects the harness recursion guard', async () => {
|
|
@@ -516,21 +952,24 @@ describe('nagging guards', () => {
|
|
|
516
952
|
})
|
|
517
953
|
|
|
518
954
|
describe('late answer collection', () => {
|
|
519
|
-
it('collects
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
955
|
+
it('collects an answer that arrived during the resumed model turn', async () => {
|
|
956
|
+
const h = harness([reply({ text: 'Ship it' })], 900)
|
|
957
|
+
writeSessionState('late-stop', h.env, {
|
|
958
|
+
last_prompt_at: AWAY,
|
|
959
|
+
pending: [{
|
|
960
|
+
question: 'Ship it?',
|
|
961
|
+
asked_at: NOW,
|
|
962
|
+
request_id: 'req_live',
|
|
963
|
+
collapse_key: 'collapse-live',
|
|
964
|
+
device_ids: ['dev_iphone'],
|
|
965
|
+
reply_deadline_at: NOW + 60_000,
|
|
966
|
+
}],
|
|
967
|
+
})
|
|
529
968
|
|
|
530
969
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
531
970
|
|
|
532
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(
|
|
533
|
-
expect(h.recorder.closed).toContain('
|
|
971
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
|
|
972
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
534
973
|
expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
|
|
535
974
|
expect(h.io.outLines).toHaveLength(1)
|
|
536
975
|
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
@@ -540,13 +979,18 @@ describe('late answer collection', () => {
|
|
|
540
979
|
})
|
|
541
980
|
|
|
542
981
|
it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
982
|
+
const h = harness([reply({ text: 'Hold' })], 900)
|
|
983
|
+
writeSessionState('late-prompt', h.env, {
|
|
984
|
+
last_prompt_at: AWAY,
|
|
985
|
+
pending: [{
|
|
986
|
+
question: 'Ship it?',
|
|
987
|
+
asked_at: NOW,
|
|
988
|
+
request_id: 'req_live',
|
|
989
|
+
collapse_key: 'collapse-live',
|
|
990
|
+
device_ids: ['dev_iphone'],
|
|
991
|
+
reply_deadline_at: NOW + 60_000,
|
|
992
|
+
}],
|
|
993
|
+
})
|
|
550
994
|
|
|
551
995
|
await hookRunCommand(
|
|
552
996
|
h.deps,
|
|
@@ -554,49 +998,229 @@ describe('late answer collection', () => {
|
|
|
554
998
|
stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
|
|
555
999
|
)
|
|
556
1000
|
|
|
1001
|
+
expect(readSessionState('late-prompt', h.env).accepted?.answers[0]?.reply.text).toBe('Hold')
|
|
1002
|
+
expect(h.io.outLines).toEqual([])
|
|
1003
|
+
|
|
1004
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
|
|
1005
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
|
|
1006
|
+
await hookRunCommand(
|
|
1007
|
+
h.deps,
|
|
1008
|
+
'stop',
|
|
1009
|
+
stdin({ session_id: 'late-prompt', stop_hook_active: true }),
|
|
1010
|
+
)
|
|
1011
|
+
|
|
557
1012
|
const retirement = h.recorder.submitted.find(
|
|
558
1013
|
(entry) => entry.draft.event === 'question_retired',
|
|
559
1014
|
)?.draft
|
|
560
1015
|
expect(retirement?.lifecycle).toMatchObject({
|
|
561
1016
|
state: 'answered',
|
|
562
|
-
retires_request_id: '
|
|
1017
|
+
retires_request_id: 'req_live',
|
|
563
1018
|
})
|
|
564
1019
|
expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
|
|
565
1020
|
expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
|
|
566
|
-
expect(
|
|
567
|
-
hookSpecificOutput: {
|
|
568
|
-
hookEventName: 'UserPromptSubmit',
|
|
569
|
-
additionalContext: expect.stringContaining('Hold'),
|
|
570
|
-
},
|
|
571
|
-
})
|
|
1021
|
+
expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
|
|
572
1022
|
})
|
|
573
1023
|
})
|
|
574
1024
|
|
|
575
|
-
describe('OpenCode answer
|
|
576
|
-
it('
|
|
1025
|
+
describe('OpenCode answer continuation', () => {
|
|
1026
|
+
it('fails closed instead of accepting an answer the idle event cannot deliver', async () => {
|
|
577
1027
|
const h = harness([reply({ text: 'Approve' })], 900)
|
|
578
1028
|
writeSessionState('open1', h.env, { last_prompt_at: AWAY })
|
|
579
1029
|
registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
|
|
580
1030
|
|
|
581
1031
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
|
|
582
1032
|
|
|
583
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(
|
|
1033
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
|
|
584
1034
|
expect(h.recorder.closed).toEqual([])
|
|
585
1035
|
expect(
|
|
586
1036
|
h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired'),
|
|
587
|
-
).
|
|
588
|
-
expect(readSessionState('open1', h.env).pending
|
|
589
|
-
question: 'Deploy?',
|
|
590
|
-
request_id: 'req_hook_1',
|
|
591
|
-
device_ids: ['dev_iphone', 'dev_mac'],
|
|
592
|
-
})
|
|
1037
|
+
).toHaveLength(0)
|
|
1038
|
+
expect(readSessionState('open1', h.env).pending).toBeUndefined()
|
|
593
1039
|
expect(h.io.outLines).toEqual([])
|
|
1040
|
+
expect(h.io.errLines.join('\n')).toContain('no proven answer continuation')
|
|
594
1041
|
})
|
|
595
1042
|
})
|
|
596
1043
|
|
|
597
1044
|
describe('reply-wait presence monitoring', () => {
|
|
1045
|
+
it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
|
|
1046
|
+
const h = harness([], 900)
|
|
1047
|
+
const factory = h.deps.clientFactory
|
|
1048
|
+
h.deps.clientFactory = () => {
|
|
1049
|
+
const client = factory!()
|
|
1050
|
+
return {
|
|
1051
|
+
...client,
|
|
1052
|
+
replies: async (requestId: string) => ({
|
|
1053
|
+
request_id: requestId,
|
|
1054
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1055
|
+
replies: [],
|
|
1056
|
+
}),
|
|
1057
|
+
closeReplies: async (requestId: string) => {
|
|
1058
|
+
h.recorder.closed.push(requestId)
|
|
1059
|
+
return {
|
|
1060
|
+
request_id: requestId,
|
|
1061
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1062
|
+
replies: [reply({ text: 'Committed at the fence' })],
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
1065
|
+
} as ApiClient
|
|
1066
|
+
}
|
|
1067
|
+
writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
|
|
1068
|
+
writeSessionState('fenced-reply', h.env, { last_prompt_at: AWAY })
|
|
1069
|
+
registerQuestion('fenced-reply', h.env, { question: 'Deploy?' }, NOW)
|
|
1070
|
+
|
|
1071
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fenced-reply' }))
|
|
1072
|
+
|
|
1073
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain(
|
|
1074
|
+
'Committed at the fence',
|
|
1075
|
+
)
|
|
1076
|
+
expect(readSessionState('fenced-reply', h.env).accepted).toBeDefined()
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
it('preserves a silent question when the close fence cannot be proven', async () => {
|
|
1080
|
+
const h = harness([], 900)
|
|
1081
|
+
const factory = h.deps.clientFactory
|
|
1082
|
+
h.deps.clientFactory = () => {
|
|
1083
|
+
const client = factory!()
|
|
1084
|
+
return {
|
|
1085
|
+
...client,
|
|
1086
|
+
closeReplies: async () => {
|
|
1087
|
+
throw new Error('partitioned during close')
|
|
1088
|
+
},
|
|
1089
|
+
} as ApiClient
|
|
1090
|
+
}
|
|
1091
|
+
writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
|
|
1092
|
+
writeSessionState('unproven-close', h.env, { last_prompt_at: AWAY })
|
|
1093
|
+
registerQuestion('unproven-close', h.env, { question: 'Deploy?' }, NOW)
|
|
1094
|
+
|
|
1095
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'unproven-close' }))
|
|
1096
|
+
|
|
1097
|
+
const state = readSessionState('unproven-close', h.env)
|
|
1098
|
+
expect(state.pending?.[0]?.request_id).toBe(h.recorder.receipts[0])
|
|
1099
|
+
expect(state.retiring).toBeUndefined()
|
|
1100
|
+
expect(h.io.errLines.join('\n')).toContain('preserving ownership')
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
it('recovers an accepted question after its submit response is lost', async () => {
|
|
1104
|
+
const h = harness([], 900)
|
|
1105
|
+
const factory = h.deps.clientFactory
|
|
1106
|
+
let acceptedRequestId: string | undefined
|
|
1107
|
+
h.deps.clientFactory = () => {
|
|
1108
|
+
const client = factory!()
|
|
1109
|
+
return {
|
|
1110
|
+
...client,
|
|
1111
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1112
|
+
if (body.draft.event !== 'agent_question') return client.submit(body, 0)
|
|
1113
|
+
acceptedRequestId = body.request_id
|
|
1114
|
+
h.recorder.submitted.push(body)
|
|
1115
|
+
h.recorder.receipts.push(body.request_id!)
|
|
1116
|
+
h.recorder.aliases.set(body.request_id!, 1)
|
|
1117
|
+
throw new Error('response lost after commit')
|
|
1118
|
+
},
|
|
1119
|
+
replies: async (requestId: string) => ({
|
|
1120
|
+
request_id: requestId,
|
|
1121
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1122
|
+
replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
|
|
1123
|
+
}),
|
|
1124
|
+
closeReplies: async (requestId: string) => ({
|
|
1125
|
+
request_id: requestId,
|
|
1126
|
+
reply_expires_at: new Date(NOW).toISOString(),
|
|
1127
|
+
replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
|
|
1128
|
+
}),
|
|
1129
|
+
} as ApiClient
|
|
1130
|
+
}
|
|
1131
|
+
writeSessionState('ambiguous-submit', h.env, { last_prompt_at: AWAY })
|
|
1132
|
+
registerQuestion('ambiguous-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1133
|
+
|
|
1134
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ambiguous-submit' }))
|
|
1135
|
+
|
|
1136
|
+
expect(acceptedRequestId).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1137
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Yes, recover it')
|
|
1138
|
+
expect(readSessionState('ambiguous-submit', h.env).accepted?.answers[0]?.pending.request_id)
|
|
1139
|
+
.toBe(acceptedRequestId)
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('replays the frozen intent when an ambiguous submit never reached the server', async () => {
|
|
1143
|
+
const h = harness([], 900)
|
|
1144
|
+
const factory = h.deps.clientFactory
|
|
1145
|
+
let firstAttempt: SubmitNotificationRequestT | undefined
|
|
1146
|
+
h.deps.clientFactory = () => {
|
|
1147
|
+
const client = factory!()
|
|
1148
|
+
return {
|
|
1149
|
+
...client,
|
|
1150
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1151
|
+
firstAttempt = body
|
|
1152
|
+
throw new Error('connection failed before commit')
|
|
1153
|
+
},
|
|
1154
|
+
replies: async () => {
|
|
1155
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1156
|
+
},
|
|
1157
|
+
closeReplies: async () => {
|
|
1158
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1159
|
+
},
|
|
1160
|
+
} as ApiClient
|
|
1161
|
+
}
|
|
1162
|
+
writeSessionState('precommit-loss', h.env, { last_prompt_at: AWAY })
|
|
1163
|
+
registerQuestion('precommit-loss', h.env, { question: 'Deploy?' }, NOW)
|
|
1164
|
+
|
|
1165
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
|
|
1166
|
+
|
|
1167
|
+
const stranded = readSessionState('precommit-loss', h.env).pending?.[0]
|
|
1168
|
+
expect(stranded?.request_id).toBeUndefined()
|
|
1169
|
+
expect(stranded?.submission).toBeDefined()
|
|
1170
|
+
expect(h.io.errLines.join('\n')).toContain('response was ambiguous')
|
|
1171
|
+
|
|
1172
|
+
h.advanceClock(600_000)
|
|
1173
|
+
h.deps.clientFactory = factory
|
|
1174
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
|
|
1175
|
+
|
|
1176
|
+
expect(h.recorder.submitted[0]).toEqual(firstAttempt)
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
|
|
1180
|
+
const h = harness([], 900)
|
|
1181
|
+
const factory = h.deps.clientFactory
|
|
1182
|
+
let polls = 0
|
|
1183
|
+
h.deps.clientFactory = () => {
|
|
1184
|
+
const client = factory!()
|
|
1185
|
+
return {
|
|
1186
|
+
...client,
|
|
1187
|
+
submit: async () => {
|
|
1188
|
+
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1189
|
+
},
|
|
1190
|
+
replies: async () => {
|
|
1191
|
+
polls += 1
|
|
1192
|
+
throw new Error('must not poll an unaccepted request')
|
|
1193
|
+
},
|
|
1194
|
+
} as ApiClient
|
|
1195
|
+
}
|
|
1196
|
+
writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
|
|
1197
|
+
registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1198
|
+
|
|
1199
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1200
|
+
|
|
1201
|
+
const pending = readSessionState('rejected-submit', h.env).pending?.[0]
|
|
1202
|
+
expect(pending?.request_id).toBeUndefined()
|
|
1203
|
+
expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1204
|
+
expect(polls).toBe(0)
|
|
1205
|
+
expect(h.io.errLines.join('\n')).toContain('submission was rejected')
|
|
1206
|
+
|
|
1207
|
+
const frozen = pending?.submission
|
|
1208
|
+
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1209
|
+
writeGlobalConfig(h, 'project = "changed-after-crash"\n')
|
|
1210
|
+
h.advanceClock(600_000)
|
|
1211
|
+
h.deps.clientFactory = factory
|
|
1212
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1213
|
+
|
|
1214
|
+
expect(h.recorder.submitted[0]).toEqual({
|
|
1215
|
+
request_id: frozen.request_id,
|
|
1216
|
+
idempotency_key: frozen.idempotency_key,
|
|
1217
|
+
draft: frozen.draft,
|
|
1218
|
+
})
|
|
1219
|
+
})
|
|
1220
|
+
|
|
598
1221
|
it('returns the terminal when the user comes back after the push', async () => {
|
|
599
1222
|
const h = harness([], 900)
|
|
1223
|
+
writeGlobalConfig(h, 'require_idle = true\n')
|
|
600
1224
|
let checks = 0
|
|
601
1225
|
h.deps.idleSeconds = () => {
|
|
602
1226
|
checks += 1
|
|
@@ -605,19 +1229,92 @@ describe('reply-wait presence monitoring', () => {
|
|
|
605
1229
|
writeSessionState('returned', h.env, { last_prompt_at: AWAY })
|
|
606
1230
|
registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
|
|
607
1231
|
|
|
608
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
|
|
1232
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
|
|
1233
|
+
|
|
1234
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
1235
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1236
|
+
expect(readSessionState('returned', h.env).pending).toBeUndefined()
|
|
1237
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
|
|
1238
|
+
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
1239
|
+
expect(h.io.errLines.join('\n')).toContain('retired before returning the terminal')
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
it('closes and retires silence at the absolute owner deadline', async () => {
|
|
1243
|
+
const h = harness([], 900)
|
|
1244
|
+
const factory = h.deps.clientFactory
|
|
1245
|
+
h.deps.clientFactory = () => {
|
|
1246
|
+
const client = factory!()
|
|
1247
|
+
return {
|
|
1248
|
+
...client,
|
|
1249
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => ({
|
|
1250
|
+
...(await client.submit(body, waitSeconds)),
|
|
1251
|
+
// The real server commits the configured reply window, rather than
|
|
1252
|
+
// the generic recorder's deliberately broad 480s fixture.
|
|
1253
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1254
|
+
}),
|
|
1255
|
+
} as ApiClient
|
|
1256
|
+
}
|
|
1257
|
+
writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
|
|
1258
|
+
writeSessionState('deadline', h.env, { last_prompt_at: AWAY })
|
|
1259
|
+
registerQuestion('deadline', h.env, { question: 'Deploy?' }, NOW)
|
|
1260
|
+
|
|
1261
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
|
|
1262
|
+
|
|
1263
|
+
expect(h.deps.now?.()).toBe(NOW + 60_000)
|
|
1264
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1265
|
+
expect(readSessionState('deadline', h.env).pending).toBeUndefined()
|
|
1266
|
+
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it('charges sequential submission latency to one deadline', async () => {
|
|
1270
|
+
const h = harness([reply({ text: 'Done' })], 900)
|
|
1271
|
+
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
1272
|
+
registerQuestion('latency', h.env, { question: 'First?' }, NOW)
|
|
1273
|
+
registerQuestion('latency', h.env, { question: 'Second?' }, NOW)
|
|
1274
|
+
h.recorder.beforeQuestionSubmit = () => h.advanceClock(40_000)
|
|
1275
|
+
|
|
1276
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
|
|
1277
|
+
|
|
1278
|
+
const windows = h.recorder.submitted
|
|
1279
|
+
.filter((entry) => entry.draft.event === 'agent_question')
|
|
1280
|
+
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1281
|
+
expect(windows).toEqual([395, 355])
|
|
1282
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 435_000)
|
|
1283
|
+
})
|
|
1284
|
+
|
|
1285
|
+
it('re-arms the next Stop for an independently pending answer', async () => {
|
|
1286
|
+
const h = harness([], 900)
|
|
1287
|
+
const answers = new Map<string, ReplyView[]>([
|
|
1288
|
+
['req_hook_1', [reply({ text: 'First answer' })]],
|
|
1289
|
+
['req_hook_2', []],
|
|
1290
|
+
])
|
|
1291
|
+
h.recorder.repliesFor = (requestId) => answers.get(requestId) ?? []
|
|
1292
|
+
writeSessionState('successor', h.env, { last_prompt_at: AWAY })
|
|
1293
|
+
registerQuestion('successor', h.env, { question: 'First?' }, NOW)
|
|
1294
|
+
registerQuestion('successor', h.env, { question: 'Second?' }, NOW)
|
|
1295
|
+
|
|
1296
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'successor' }))
|
|
1297
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('First answer')
|
|
1298
|
+
expect(readSessionState('successor', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
1299
|
+
'Second?',
|
|
1300
|
+
])
|
|
609
1301
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1302
|
+
answers.set('req_hook_2', [reply({ text: 'Second answer' })])
|
|
1303
|
+
h.io.outLines = []
|
|
1304
|
+
await hookRunCommand(
|
|
1305
|
+
h.deps,
|
|
1306
|
+
'stop',
|
|
1307
|
+
stdin({ session_id: 'successor', stop_hook_active: true }),
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
|
|
1311
|
+
expect(readSessionState('successor', h.env).pending).toBeUndefined()
|
|
1312
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(2)
|
|
616
1313
|
})
|
|
617
1314
|
})
|
|
618
1315
|
|
|
619
1316
|
describe('Cursor stop output', () => {
|
|
620
|
-
it('
|
|
1317
|
+
it('fails closed when the invoking shell cannot name the exact conversation', async () => {
|
|
621
1318
|
const h = harness([reply({ text: 'Ship it' })], 900)
|
|
622
1319
|
writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
|
|
623
1320
|
registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
|
|
@@ -633,12 +1330,9 @@ describe('Cursor stop output', () => {
|
|
|
633
1330
|
'cursor',
|
|
634
1331
|
)
|
|
635
1332
|
|
|
636
|
-
expect(h.
|
|
637
|
-
|
|
638
|
-
expect(
|
|
639
|
-
'Notifai — the user answered from Furankuphone: "Ship it". Continue with that answer.',
|
|
640
|
-
)
|
|
641
|
-
expect(output).not.toHaveProperty('decision')
|
|
1333
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
1334
|
+
expect(h.io.outLines).toHaveLength(0)
|
|
1335
|
+
expect(h.io.errLines.join('\n')).toContain('asynchronous ask is unsupported')
|
|
642
1336
|
})
|
|
643
1337
|
})
|
|
644
1338
|
|
|
@@ -661,36 +1355,40 @@ describe('question queueing and retirement debt', () => {
|
|
|
661
1355
|
}))
|
|
662
1356
|
}
|
|
663
1357
|
|
|
664
|
-
|
|
1358
|
+
function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
|
|
1359
|
+
writeSessionState(sessionId, h.env, {
|
|
1360
|
+
last_prompt_at: AWAY,
|
|
1361
|
+
pending: [{
|
|
1362
|
+
question: 'Ship it?',
|
|
1363
|
+
asked_at: NOW,
|
|
1364
|
+
request_id: requestId,
|
|
1365
|
+
collapse_key: 'collapse-live',
|
|
1366
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1367
|
+
reply_deadline_at: NOW + 60_000,
|
|
1368
|
+
}],
|
|
1369
|
+
})
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
it('registers a second question without mutating the live first question', () => {
|
|
665
1373
|
const h = harness([])
|
|
666
|
-
|
|
667
|
-
registerQuestion('sup1', h.env, { question: 'Ship it?' })
|
|
668
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
669
|
-
const first = h.recorder.receipts[0]
|
|
670
|
-
expect(first).toBeDefined()
|
|
1374
|
+
seedLive(h, 'sup1')
|
|
671
1375
|
|
|
672
1376
|
// The agent carried on and asked something else. The first question is
|
|
673
1377
|
// still the user's to answer — a second question never ends the first;
|
|
674
1378
|
// superseding is reply semantics, not question semantics.
|
|
675
1379
|
registerQuestion('sup1', h.env, { question: 'Deploy it?' })
|
|
676
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
677
1380
|
|
|
678
1381
|
expect(retirements(h)).toEqual([])
|
|
679
|
-
expect(
|
|
680
|
-
h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
|
|
681
|
-
).toHaveLength(1)
|
|
682
1382
|
const live = readSessionState('sup1', h.env).pending
|
|
683
1383
|
expect(live).toHaveLength(2)
|
|
684
1384
|
expect(live?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
|
|
685
|
-
expect(live?.
|
|
1385
|
+
expect(live?.map((entry) => entry.request_id)).toEqual(['req_live', undefined])
|
|
686
1386
|
})
|
|
687
1387
|
|
|
688
1388
|
it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
|
|
689
1389
|
const h = harness([])
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
693
|
-
const first = h.recorder.receipts[0]
|
|
1390
|
+
seedLive(h, 'sup2')
|
|
1391
|
+
const first = 'req_live'
|
|
694
1392
|
|
|
695
1393
|
// Offline when the user returns: the wipe parks the retirement, the
|
|
696
1394
|
// drain fails, and it must not forget what it was for.
|
|
@@ -706,13 +1404,11 @@ describe('question queueing and retirement debt', () => {
|
|
|
706
1404
|
|
|
707
1405
|
it('keeps the original delivery targets when routing changes before retirement', async () => {
|
|
708
1406
|
const h = harness([])
|
|
709
|
-
|
|
710
|
-
registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
|
|
711
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
1407
|
+
seedLive(h, 'sup-targets')
|
|
712
1408
|
|
|
713
1409
|
const live = readSessionState('sup-targets', h.env).pending?.[0]
|
|
714
1410
|
expect(live).toMatchObject({
|
|
715
|
-
request_id: '
|
|
1411
|
+
request_id: 'req_live',
|
|
716
1412
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
717
1413
|
})
|
|
718
1414
|
|
|
@@ -727,7 +1423,7 @@ describe('question queueing and retirement debt', () => {
|
|
|
727
1423
|
const retirement = h.recorder.submitted.find(
|
|
728
1424
|
(submission) => submission.draft.event === 'question_retired',
|
|
729
1425
|
)
|
|
730
|
-
expect(retirement?.draft.lifecycle?.retires_request_id).toBe('
|
|
1426
|
+
expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
|
|
731
1427
|
expect(retirement?.draft.targets).toEqual({
|
|
732
1428
|
mode: 'selected',
|
|
733
1429
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
@@ -739,10 +1435,8 @@ describe('question queueing and retirement debt', () => {
|
|
|
739
1435
|
// before every guard — retirement debt has nothing to do with whether
|
|
740
1436
|
// this turn may ask.
|
|
741
1437
|
const h = harness([])
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
|
|
745
|
-
const first = h.recorder.receipts[0]
|
|
1438
|
+
seedLive(h, 'sup3')
|
|
1439
|
+
const first = 'req_live'
|
|
746
1440
|
|
|
747
1441
|
h.recorder.failSubmits = true
|
|
748
1442
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
|
|
@@ -759,10 +1453,8 @@ describe('question queueing and retirement debt', () => {
|
|
|
759
1453
|
// UserPromptSubmit resets session state to record presence, and that reset
|
|
760
1454
|
// used to take the retirement queue with it.
|
|
761
1455
|
const h = harness([])
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
|
|
765
|
-
const first = h.recorder.receipts[0]
|
|
1456
|
+
seedLive(h, 'sup4')
|
|
1457
|
+
const first = 'req_live'
|
|
766
1458
|
|
|
767
1459
|
// The first return is offline: the wipe parks, the drain fails, and the
|
|
768
1460
|
// next prompt must still find the debt.
|
|
@@ -795,18 +1487,7 @@ describe('question queueing and retirement debt', () => {
|
|
|
795
1487
|
describe('several questions in flight', () => {
|
|
796
1488
|
/** Route each live request its own replies; the shared list cannot say who answered what. */
|
|
797
1489
|
function repliesByRequest(h: Harness, byRequest: Map<string, ReplyView[]>): void {
|
|
798
|
-
|
|
799
|
-
h.deps.clientFactory = () => {
|
|
800
|
-
const client = factory!()
|
|
801
|
-
return {
|
|
802
|
-
...client,
|
|
803
|
-
replies: async (requestId: string) => ({
|
|
804
|
-
request_id: requestId,
|
|
805
|
-
reply_expires_at: null,
|
|
806
|
-
replies: byRequest.get(requestId) ?? [],
|
|
807
|
-
}),
|
|
808
|
-
} as ApiClient
|
|
809
|
-
}
|
|
1490
|
+
h.recorder.repliesFor = (requestId) => byRequest.get(requestId) ?? []
|
|
810
1491
|
}
|
|
811
1492
|
|
|
812
1493
|
it('escalates every registered question in one pass, each as its own notification', async () => {
|
|
@@ -822,8 +1503,155 @@ describe('several questions in flight', () => {
|
|
|
822
1503
|
// Each is its own notification with its own collapse key — one ask never
|
|
823
1504
|
// stands in for, or replaces, another.
|
|
824
1505
|
expect(new Set(questions.map((s) => s.draft.delivery.collapse_key)).size).toBe(2)
|
|
825
|
-
|
|
826
|
-
expect(
|
|
1506
|
+
expect(readSessionState('multi1', h.env).pending).toBeUndefined()
|
|
1507
|
+
expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* A registered form/ask must become a durable server request at turn-end.
|
|
1512
|
+
* Codex and Claude both enter through the same Stop
|
|
1513
|
+
* handler; the harness flag only changes answer injection, not settlement.
|
|
1514
|
+
*/
|
|
1515
|
+
it('settles a registered form into a durable request_id on the Codex Stop path', async () => {
|
|
1516
|
+
const h = harness([], 900)
|
|
1517
|
+
writeSessionState('codex-form', h.env, { last_prompt_at: AWAY })
|
|
1518
|
+
const built = buildQuestions(
|
|
1519
|
+
{ form: JSON.stringify({
|
|
1520
|
+
questions: [
|
|
1521
|
+
{ text: 'Start personally?', choices: ['Yes', 'No'] },
|
|
1522
|
+
{ text: 'Which region?', choices: ['US', 'EU'] },
|
|
1523
|
+
],
|
|
1524
|
+
}) },
|
|
1525
|
+
undefined,
|
|
1526
|
+
)
|
|
1527
|
+
expect(built.ok).toBe(true)
|
|
1528
|
+
if (!built.ok) return
|
|
1529
|
+
registerQuestion('codex-form', h.env, {
|
|
1530
|
+
question: built.questions[0]!.text,
|
|
1531
|
+
questions: built.questions,
|
|
1532
|
+
})
|
|
1533
|
+
|
|
1534
|
+
// Registration alone must not submit; durability happens at turn-end.
|
|
1535
|
+
expect(h.recorder.submitted).toEqual([])
|
|
1536
|
+
expect(readSessionState('codex-form', h.env).pending?.[0]?.request_id).toBeUndefined()
|
|
1537
|
+
|
|
1538
|
+
await hookRunCommand(
|
|
1539
|
+
h.deps,
|
|
1540
|
+
'stop',
|
|
1541
|
+
stdin({ session_id: 'codex-form' }),
|
|
1542
|
+
'codex',
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
|
|
1546
|
+
expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
|
|
1547
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1548
|
+
expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
it('keeps a settled ask durable and collects the answer after a transient internal_error', async () => {
|
|
1552
|
+
const h = harness([], 900)
|
|
1553
|
+
let polls = 0
|
|
1554
|
+
const factory = h.deps.clientFactory
|
|
1555
|
+
h.deps.clientFactory = () => {
|
|
1556
|
+
const client = factory!()
|
|
1557
|
+
return {
|
|
1558
|
+
...client,
|
|
1559
|
+
replies: async (requestId: string) => {
|
|
1560
|
+
polls += 1
|
|
1561
|
+
if (polls <= 2) {
|
|
1562
|
+
throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
request_id: requestId,
|
|
1566
|
+
reply_expires_at: null,
|
|
1567
|
+
replies: [reply({ text: 'Yes — start personally' })],
|
|
1568
|
+
} satisfies ListRepliesResponse
|
|
1569
|
+
},
|
|
1570
|
+
} as ApiClient
|
|
1571
|
+
}
|
|
1572
|
+
writeSessionState('recover-500', h.env, { last_prompt_at: AWAY })
|
|
1573
|
+
registerQuestion('recover-500', h.env, { question: 'Start personally?' }, NOW - 300_000)
|
|
1574
|
+
|
|
1575
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'recover-500' }), 'codex')
|
|
1576
|
+
|
|
1577
|
+
// Submit recorded the durable id before any wait; a 500 must not erase it
|
|
1578
|
+
// mid-flight, and recovery must still surface the late answer.
|
|
1579
|
+
const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
|
|
1580
|
+
expect(questions).toHaveLength(1)
|
|
1581
|
+
expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1582
|
+
expect(polls).toBeGreaterThanOrEqual(3)
|
|
1583
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1584
|
+
expect(readSessionState('recover-500', h.env).pending).toBeUndefined()
|
|
1585
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
1586
|
+
expect(output.decision).toBe('block')
|
|
1587
|
+
expect(output.reason).toContain('Yes — start personally')
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
it('stops retrying and names a permanent rejection during the blocking multi-wait', async () => {
|
|
1591
|
+
const h = harness([], 900)
|
|
1592
|
+
let polls = 0
|
|
1593
|
+
const factory = h.deps.clientFactory
|
|
1594
|
+
h.deps.clientFactory = () => {
|
|
1595
|
+
const client = factory!()
|
|
1596
|
+
return {
|
|
1597
|
+
...client,
|
|
1598
|
+
replies: async () => {
|
|
1599
|
+
polls += 1
|
|
1600
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1601
|
+
},
|
|
1602
|
+
} as ApiClient
|
|
1603
|
+
}
|
|
1604
|
+
writeSessionState('permanent-wait', h.env, { last_prompt_at: AWAY })
|
|
1605
|
+
registerQuestion('permanent-wait', h.env, { question: 'Still there?' }, NOW - 300_000)
|
|
1606
|
+
|
|
1607
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-wait' }), 'codex')
|
|
1608
|
+
|
|
1609
|
+
expect(polls).toBe(1)
|
|
1610
|
+
expect(readSessionState('permanent-wait', h.env).pending).toBeUndefined()
|
|
1611
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
1612
|
+
`${h.recorder.receipts[0]}: not_found (HTTP 404): No such request.`,
|
|
1613
|
+
)
|
|
1614
|
+
expect(h.io.errLines.join('\n')).not.toContain('could not reach the server')
|
|
1615
|
+
})
|
|
1616
|
+
|
|
1617
|
+
it('distinguishes a permanent late-poll rejection from transient trouble', async () => {
|
|
1618
|
+
const h = harness([], 900)
|
|
1619
|
+
let polls = 0
|
|
1620
|
+
const factory = h.deps.clientFactory
|
|
1621
|
+
h.deps.clientFactory = () => {
|
|
1622
|
+
const client = factory!()
|
|
1623
|
+
return {
|
|
1624
|
+
...client,
|
|
1625
|
+
replies: async () => {
|
|
1626
|
+
polls += 1
|
|
1627
|
+
throw new ApiCallError(401, 'machine_revoked', 'This machine was revoked.')
|
|
1628
|
+
},
|
|
1629
|
+
} as ApiClient
|
|
1630
|
+
}
|
|
1631
|
+
writeSessionState('permanent-late', h.env, {
|
|
1632
|
+
last_prompt_at: AWAY,
|
|
1633
|
+
pending: [
|
|
1634
|
+
{
|
|
1635
|
+
question: 'Deploy?',
|
|
1636
|
+
asked_at: NOW - 300_000,
|
|
1637
|
+
request_id: 'req_existing',
|
|
1638
|
+
collapse_key: 'question-existing',
|
|
1639
|
+
device_ids: ['dev_iphone'],
|
|
1640
|
+
reply_deadline_at: NOW + 60_000,
|
|
1641
|
+
},
|
|
1642
|
+
],
|
|
1643
|
+
})
|
|
1644
|
+
|
|
1645
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-late' }), 'codex')
|
|
1646
|
+
|
|
1647
|
+
expect(polls).toBe(1)
|
|
1648
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
1649
|
+
'req_existing: machine_revoked (HTTP 401): This machine was revoked.',
|
|
1650
|
+
)
|
|
1651
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
1652
|
+
'reply polling stopped after a permanent server rejection',
|
|
1653
|
+
)
|
|
1654
|
+
expect(h.io.errLines.join('\n')).not.toContain('could not check whether its answer arrived')
|
|
827
1655
|
})
|
|
828
1656
|
|
|
829
1657
|
it('resumes with every answer that arrived, each tied to its question', async () => {
|
|
@@ -843,7 +1671,7 @@ describe('several questions in flight', () => {
|
|
|
843
1671
|
|
|
844
1672
|
// Both reply windows close immediately (first answer claims each
|
|
845
1673
|
// question); the retirement drain closes again, which is idempotent.
|
|
846
|
-
expect(new Set(h.recorder.closed)).toEqual(new Set(
|
|
1674
|
+
expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
|
|
847
1675
|
expect(readSessionState('multi2', h.env).pending).toBeUndefined()
|
|
848
1676
|
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
849
1677
|
expect(output.decision).toBe('block')
|
|
@@ -852,6 +1680,49 @@ describe('several questions in flight', () => {
|
|
|
852
1680
|
expect(output.reason).toContain('"Deploy where?" → "Staging"')
|
|
853
1681
|
})
|
|
854
1682
|
|
|
1683
|
+
it('journals an accepted answer until a successor Stop proves delivery', async () => {
|
|
1684
|
+
const h = harness([reply({ text: 'Ship it' })], 900)
|
|
1685
|
+
writeSessionState('answer-handoff', h.env, {
|
|
1686
|
+
last_prompt_at: AWAY,
|
|
1687
|
+
pending: [
|
|
1688
|
+
{
|
|
1689
|
+
question: 'Ship it?',
|
|
1690
|
+
asked_at: NOW,
|
|
1691
|
+
request_id: 'req_existing',
|
|
1692
|
+
collapse_key: 'question-existing',
|
|
1693
|
+
device_ids: ['dev_iphone'],
|
|
1694
|
+
reply_deadline_at: NOW + 60_000,
|
|
1695
|
+
},
|
|
1696
|
+
],
|
|
1697
|
+
})
|
|
1698
|
+
|
|
1699
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
|
|
1700
|
+
const staged = readSessionState('answer-handoff', h.env)
|
|
1701
|
+
expect(staged.accepted?.answers[0]?.reply.text).toBe('Ship it')
|
|
1702
|
+
expect(JSON.parse(h.io.outLines.at(-1)!).decision).toBe('block')
|
|
1703
|
+
|
|
1704
|
+
h.io.outLines = []
|
|
1705
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
|
|
1706
|
+
expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
|
|
1707
|
+
expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
|
|
1708
|
+
|
|
1709
|
+
h.io.outLines = []
|
|
1710
|
+
await hookRunCommand(
|
|
1711
|
+
h.deps,
|
|
1712
|
+
'stop',
|
|
1713
|
+
stdin({ session_id: 'answer-handoff', stop_hook_active: true }),
|
|
1714
|
+
)
|
|
1715
|
+
expect(h.io.outLines).toEqual([])
|
|
1716
|
+
expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
|
|
1717
|
+
expect(
|
|
1718
|
+
h.recorder.submitted.find(
|
|
1719
|
+
(entry) =>
|
|
1720
|
+
entry.draft.event === 'question_retired' &&
|
|
1721
|
+
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
1722
|
+
)?.draft.lifecycle?.state,
|
|
1723
|
+
).toBe('answered')
|
|
1724
|
+
})
|
|
1725
|
+
|
|
855
1726
|
it('resumes with a partial answer and keeps the rest registered', async () => {
|
|
856
1727
|
const h = harness([], 900)
|
|
857
1728
|
const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
|
|
@@ -867,7 +1738,7 @@ describe('several questions in flight', () => {
|
|
|
867
1738
|
expect(output.reason).toContain('1 more registered question is still waiting')
|
|
868
1739
|
const live = readSessionState('multi3', h.env).pending
|
|
869
1740
|
expect(live?.map((entry) => entry.question)).toEqual(['Deploy where?'])
|
|
870
|
-
expect(live?.[0]?.request_id).toBe(
|
|
1741
|
+
expect(live?.[0]?.request_id).toBe(h.recorder.receipts[1])
|
|
871
1742
|
|
|
872
1743
|
// The remaining question's answer arrives before the next turn ends; the
|
|
873
1744
|
// late-answer path hands it over without asking anything twice.
|
|
@@ -894,14 +1765,20 @@ describe('a question that outlives its session', () => {
|
|
|
894
1765
|
}))
|
|
895
1766
|
}
|
|
896
1767
|
|
|
897
|
-
/**
|
|
1768
|
+
/** State a still-owned request can have when SessionEnd races its Stop. */
|
|
898
1769
|
async function pushUnanswered(h: Harness, sessionId: string): Promise<string> {
|
|
899
|
-
writeSessionState(sessionId, h.env, {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1770
|
+
writeSessionState(sessionId, h.env, {
|
|
1771
|
+
last_prompt_at: AWAY,
|
|
1772
|
+
pending: [{
|
|
1773
|
+
question: 'Ship it?',
|
|
1774
|
+
asked_at: NOW,
|
|
1775
|
+
request_id: 'req_live',
|
|
1776
|
+
collapse_key: 'collapse-live',
|
|
1777
|
+
device_ids: ['dev_iphone'],
|
|
1778
|
+
reply_deadline_at: NOW + 60_000,
|
|
1779
|
+
}],
|
|
1780
|
+
})
|
|
1781
|
+
return 'req_live'
|
|
905
1782
|
}
|
|
906
1783
|
|
|
907
1784
|
it('retires a question the harness exited on, from a later session', async () => {
|
|
@@ -1030,12 +1907,10 @@ describe('ask registration', () => {
|
|
|
1030
1907
|
|
|
1031
1908
|
it('stores the validated question set, comma-bearing labels verbatim', () => {
|
|
1032
1909
|
const h = harness()
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}),
|
|
1038
|
-
).toBe(EXIT.ok)
|
|
1910
|
+
const built = buildQuestions({ choice: ['Yes, ship it', 'No, hold'] }, 'Ship it?')
|
|
1911
|
+
expect(built.ok).toBe(true)
|
|
1912
|
+
if (!built.ok) return
|
|
1913
|
+
registerQuestion('a2', h.env, { question: built.questions[0]!.text, questions: built.questions })
|
|
1039
1914
|
expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
|
|
1040
1915
|
{
|
|
1041
1916
|
id: 'ship-it',
|
|
@@ -1051,18 +1926,23 @@ describe('ask registration', () => {
|
|
|
1051
1926
|
it('registers a multi-question form and pushes it as one set', async () => {
|
|
1052
1927
|
const h = harness([reply({ text: 'Yes' })], 900)
|
|
1053
1928
|
writeSessionState('form1', h.env, { last_prompt_at: AWAY })
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
session: 'form1',
|
|
1057
|
-
form: JSON.stringify({
|
|
1929
|
+
const built = buildQuestions(
|
|
1930
|
+
{ form: JSON.stringify({
|
|
1058
1931
|
questions: [
|
|
1059
1932
|
{ text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
|
|
1060
1933
|
{ text: 'Anything to watch?' },
|
|
1061
1934
|
],
|
|
1062
1935
|
detail: '## Context\nThe long story.',
|
|
1063
|
-
}),
|
|
1064
|
-
|
|
1065
|
-
)
|
|
1936
|
+
}) },
|
|
1937
|
+
undefined,
|
|
1938
|
+
)
|
|
1939
|
+
expect(built.ok).toBe(true)
|
|
1940
|
+
if (!built.ok) return
|
|
1941
|
+
registerQuestion('form1', h.env, {
|
|
1942
|
+
question: built.questions[0]!.text,
|
|
1943
|
+
questions: built.questions,
|
|
1944
|
+
...(built.detail === undefined ? {} : { detail: built.detail }),
|
|
1945
|
+
})
|
|
1066
1946
|
const pending = readSessionState('form1', h.env).pending?.[0]
|
|
1067
1947
|
expect(pending?.questions).toHaveLength(2)
|
|
1068
1948
|
expect(pending?.detail).toContain('long story')
|
|
@@ -1101,9 +1981,10 @@ describe('ask registration', () => {
|
|
|
1101
1981
|
900,
|
|
1102
1982
|
)
|
|
1103
1983
|
writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1984
|
+
const built = buildQuestions({ choice: ['Yes', 'No'] }, 'Ship it?')
|
|
1985
|
+
expect(built.ok).toBe(true)
|
|
1986
|
+
if (!built.ok) return
|
|
1987
|
+
registerQuestion('latest1', h.env, { question: 'Ship it?', questions: built.questions })
|
|
1107
1988
|
|
|
1108
1989
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
|
|
1109
1990
|
|
|
@@ -1127,7 +2008,7 @@ describe('ask registration', () => {
|
|
|
1127
2008
|
900,
|
|
1128
2009
|
)
|
|
1129
2010
|
writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
|
|
1130
|
-
|
|
2011
|
+
registerQuestion('parts1', h.env, { question: 'Which color?' })
|
|
1131
2012
|
|
|
1132
2013
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
|
|
1133
2014
|
|
|
@@ -1136,27 +2017,17 @@ describe('ask registration', () => {
|
|
|
1136
2017
|
expect(decision.reason).toContain('"Use blue", then "actually teal"')
|
|
1137
2018
|
})
|
|
1138
2019
|
|
|
1139
|
-
it('
|
|
1140
|
-
// The exported id is often a chosen label while hook state is keyed by the
|
|
1141
|
-
// harness's own id, so the pointer must outrank the env var.
|
|
2020
|
+
it('does not let an explicit session bypass exact active-harness proof', () => {
|
|
1142
2021
|
const h = harness()
|
|
1143
|
-
h.deps
|
|
1144
|
-
|
|
1145
|
-
h.deps,
|
|
1146
|
-
'user-prompt-submit',
|
|
1147
|
-
stdin({ session_id: 'real1', cwd: h.deps.cwd }),
|
|
1148
|
-
'claude-code',
|
|
1149
|
-
)
|
|
1150
|
-
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
1151
|
-
expect(readSessionState('real1', h.env).pending?.[0]?.question).toBe('Ship it?')
|
|
1152
|
-
expect(readSessionState('my-label', h.env).pending).toBeUndefined()
|
|
2022
|
+
expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
|
|
2023
|
+
expect(readSessionState('guessed', h.env).pending).toBeUndefined()
|
|
1153
2024
|
})
|
|
1154
2025
|
|
|
1155
|
-
it('
|
|
2026
|
+
it('does not route from NOTIFAI_SESSION where no exact harness has spoken', () => {
|
|
1156
2027
|
const h = harness()
|
|
1157
2028
|
h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
|
|
1158
|
-
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.
|
|
1159
|
-
expect(readSessionState('solo-session', h.env).pending
|
|
2029
|
+
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
2030
|
+
expect(readSessionState('solo-session', h.env).pending).toBeUndefined()
|
|
1160
2031
|
})
|
|
1161
2032
|
|
|
1162
2033
|
it('queues alongside incomplete live state without touching it', () => {
|
|
@@ -1174,7 +2045,7 @@ describe('ask registration', () => {
|
|
|
1174
2045
|
],
|
|
1175
2046
|
})
|
|
1176
2047
|
|
|
1177
|
-
|
|
2048
|
+
registerQuestion('incomplete', h.env, { question: 'Another?' })
|
|
1178
2049
|
const pending = readSessionState('incomplete', h.env).pending
|
|
1179
2050
|
expect(pending?.map((entry) => entry.question)).toEqual(['Original?', 'Another?'])
|
|
1180
2051
|
expect(pending?.[0]).toMatchObject({
|
|
@@ -1186,11 +2057,10 @@ describe('ask registration', () => {
|
|
|
1186
2057
|
it('refuses a fifth question and names the form alternative', () => {
|
|
1187
2058
|
const h = harness()
|
|
1188
2059
|
for (const question of ['One?', 'Two?', 'Three?', 'Four?']) {
|
|
1189
|
-
|
|
2060
|
+
registerQuestion('crowded', h.env, { question })
|
|
1190
2061
|
}
|
|
1191
2062
|
|
|
1192
|
-
expect(
|
|
1193
|
-
expect(h.io.errLines.join('\n')).toContain('ask --form')
|
|
2063
|
+
expect(() => registerQuestion('crowded', h.env, { question: 'Five?' })).toThrow(/ask --form/)
|
|
1194
2064
|
expect(readSessionState('crowded', h.env).pending).toHaveLength(4)
|
|
1195
2065
|
})
|
|
1196
2066
|
})
|
|
@@ -1202,14 +2072,52 @@ describe('user-prompt-submit hook', () => {
|
|
|
1202
2072
|
expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
|
|
1203
2073
|
})
|
|
1204
2074
|
|
|
1205
|
-
it('
|
|
2075
|
+
it('records Stop separately so a prompt hook cannot impersonate turn-end routing', async () => {
|
|
1206
2076
|
const h = harness()
|
|
2077
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'events' }))
|
|
2078
|
+
expect(readSessionState('events', h.env).last_stop_at).toBeUndefined()
|
|
1207
2079
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
2080
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'events' }))
|
|
2081
|
+
expect(readSessionState('events', h.env).last_stop_at).toBe(NOW)
|
|
2082
|
+
})
|
|
2083
|
+
|
|
2084
|
+
it('diagnoses partial production input as malformed or truncated', async () => {
|
|
2085
|
+
const h = harness()
|
|
2086
|
+
const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
|
|
2087
|
+
const input = new PassThrough()
|
|
2088
|
+
input.write('{"session_id":')
|
|
2089
|
+
|
|
2090
|
+
expect(
|
|
2091
|
+
await hookRunCommand(
|
|
2092
|
+
deps,
|
|
2093
|
+
'user-prompt-submit',
|
|
2094
|
+
() => readStdinWithTimeout(input, 5),
|
|
2095
|
+
),
|
|
2096
|
+
).toBe(EXIT.ok)
|
|
1211
2097
|
|
|
1212
2098
|
expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
|
|
2099
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
2100
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
2101
|
+
expect(records.at(-1)?.data).toMatchObject({ reason: 'malformed-input' })
|
|
2102
|
+
})
|
|
2103
|
+
|
|
2104
|
+
it('records an empty production input timeout before failing open', async () => {
|
|
2105
|
+
const h = harness()
|
|
2106
|
+
const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
|
|
2107
|
+
const input = new PassThrough()
|
|
2108
|
+
|
|
2109
|
+
expect(
|
|
2110
|
+
await hookRunCommand(
|
|
2111
|
+
deps,
|
|
2112
|
+
'user-prompt-submit',
|
|
2113
|
+
() => readStdinWithTimeout(input, 5),
|
|
2114
|
+
),
|
|
2115
|
+
).toBe(EXIT.ok)
|
|
2116
|
+
|
|
2117
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
2118
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
2119
|
+
expect(records.at(-1)?.data).toMatchObject({ reason: 'input-read-failed' })
|
|
2120
|
+
expect(records.at(-1)?.data?.['message']).toMatch(/timed out waiting/)
|
|
1213
2121
|
})
|
|
1214
2122
|
|
|
1215
2123
|
it('explains when a missing machine credential makes a hook skip routing', async () => {
|
|
@@ -1225,28 +2133,20 @@ describe('user-prompt-submit hook', () => {
|
|
|
1225
2133
|
|
|
1226
2134
|
it('preserves a delivered question through a second ask and the prompt transition', async () => {
|
|
1227
2135
|
const h = harness([], 900)
|
|
1228
|
-
|
|
1229
|
-
h.deps,
|
|
1230
|
-
'user-prompt-submit',
|
|
1231
|
-
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
1232
|
-
'claude-code',
|
|
1233
|
-
)
|
|
1234
|
-
expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
|
|
1235
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
1236
|
-
|
|
1237
|
-
const live = readSessionState('transition', h.env).pending?.[0]
|
|
1238
|
-
expect(live).toMatchObject({
|
|
2136
|
+
const live = {
|
|
1239
2137
|
question: 'Ship it?',
|
|
1240
|
-
request_id: '
|
|
2138
|
+
request_id: 'req_live',
|
|
2139
|
+
collapse_key: 'collapse-live',
|
|
1241
2140
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1242
|
-
|
|
1243
|
-
|
|
2141
|
+
reply_deadline_at: NOW + 60_000,
|
|
2142
|
+
}
|
|
2143
|
+
writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
|
|
1244
2144
|
|
|
1245
2145
|
// A second plain `ask` resolves through the project pointer and joins the
|
|
1246
2146
|
// queue behind the live question — it must not touch it. The next prompt
|
|
1247
2147
|
// then resets presence before another Stop can run — the transition that
|
|
1248
2148
|
// used to erase the retirement debt.
|
|
1249
|
-
|
|
2149
|
+
registerQuestion('transition', h.env, { question: 'Deploy it?' })
|
|
1250
2150
|
const queued = readSessionState('transition', h.env)
|
|
1251
2151
|
expect(queued.retiring ?? []).toEqual([])
|
|
1252
2152
|
expect(queued.pending?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
|
|
@@ -1258,16 +2158,17 @@ describe('user-prompt-submit hook', () => {
|
|
|
1258
2158
|
'claude-code',
|
|
1259
2159
|
)
|
|
1260
2160
|
|
|
1261
|
-
// The delivered question retires truthfully
|
|
1262
|
-
// one
|
|
2161
|
+
// The delivered question retires truthfully. The never-delivered second
|
|
2162
|
+
// one must remain queued: if Codex skipped Stop, erasing it here loses the
|
|
2163
|
+
// only copy before it ever reached a device.
|
|
1263
2164
|
const retirement = h.recorder.submitted.find(
|
|
1264
2165
|
(submission) => submission.draft.event === 'question_retired',
|
|
1265
2166
|
)?.draft
|
|
1266
|
-
expect(retirement?.delivery.collapse_key).toBe(live
|
|
2167
|
+
expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
|
|
1267
2168
|
expect(retirement?.lifecycle).toEqual({
|
|
1268
2169
|
tier: 'done',
|
|
1269
2170
|
state: 'answered_elsewhere',
|
|
1270
|
-
retires_request_id: live
|
|
2171
|
+
retires_request_id: live.request_id,
|
|
1271
2172
|
})
|
|
1272
2173
|
expect(retirement?.targets).toEqual({
|
|
1273
2174
|
mode: 'selected',
|
|
@@ -1276,28 +2177,29 @@ describe('user-prompt-submit hook', () => {
|
|
|
1276
2177
|
expect(
|
|
1277
2178
|
h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
|
|
1278
2179
|
).toHaveLength(1)
|
|
2180
|
+
expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
2181
|
+
'Deploy it?',
|
|
2182
|
+
])
|
|
2183
|
+
|
|
2184
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
2185
|
+
expect(readSessionState('transition', h.env).pending).toBeUndefined()
|
|
1279
2186
|
})
|
|
1280
2187
|
|
|
1281
|
-
it('retires a
|
|
1282
|
-
// Drives the actual flow rather than hand-writing state: the previous
|
|
1283
|
-
// version of this test fabricated a shape production never wrote, so it
|
|
1284
|
-
// passed while the retirement path was unreachable.
|
|
2188
|
+
it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
|
|
1285
2189
|
const h = harness([])
|
|
1286
2190
|
writeSessionState('s11', h.env, { last_prompt_at: AWAY })
|
|
1287
2191
|
registerQuestion('s11', h.env, { question: 'Which environment?' })
|
|
1288
2192
|
|
|
1289
2193
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
2194
|
+
expect(readSessionState('s11', h.env).pending).toBeUndefined()
|
|
2195
|
+
const retirementCount = h.recorder.submitted.filter(
|
|
2196
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
2197
|
+
).length
|
|
1293
2198
|
|
|
1294
2199
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
|
|
1295
2200
|
|
|
1296
|
-
expect(h.recorder.closed).
|
|
1297
|
-
|
|
1298
|
-
expect(retirement?.presentation.title).toBe('Answered in the terminal')
|
|
1299
|
-
expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
|
|
1300
|
-
expect(retirement?.reply).toBeUndefined()
|
|
2201
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2202
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired')).toHaveLength(retirementCount + 1)
|
|
1301
2203
|
})
|
|
1302
2204
|
|
|
1303
2205
|
it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
|
|
@@ -1305,11 +2207,16 @@ describe('user-prompt-submit hook', () => {
|
|
|
1305
2207
|
// lifecycle update, which the server renders as a background push — the
|
|
1306
2208
|
// old "Answered" tombstone alert told the user what they just did.
|
|
1307
2209
|
const h = harness([])
|
|
1308
|
-
writeSessionState('s15', h.env, {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
2210
|
+
writeSessionState('s15', h.env, {
|
|
2211
|
+
last_prompt_at: AWAY,
|
|
2212
|
+
pending: [{
|
|
2213
|
+
question: 'Which environment?',
|
|
2214
|
+
request_id: 'req_live',
|
|
2215
|
+
collapse_key: 'collapse-live',
|
|
2216
|
+
device_ids: ['dev_iphone'],
|
|
2217
|
+
reply_deadline_at: NOW + 60_000,
|
|
2218
|
+
}],
|
|
2219
|
+
})
|
|
1313
2220
|
|
|
1314
2221
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
|
|
1315
2222
|
|
|
@@ -1319,7 +2226,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
1319
2226
|
state: 'answered_elsewhere',
|
|
1320
2227
|
// The history-entry correlation id: companions key entries by request
|
|
1321
2228
|
// id and never persisted the collapse key.
|
|
1322
|
-
retires_request_id: '
|
|
2229
|
+
retires_request_id: 'req_live',
|
|
1323
2230
|
})
|
|
1324
2231
|
})
|
|
1325
2232
|
|
|
@@ -1328,12 +2235,17 @@ describe('user-prompt-submit hook', () => {
|
|
|
1328
2235
|
registerQuestion('s16', h.env, { question: 'Ship it?' })
|
|
1329
2236
|
|
|
1330
2237
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
|
|
2238
|
+
await hookRunCommand(
|
|
2239
|
+
h.deps,
|
|
2240
|
+
'stop',
|
|
2241
|
+
stdin({ session_id: 's16', stop_hook_active: true }),
|
|
2242
|
+
)
|
|
1331
2243
|
|
|
1332
2244
|
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
1333
2245
|
expect(retirement?.draft.lifecycle).toEqual({
|
|
1334
2246
|
tier: 'done',
|
|
1335
2247
|
state: 'answered',
|
|
1336
|
-
retires_request_id:
|
|
2248
|
+
retires_request_id: h.recorder.receipts[0],
|
|
1337
2249
|
})
|
|
1338
2250
|
})
|
|
1339
2251
|
|
|
@@ -1364,19 +2276,101 @@ describe('user-prompt-submit hook', () => {
|
|
|
1364
2276
|
})
|
|
1365
2277
|
|
|
1366
2278
|
describe('session-end hook', () => {
|
|
1367
|
-
it('drops local state without touching the network
|
|
2279
|
+
it('drops local state without touching the network and records the lifecycle', async () => {
|
|
1368
2280
|
const h = harness()
|
|
1369
2281
|
writeSessionState('s12', h.env, { last_prompt_at: NOW })
|
|
2282
|
+
const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
|
|
1370
2283
|
|
|
1371
|
-
const code = await hookRunCommand(
|
|
2284
|
+
const code = await hookRunCommand(deps, 'session-end', stdin({ session_id: 's12' }))
|
|
1372
2285
|
|
|
1373
2286
|
expect(code).toBe(EXIT.ok)
|
|
1374
2287
|
expect(readSessionState('s12', h.env)).toEqual({})
|
|
1375
2288
|
expect(h.recorder.closed).toEqual([])
|
|
2289
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
2290
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
2291
|
+
expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
|
|
2292
|
+
})
|
|
2293
|
+
|
|
2294
|
+
it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
|
|
2295
|
+
const h = harness()
|
|
2296
|
+
const sessionId = 'session-end-budget'
|
|
2297
|
+
writeSessionState(sessionId, h.env, { last_prompt_at: NOW })
|
|
2298
|
+
writeProjectSession(h.deps.cwd, h.env, sessionId, Date.now(), 'codex')
|
|
2299
|
+
const configFile = sessionConfigPath(sessionId, h.env)
|
|
2300
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
2301
|
+
writeFileSync(configFile, 'ask_notifications = false\nlog_level = "info"\n')
|
|
2302
|
+
expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toMatchObject({
|
|
2303
|
+
value: false,
|
|
2304
|
+
source: expect.stringMatching(/^session:/),
|
|
2305
|
+
})
|
|
2306
|
+
|
|
2307
|
+
const stateFile = path.join(
|
|
2308
|
+
stateDir(h.env),
|
|
2309
|
+
'sessions',
|
|
2310
|
+
`${sanitizeSessionId(sessionId)}.json`,
|
|
2311
|
+
)
|
|
2312
|
+
const projectPointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
2313
|
+
expect(existsSync(stateFile)).toBe(true)
|
|
2314
|
+
expect(existsSync(configFile)).toBe(true)
|
|
2315
|
+
expect(existsSync(projectPointer)).toBe(true)
|
|
2316
|
+
|
|
2317
|
+
const logLock = `${activeLogPath(h.env)}.lock`
|
|
2318
|
+
const holder = runSessionEndLogLockHolder(h.deps.cwd, h.env, logLock)
|
|
2319
|
+
let cli: ReturnType<typeof runSessionEndCli> | undefined
|
|
2320
|
+
try {
|
|
2321
|
+
await waitUntil(
|
|
2322
|
+
() => existsSync(holder.readyPath),
|
|
2323
|
+
10_000,
|
|
2324
|
+
'SessionEnd log lock worker did not acquire its bakery ticket',
|
|
2325
|
+
)
|
|
2326
|
+
expect(readdirSync(logLock).filter((name) => name.startsWith('ticket-'))).toHaveLength(1)
|
|
2327
|
+
|
|
2328
|
+
cli = runSessionEndCli(h.env, h.deps.cwd, sessionId)
|
|
2329
|
+
await waitUntil(
|
|
2330
|
+
() =>
|
|
2331
|
+
readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
|
|
2332
|
+
700,
|
|
2333
|
+
'the real Codex hook did not reach contended diagnostics before its deadline',
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
// Reaching the second bakery ticket proves main.ts preAction did not write
|
|
2337
|
+
// first. Every durable cleanup must already be visible while diagnostics
|
|
2338
|
+
// are still blocked behind the live holder.
|
|
2339
|
+
expect(existsSync(stateFile)).toBe(false)
|
|
2340
|
+
expect(existsSync(configFile)).toBe(false)
|
|
2341
|
+
expect(existsSync(projectPointer)).toBe(false)
|
|
2342
|
+
|
|
2343
|
+
const result = await cli.done
|
|
2344
|
+
expect(result).toMatchObject({ code: EXIT.ok, signal: null, stdout: '', stderr: '' })
|
|
2345
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(900)
|
|
2346
|
+
expect(result.durationMs).toBeLessThan(2_000)
|
|
2347
|
+
// The diagnostic contender timed out rather than stealing the held lock,
|
|
2348
|
+
// disabled its sink, and still let the real CLI exit successfully.
|
|
2349
|
+
expect(existsSync(activeLogPath(h.env))).toBe(false)
|
|
2350
|
+
|
|
2351
|
+
writeFileSync(holder.releasePath, 'release')
|
|
2352
|
+
await holder.done
|
|
2353
|
+
|
|
2354
|
+
expect(readSessionState(sessionId, h.env)).toEqual({})
|
|
2355
|
+
expect(readProjectSession(h.deps.cwd, h.env, Date.now())).toBeNull()
|
|
2356
|
+
expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toEqual({
|
|
2357
|
+
value: true,
|
|
2358
|
+
source: 'default',
|
|
2359
|
+
})
|
|
2360
|
+
} finally {
|
|
2361
|
+
if (!existsSync(holder.releasePath)) writeFileSync(holder.releasePath, 'release')
|
|
2362
|
+
if (cli?.child.exitCode === null && cli.child.signalCode === null) cli.child.kill('SIGKILL')
|
|
2363
|
+
if (holder.child.exitCode === null && holder.child.signalCode === null) {
|
|
2364
|
+
holder.child.kill('SIGKILL')
|
|
2365
|
+
}
|
|
2366
|
+
rmSync(h.deps.cwd, { recursive: true, force: true })
|
|
2367
|
+
}
|
|
1376
2368
|
})
|
|
1377
2369
|
|
|
1378
|
-
it('clears only the ending session project
|
|
2370
|
+
it('clears only the ending session from a concurrent project index', async () => {
|
|
1379
2371
|
const h = harness()
|
|
2372
|
+
writeSessionState('still-running', h.env, { last_prompt_at: NOW })
|
|
2373
|
+
writeProjectSession(h.deps.cwd, h.env, 'still-running', NOW - 1, 'codex')
|
|
1380
2374
|
await hookRunCommand(
|
|
1381
2375
|
h.deps,
|
|
1382
2376
|
'user-prompt-submit',
|
|
@@ -1384,6 +2378,15 @@ describe('session-end hook', () => {
|
|
|
1384
2378
|
'claude-code',
|
|
1385
2379
|
)
|
|
1386
2380
|
expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
|
|
2381
|
+
expect(
|
|
2382
|
+
readMatchingProjectSessionPointer(
|
|
2383
|
+
h.deps.cwd,
|
|
2384
|
+
h.env,
|
|
2385
|
+
NOW,
|
|
2386
|
+
'still-running',
|
|
2387
|
+
'codex',
|
|
2388
|
+
),
|
|
2389
|
+
).toEqual({ sessionId: 'still-running', harness: 'codex' })
|
|
1387
2390
|
|
|
1388
2391
|
await hookRunCommand(
|
|
1389
2392
|
h.deps,
|
|
@@ -1392,7 +2395,16 @@ describe('session-end hook', () => {
|
|
|
1392
2395
|
'claude-code',
|
|
1393
2396
|
)
|
|
1394
2397
|
|
|
1395
|
-
expect(readProjectSession(h.deps.cwd, h.env, NOW)).
|
|
2398
|
+
expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('still-running')
|
|
2399
|
+
expect(
|
|
2400
|
+
readMatchingProjectSessionPointer(
|
|
2401
|
+
h.deps.cwd,
|
|
2402
|
+
h.env,
|
|
2403
|
+
NOW,
|
|
2404
|
+
'still-running',
|
|
2405
|
+
'codex',
|
|
2406
|
+
),
|
|
2407
|
+
).toEqual({ sessionId: 'still-running', harness: 'codex' })
|
|
1396
2408
|
})
|
|
1397
2409
|
|
|
1398
2410
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
@@ -1443,6 +2455,11 @@ describe('telling concurrent agents apart', () => {
|
|
|
1443
2455
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
1444
2456
|
|
|
1445
2457
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2458
|
+
await hookRunCommand(
|
|
2459
|
+
h.deps,
|
|
2460
|
+
'stop',
|
|
2461
|
+
stdin({ session_id: 'sess-abc', stop_hook_active: true }),
|
|
2462
|
+
)
|
|
1446
2463
|
|
|
1447
2464
|
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
1448
2465
|
expect(retirement?.draft.session).toBe('sess-abc')
|
|
@@ -1460,7 +2477,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
1460
2477
|
})
|
|
1461
2478
|
|
|
1462
2479
|
describe('clock jumps', () => {
|
|
1463
|
-
const config =
|
|
2480
|
+
const config = presenceGatedConfig('/nowhere', {})
|
|
1464
2481
|
|
|
1465
2482
|
it('does not hijack a terminal because the clock jumped forward', () => {
|
|
1466
2483
|
// NTP correction or a VM resume moves `now` without any time passing for
|
|
@@ -1694,13 +2711,33 @@ describe('two hooks racing one question', () => {
|
|
|
1694
2711
|
// A crashed hook must not suppress every question for this session for
|
|
1695
2712
|
// ever — that is worse than the duplicate the claim prevents.
|
|
1696
2713
|
const h = harness()
|
|
1697
|
-
|
|
2714
|
+
const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId('race3')}.claim`)
|
|
2715
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
2716
|
+
writeFileSync(
|
|
2717
|
+
file,
|
|
2718
|
+
`${JSON.stringify({ pid: 999_999_999, at: REAL - 1, token: 'dead' })}\n`,
|
|
2719
|
+
)
|
|
1698
2720
|
expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
|
|
1699
2721
|
})
|
|
1700
2722
|
|
|
2723
|
+
it('never steals an old claim while its process is still alive', () => {
|
|
2724
|
+
const h = harness()
|
|
2725
|
+
expect(claimQuestionPush('race-live', h.env, REAL - 10 * 60_000)).toBe(true)
|
|
2726
|
+
expect(claimQuestionPush('race-live', h.env, REAL)).toBe(false)
|
|
2727
|
+
})
|
|
2728
|
+
|
|
1701
2729
|
it('serializes a contender that arrives while a stale claim is being replaced', () => {
|
|
1702
2730
|
const h = harness()
|
|
1703
|
-
|
|
2731
|
+
const file = path.join(
|
|
2732
|
+
stateDir(h.env),
|
|
2733
|
+
'sessions',
|
|
2734
|
+
`${sanitizeSessionId('race-break')}.claim`,
|
|
2735
|
+
)
|
|
2736
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
2737
|
+
writeFileSync(
|
|
2738
|
+
file,
|
|
2739
|
+
`${JSON.stringify({ pid: 999_999_999, at: REAL - 10 * 60_000, token: 'dead' })}\n`,
|
|
2740
|
+
)
|
|
1704
2741
|
let contender: ReturnType<typeof claimQuestionPush> | undefined
|
|
1705
2742
|
|
|
1706
2743
|
const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
|
|
@@ -1729,10 +2766,56 @@ describe('two hooks racing one question', () => {
|
|
|
1729
2766
|
expect(h.recorder.submitted).toHaveLength(0)
|
|
1730
2767
|
expect(h.io.errLines.join(" ")).toContain('already handling')
|
|
1731
2768
|
})
|
|
2769
|
+
|
|
2770
|
+
it('resumes the agent once when two Stops collect the same late answer', async () => {
|
|
2771
|
+
const answers: ReplyView[] = []
|
|
2772
|
+
const h = harness(answers, 900)
|
|
2773
|
+
writeSessionState('race-late-answer', h.env, {
|
|
2774
|
+
last_prompt_at: AWAY,
|
|
2775
|
+
pending: [
|
|
2776
|
+
{
|
|
2777
|
+
question: 'Ship it?',
|
|
2778
|
+
asked_at: NOW,
|
|
2779
|
+
request_id: 'req_existing',
|
|
2780
|
+
collapse_key: 'question-existing',
|
|
2781
|
+
device_ids: ['dev_iphone'],
|
|
2782
|
+
reply_deadline_at: NOW + 60_000,
|
|
2783
|
+
},
|
|
2784
|
+
],
|
|
2785
|
+
})
|
|
2786
|
+
answers.push(reply({ text: 'Ship it' }))
|
|
2787
|
+
h.io.outLines = []
|
|
2788
|
+
|
|
2789
|
+
let polls = 0
|
|
2790
|
+
let markFirstPoll: (() => void) | undefined
|
|
2791
|
+
const firstPoll = new Promise<void>((resolve) => {
|
|
2792
|
+
markFirstPoll = resolve
|
|
2793
|
+
})
|
|
2794
|
+
let releasePolls: (() => void) | undefined
|
|
2795
|
+
const heldPoll = new Promise<void>((resolve) => {
|
|
2796
|
+
releasePolls = resolve
|
|
2797
|
+
})
|
|
2798
|
+
h.recorder.beforeReplies = async () => {
|
|
2799
|
+
polls += 1
|
|
2800
|
+
markFirstPoll?.()
|
|
2801
|
+
await heldPoll
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
const firstStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
|
|
2805
|
+
await firstPoll
|
|
2806
|
+
const secondStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
|
|
2807
|
+
await Promise.race([secondStop, new Promise<void>((resolve) => setImmediate(resolve))])
|
|
2808
|
+
releasePolls?.()
|
|
2809
|
+
await Promise.all([firstStop, secondStop])
|
|
2810
|
+
|
|
2811
|
+
const resumes = h.io.outLines.map((line) => JSON.parse(line) as { decision?: string })
|
|
2812
|
+
expect(polls).toBe(1)
|
|
2813
|
+
expect(resumes.filter((entry) => entry.decision === 'block')).toHaveLength(1)
|
|
2814
|
+
})
|
|
1732
2815
|
})
|
|
1733
2816
|
|
|
1734
2817
|
describe('question registration racing a Stop submission', () => {
|
|
1735
|
-
it('keeps
|
|
2818
|
+
it('keeps the new question when the older owner expires', async () => {
|
|
1736
2819
|
const h = harness([], 900)
|
|
1737
2820
|
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
1738
2821
|
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
@@ -1743,16 +2826,16 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1743
2826
|
|
|
1744
2827
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
|
|
1745
2828
|
|
|
1746
|
-
// The racing ask appended
|
|
1747
|
-
//
|
|
2829
|
+
// The racing ask appended and remains unasked. The already-delivered old
|
|
2830
|
+
// question is retired when this Stop owner reaches its deadline, so it
|
|
2831
|
+
// cannot accept a phone answer after the hook has returned.
|
|
1748
2832
|
const state = readSessionState('submit-race', h.env)
|
|
1749
|
-
expect(state.pending?.map((entry) => entry.question)).toEqual([
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
expect(state.retiring ?? []).toEqual([])
|
|
2833
|
+
expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
|
|
2834
|
+
expect(state.pending?.[0]?.request_id).toBeUndefined()
|
|
2835
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2836
|
+
expect(state.retiring).toContainEqual(
|
|
2837
|
+
expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
|
|
2838
|
+
)
|
|
1756
2839
|
})
|
|
1757
2840
|
|
|
1758
2841
|
it('keeps the newer question when the older in-flight question receives an answer', async () => {
|
|
@@ -1768,12 +2851,7 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1768
2851
|
|
|
1769
2852
|
const state = readSessionState('answer-race', h.env)
|
|
1770
2853
|
expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
|
|
1771
|
-
expect(
|
|
1772
|
-
|
|
1773
|
-
(entry) =>
|
|
1774
|
-
entry.draft.event === 'question_retired' &&
|
|
1775
|
-
entry.draft.lifecycle?.retires_request_id === 'req_hook_1',
|
|
1776
|
-
)?.draft.lifecycle?.state,
|
|
1777
|
-
).toBe('answered')
|
|
2854
|
+
expect(state.accepted?.answers[0]?.pending.request_id).toBe(h.recorder.receipts[0])
|
|
2855
|
+
expect(state.accepted?.answers[0]?.reply.text).toBe('Old answer')
|
|
1778
2856
|
})
|
|
1779
2857
|
})
|