@raidiant/notifai 0.3.0 → 0.5.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 +19 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- 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 +34 -0
- package/src/harnesses.ts +57 -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 +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
package/src/hooks.test.ts
CHANGED
|
@@ -1,34 +1,56 @@
|
|
|
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,
|
|
16
20
|
SubmissionReceipt,
|
|
17
21
|
SubmitNotificationRequestT,
|
|
18
22
|
} from '@raidiant/notifai-protocol'
|
|
19
|
-
import { describe, expect, it } from 'vitest'
|
|
20
|
-
import type
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
24
|
+
import { ApiCallError, type ApiClient } from './client.js'
|
|
25
|
+
import { CLAUDE_POST_SEND_LIVENESS_MS, type ClaudeWakeAdapters } from './claude-wake.js'
|
|
26
|
+
import type { CodexWakeAdapters, CodexWakeObservation } from './codex-wake.js'
|
|
27
|
+
import { readStdinWithTimeout } from './hook-input.js'
|
|
28
|
+
import {
|
|
29
|
+
activeLogPath,
|
|
30
|
+
bootstrapLogger,
|
|
31
|
+
createLogger,
|
|
32
|
+
readLogRecords,
|
|
33
|
+
type LogRecord,
|
|
34
|
+
} from './logging.js'
|
|
35
|
+
import { EXIT, askCommand, buildQuestions, hookRunCommand, type CommandDeps, type CommandIo } from './commands.js'
|
|
36
|
+
import {
|
|
37
|
+
loadConfig,
|
|
38
|
+
projectSessionPointerPath,
|
|
39
|
+
sanitizeSessionId,
|
|
40
|
+
sessionConfigPath,
|
|
41
|
+
stateDir,
|
|
42
|
+
} from './config.js'
|
|
23
43
|
import {
|
|
24
44
|
claimQuestionPush,
|
|
25
45
|
clearSessionState,
|
|
26
46
|
drainRetirements,
|
|
27
47
|
drainOrphanRetirements,
|
|
28
|
-
|
|
48
|
+
runEscalationWaiter,
|
|
49
|
+
MAX_CONTINUATION_COUNT,
|
|
29
50
|
orphanRetirements,
|
|
30
51
|
pruneAbandonedSessions,
|
|
31
52
|
releaseQuestionPush,
|
|
53
|
+
readMatchingProjectSessionPointer,
|
|
32
54
|
readProjectSession,
|
|
33
55
|
readSessionState,
|
|
34
56
|
registerQuestion,
|
|
@@ -55,15 +77,31 @@ interface Recorder {
|
|
|
55
77
|
submitted: SubmitNotificationRequestT[]
|
|
56
78
|
/** `submitted[i]`'s request id, so a test can name what it just sent. */
|
|
57
79
|
receipts: string[]
|
|
80
|
+
/** Stable test aliases survive the fresh client created for each hook. */
|
|
81
|
+
aliases: Map<string, number>
|
|
58
82
|
closed: string[]
|
|
59
83
|
/** Simulates an offline machine; retirement has to survive one. */
|
|
60
84
|
failSubmits?: boolean
|
|
61
85
|
/** Runs while an agent_question submit is in flight, before onSubmitted. */
|
|
62
86
|
beforeQuestionSubmit?: () => void
|
|
87
|
+
/** Lets race tests hold every replies call until all contenders are polling. */
|
|
88
|
+
beforeReplies?: () => Promise<void>
|
|
89
|
+
/** Per-request answer stream for multi-question ownership tests. */
|
|
90
|
+
repliesFor?: (requestId: string) => ReplyView[]
|
|
63
91
|
}
|
|
64
92
|
|
|
65
93
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
66
94
|
let submissions = 0
|
|
95
|
+
const recordedReplies = (requestId: string): ReplyView[] => {
|
|
96
|
+
const direct = recorder.repliesFor?.(requestId)
|
|
97
|
+
if (direct !== undefined && direct.length > 0) return direct
|
|
98
|
+
const ordinal = recorder.aliases.get(requestId)
|
|
99
|
+
if (ordinal !== undefined) {
|
|
100
|
+
const legacyAlias = recorder.repliesFor?.(`req_hook_${ordinal}`)
|
|
101
|
+
if (legacyAlias !== undefined) return legacyAlias
|
|
102
|
+
}
|
|
103
|
+
return direct ?? replies
|
|
104
|
+
}
|
|
67
105
|
return {
|
|
68
106
|
beginPairing: notUsed,
|
|
69
107
|
pollPairing: notUsed,
|
|
@@ -98,19 +136,33 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
98
136
|
if (body.draft.event === 'agent_question') recorder.beforeQuestionSubmit?.()
|
|
99
137
|
recorder.submitted.push(body)
|
|
100
138
|
submissions += 1
|
|
101
|
-
|
|
139
|
+
const requestId = body.request_id ?? `req_hook_${submissions}`
|
|
140
|
+
recorder.aliases.set(requestId, recorder.receipts.length + 1)
|
|
141
|
+
recorder.receipts.push(requestId)
|
|
102
142
|
return {
|
|
103
|
-
request_id:
|
|
143
|
+
request_id: requestId,
|
|
144
|
+
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
104
145
|
replayed: false,
|
|
105
146
|
overall: 'provider_accepted_all',
|
|
106
147
|
deliveries: [],
|
|
107
148
|
warnings: [],
|
|
108
149
|
} satisfies SubmissionReceipt
|
|
109
150
|
},
|
|
110
|
-
replies: async (requestId) =>
|
|
111
|
-
(
|
|
151
|
+
replies: async (requestId) => {
|
|
152
|
+
await recorder.beforeReplies?.()
|
|
153
|
+
return ({
|
|
154
|
+
request_id: requestId,
|
|
155
|
+
reply_expires_at: null,
|
|
156
|
+
replies: recordedReplies(requestId),
|
|
157
|
+
}) satisfies ListRepliesResponse
|
|
158
|
+
},
|
|
112
159
|
closeReplies: async (requestId) => {
|
|
113
160
|
recorder.closed.push(requestId)
|
|
161
|
+
return {
|
|
162
|
+
request_id: requestId,
|
|
163
|
+
reply_expires_at: new Date(NOW).toISOString(),
|
|
164
|
+
replies: recordedReplies(requestId),
|
|
165
|
+
} satisfies ListRepliesResponse
|
|
114
166
|
},
|
|
115
167
|
} as ApiClient
|
|
116
168
|
}
|
|
@@ -139,23 +191,19 @@ interface Harness {
|
|
|
139
191
|
io: CapturedIo
|
|
140
192
|
recorder: Recorder
|
|
141
193
|
env: NodeJS.ProcessEnv
|
|
194
|
+
advanceClock(milliseconds: number): void
|
|
142
195
|
}
|
|
143
196
|
|
|
144
197
|
const NOW = 1_800_000_000_000
|
|
145
198
|
|
|
146
|
-
|
|
147
|
-
* `idleSeconds` defaults to null — "this machine has no idle source" — so these
|
|
148
|
-
* cases exercise the degraded path and stay independent of whether the person
|
|
149
|
-
* running the suite is touching their own keyboard.
|
|
150
|
-
*/
|
|
151
|
-
function harness(replies: ReplyView[] = [], idleSeconds: number | null = null): Harness {
|
|
199
|
+
function harness(replies: ReplyView[] = []): Harness {
|
|
152
200
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-'))
|
|
153
201
|
const env: NodeJS.ProcessEnv = {
|
|
154
202
|
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
155
203
|
XDG_STATE_HOME: path.join(root, 'state'),
|
|
156
204
|
}
|
|
157
205
|
const io = new CapturedIo()
|
|
158
|
-
const recorder: Recorder = { submitted: [], receipts: [], closed: [] }
|
|
206
|
+
const recorder: Recorder = { submitted: [], receipts: [], closed: [], aliases: new Map() }
|
|
159
207
|
// Virtual clock: sleeps advance it instead of costing wall time. A frozen
|
|
160
208
|
// clock would make the reply poll's deadline unreachable and spin forever.
|
|
161
209
|
let clock = NOW
|
|
@@ -163,6 +211,9 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
|
|
|
163
211
|
io,
|
|
164
212
|
recorder,
|
|
165
213
|
env,
|
|
214
|
+
advanceClock(milliseconds: number) {
|
|
215
|
+
clock += milliseconds
|
|
216
|
+
},
|
|
166
217
|
deps: {
|
|
167
218
|
io,
|
|
168
219
|
store: {
|
|
@@ -180,7 +231,6 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
|
|
|
180
231
|
cwd: root,
|
|
181
232
|
clientFactory: () => fakeClient(recorder, replies),
|
|
182
233
|
now: () => clock,
|
|
183
|
-
idleSeconds: () => idleSeconds,
|
|
184
234
|
sleep: async (milliseconds: number) => {
|
|
185
235
|
clock += milliseconds
|
|
186
236
|
},
|
|
@@ -192,45 +242,144 @@ function stdin(payload: unknown): () => Promise<string> {
|
|
|
192
242
|
return async () => JSON.stringify(payload)
|
|
193
243
|
}
|
|
194
244
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
245
|
+
async function waitUntil(
|
|
246
|
+
predicate: () => boolean,
|
|
247
|
+
timeoutMs: number,
|
|
248
|
+
failure: string,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
const deadline = Date.now() + timeoutMs
|
|
251
|
+
while (!predicate()) {
|
|
252
|
+
if (Date.now() >= deadline) throw new Error(failure)
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
198
256
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
257
|
+
interface SessionEndLogLockHolder {
|
|
258
|
+
child: ChildProcess
|
|
259
|
+
readyPath: string
|
|
260
|
+
releasePath: string
|
|
261
|
+
done: Promise<void>
|
|
262
|
+
}
|
|
205
263
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
264
|
+
function runSessionEndLogLockHolder(
|
|
265
|
+
root: string,
|
|
266
|
+
env: NodeJS.ProcessEnv,
|
|
267
|
+
lockPath: string,
|
|
268
|
+
): SessionEndLogLockHolder {
|
|
269
|
+
const readyPath = path.join(root, 'session-end-log-lock-ready')
|
|
270
|
+
const releasePath = path.join(root, 'session-end-log-lock-release')
|
|
271
|
+
const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
|
|
272
|
+
const child = spawn(
|
|
273
|
+
process.execPath,
|
|
274
|
+
[
|
|
275
|
+
vitest,
|
|
276
|
+
'run',
|
|
277
|
+
'src/session-end-log-lock-worker.test.ts',
|
|
278
|
+
'--reporter=dot',
|
|
279
|
+
'--maxWorkers=1',
|
|
280
|
+
],
|
|
281
|
+
{
|
|
282
|
+
cwd: process.cwd(),
|
|
283
|
+
env: {
|
|
284
|
+
...process.env,
|
|
285
|
+
...env,
|
|
286
|
+
NOTIFAI_SESSION_END_LOG_LOCK_WORKER_MODE: 'hold',
|
|
287
|
+
NOTIFAI_SESSION_END_LOG_LOCK_PATH: lockPath,
|
|
288
|
+
NOTIFAI_SESSION_END_LOG_LOCK_READY: readyPath,
|
|
289
|
+
NOTIFAI_SESSION_END_LOG_LOCK_RELEASE: releasePath,
|
|
290
|
+
},
|
|
291
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
const stdout: Buffer[] = []
|
|
295
|
+
const stderr: Buffer[] = []
|
|
296
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
297
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
298
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
299
|
+
child.once('error', reject)
|
|
300
|
+
child.once('exit', (code) => {
|
|
301
|
+
if (code === 0) resolve()
|
|
302
|
+
else {
|
|
303
|
+
reject(
|
|
304
|
+
new Error(
|
|
305
|
+
`SessionEnd log lock worker exited ${String(code)}\n${Buffer.concat(stdout).toString()}\n${Buffer.concat(stderr).toString()}`,
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
})
|
|
211
310
|
})
|
|
311
|
+
void done.catch(() => undefined)
|
|
312
|
+
return { child, readyPath, releasePath, done }
|
|
313
|
+
}
|
|
212
314
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
315
|
+
interface CliProcessResult {
|
|
316
|
+
code: number | null
|
|
317
|
+
signal: NodeJS.Signals | null
|
|
318
|
+
stdout: string
|
|
319
|
+
stderr: string
|
|
320
|
+
durationMs: number
|
|
321
|
+
}
|
|
220
322
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
323
|
+
function runSessionEndCli(
|
|
324
|
+
env: NodeJS.ProcessEnv,
|
|
325
|
+
cwd: string,
|
|
326
|
+
sessionId: string,
|
|
327
|
+
): { child: ChildProcess; done: Promise<CliProcessResult> } {
|
|
328
|
+
const startedAt = Date.now()
|
|
329
|
+
const child = spawn(
|
|
330
|
+
process.execPath,
|
|
331
|
+
[
|
|
332
|
+
path.join(process.cwd(), 'dist', 'main.js'),
|
|
333
|
+
'hook',
|
|
334
|
+
'session-end',
|
|
335
|
+
'--owner',
|
|
336
|
+
'notifai',
|
|
337
|
+
'--harness',
|
|
338
|
+
'codex',
|
|
339
|
+
],
|
|
340
|
+
{
|
|
341
|
+
cwd,
|
|
342
|
+
env: { ...process.env, ...env, NOTIFAI_CREDENTIALS: 'file' },
|
|
343
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
const stdout: Buffer[] = []
|
|
347
|
+
const stderr: Buffer[] = []
|
|
348
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
349
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
350
|
+
child.stdin!.end(JSON.stringify({ session_id: sessionId, cwd }))
|
|
351
|
+
const done = new Promise<CliProcessResult>((resolve, reject) => {
|
|
352
|
+
child.once('error', reject)
|
|
353
|
+
child.once('exit', (code, signal) => {
|
|
354
|
+
resolve({
|
|
355
|
+
code,
|
|
356
|
+
signal,
|
|
357
|
+
stdout: Buffer.concat(stdout).toString(),
|
|
358
|
+
stderr: Buffer.concat(stderr).toString(),
|
|
359
|
+
durationMs: Date.now() - startedAt,
|
|
360
|
+
})
|
|
361
|
+
})
|
|
230
362
|
})
|
|
363
|
+
void done.catch(() => undefined)
|
|
364
|
+
return { child, done }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function writeGlobalConfig(h: Harness, toml: string): void {
|
|
368
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
369
|
+
mkdirSync(dir, { recursive: true })
|
|
370
|
+
writeFileSync(path.join(dir, 'config.toml'), toml)
|
|
371
|
+
}
|
|
231
372
|
|
|
232
|
-
|
|
233
|
-
|
|
373
|
+
/** Long enough ago that no test is accidentally sensitive to the stamp. */
|
|
374
|
+
const AWAY = NOW - 600_000
|
|
375
|
+
|
|
376
|
+
describe('pushing a registered question', () => {
|
|
377
|
+
// Found by a live Claude Code session: a spawned agent always has a
|
|
378
|
+
// just-set last_prompt_at, and the routing gate once read that as the user
|
|
379
|
+
// being present — so its FIRST question could never escalate, which is the
|
|
380
|
+
// "kick off agents and walk away" case the feature exists for.
|
|
381
|
+
it('pushes a freshly spawned session first question', async () => {
|
|
382
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
234
383
|
// Prompt 20s ago, exactly as a just-spawned agent has.
|
|
235
384
|
writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
|
|
236
385
|
registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
|
|
@@ -251,7 +400,7 @@ describe('presence gate', () => {
|
|
|
251
400
|
})
|
|
252
401
|
|
|
253
402
|
it('names the resolved project in the pushed question title', async () => {
|
|
254
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
403
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
255
404
|
const projectDir = path.join(h.deps.cwd, '.notifai')
|
|
256
405
|
mkdirSync(projectDir, { recursive: true })
|
|
257
406
|
writeFileSync(
|
|
@@ -265,171 +414,354 @@ describe('presence gate', () => {
|
|
|
265
414
|
|
|
266
415
|
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
|
|
267
416
|
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A hook's decisions are invisible from everywhere else: its stderr belongs to
|
|
421
|
+
* the harness, and its usual outcome is to do nothing at all. These are the
|
|
422
|
+
* tests that the local log closes that gap — that "my question never reached my
|
|
423
|
+
* phone" has an answer rather than a silence.
|
|
424
|
+
*/
|
|
425
|
+
describe('what the hook leaves behind', () => {
|
|
426
|
+
function recording(h: Harness): CommandDeps {
|
|
427
|
+
return { ...h.deps, logger: createLogger({ env: h.env, cmd: 'hook stop' }) }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function gates(h: Harness): LogRecord[] {
|
|
431
|
+
return readLogRecords(h.env, { event: ['hook.gate'], limit: 50 }).records
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
it('records why a question was held, in a name a filter can match', async () => {
|
|
435
|
+
const h = harness([])
|
|
436
|
+
writeSessionState('held', h.env, { accepted: undefined })
|
|
437
|
+
registerQuestion('held', h.env, { question: 'Ship it?' }, NOW)
|
|
438
|
+
// A continuation turn must not re-ask the question it is continuing from.
|
|
439
|
+
writeSessionState('held', h.env, {
|
|
440
|
+
...readSessionState('held', h.env),
|
|
441
|
+
continuation: { answered_at: NOW + 1000, count: 1 },
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
await hookRunCommand(
|
|
445
|
+
recording(h),
|
|
446
|
+
'stop',
|
|
447
|
+
stdin({ session_id: 'held', cwd: h.deps.cwd, stop_hook_active: true }),
|
|
448
|
+
)
|
|
268
449
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
expect(
|
|
450
|
+
const held = gates(h).find((record) => record.data?.['reason'] === 'continuation-repeat')
|
|
451
|
+
expect(held).toBeDefined()
|
|
452
|
+
expect(held!.data).toMatchObject({ verdict: 'held' })
|
|
453
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
273
454
|
})
|
|
274
455
|
|
|
275
|
-
it('
|
|
276
|
-
// Silent
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
456
|
+
it('records the switch being off, which the user is deliberately never told', async () => {
|
|
457
|
+
// Silent to the user by design, so from outside "you turned it off" and "a
|
|
458
|
+
// bug ate my question" look identical. This is what tells them apart.
|
|
459
|
+
const h = harness([])
|
|
460
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
461
|
+
mkdirSync(dir, { recursive: true })
|
|
462
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_notifications = false\n')
|
|
463
|
+
writeSessionState('off', h.env, { last_prompt_at: AWAY })
|
|
464
|
+
registerQuestion('off', h.env, { question: 'Ship it?' })
|
|
465
|
+
|
|
466
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'off', cwd: h.deps.cwd }))
|
|
467
|
+
|
|
281
468
|
expect(h.recorder.submitted).toHaveLength(0)
|
|
282
|
-
|
|
469
|
+
// Nothing was printed, and the log is the only account of it.
|
|
470
|
+
expect(h.io.errLines.join('\n')).not.toContain('notifications')
|
|
471
|
+
expect(gates(h).map((record) => record.data?.['reason'])).toContain('notifications-off')
|
|
283
472
|
})
|
|
284
|
-
})
|
|
285
473
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
474
|
+
it('records a turn with no question at all, so "did the hook run" is answerable', async () => {
|
|
475
|
+
const h = harness()
|
|
476
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'quiet', cwd: h.deps.cwd }))
|
|
477
|
+
const events = readLogRecords(h.env, { limit: 50 }).records.map((record) => record.event)
|
|
478
|
+
expect(events).toContain('hook.start')
|
|
479
|
+
expect(events).toContain('hook.end')
|
|
480
|
+
expect(gates(h).map((record) => record.data?.['reason'])).toContain('no-question')
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('lets the hook project re-enable a bootstrap logger and records resolved config', async () => {
|
|
484
|
+
const h = harness()
|
|
485
|
+
writeGlobalConfig(h, 'log_level = "off"\n')
|
|
486
|
+
const startup = path.join(h.deps.cwd, 'startup')
|
|
487
|
+
const project = path.join(h.deps.cwd, 'project')
|
|
488
|
+
mkdirSync(startup, { recursive: true })
|
|
489
|
+
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
490
|
+
writeFileSync(
|
|
491
|
+
path.join(project, '.notifai', 'config.toml'),
|
|
492
|
+
'project = "hook-project"\nlog_level = "debug"\n',
|
|
493
|
+
)
|
|
494
|
+
const deps = { ...h.deps, cwd: startup, logger: bootstrapLogger({ env: h.env, cwd: startup }) }
|
|
495
|
+
|
|
496
|
+
await hookRunCommand(deps, 'stop', stdin({ session_id: 'reenabled', cwd: project }))
|
|
497
|
+
|
|
498
|
+
const records = readLogRecords(h.env, { limit: 50 }).records
|
|
499
|
+
expect(records.map((record) => record.event)).toEqual(
|
|
500
|
+
expect.arrayContaining(['config.resolved', 'hook.start', 'hook.end']),
|
|
501
|
+
)
|
|
502
|
+
expect(records.find((record) => record.event === 'config.resolved')).toMatchObject({
|
|
503
|
+
project: 'hook-project',
|
|
504
|
+
session: 'reenabled',
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('persists an answer once while still reporting it to the harness', async () => {
|
|
509
|
+
const h = harness([reply({ text: 'Allow exactly once' })])
|
|
510
|
+
writeSessionState('answer-log', h.env, { last_prompt_at: AWAY })
|
|
511
|
+
registerQuestion('answer-log', h.env, { question: 'Ship it?' })
|
|
512
|
+
|
|
513
|
+
await hookRunCommand(
|
|
514
|
+
recording(h),
|
|
515
|
+
'stop',
|
|
516
|
+
stdin({ session_id: 'answer-log', cwd: h.deps.cwd }),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
520
|
+
expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
|
|
521
|
+
const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
|
|
522
|
+
expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
|
|
523
|
+
'queued',
|
|
524
|
+
'delivered',
|
|
525
|
+
])
|
|
526
|
+
expect(h.io.errLines.join('\n')).toContain('Allow exactly once')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('records the push and ties it to the request id the server knows', async () => {
|
|
530
|
+
const h = harness([])
|
|
294
531
|
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
295
532
|
mkdirSync(dir, { recursive: true })
|
|
296
|
-
writeFileSync(path.join(dir, 'config.toml'),
|
|
297
|
-
|
|
533
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
534
|
+
writeSessionState('pushed', h.env, { last_prompt_at: AWAY })
|
|
535
|
+
registerQuestion('pushed', h.env, { question: 'Ship it?' })
|
|
298
536
|
|
|
299
|
-
|
|
300
|
-
// One second of idle: as present as it is possible to be.
|
|
301
|
-
const config = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
|
|
302
|
-
expect(isUserAway({ last_prompt_at: NOW }, config, NOW, 1)).toBe(false)
|
|
537
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'pushed', cwd: h.deps.cwd }))
|
|
303
538
|
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
expect(
|
|
539
|
+
const pushed = readLogRecords(h.env, { event: ['hook.pushed'] }).records
|
|
540
|
+
expect(pushed).toHaveLength(1)
|
|
541
|
+
expect(pushed[0]!.data).toMatchObject({ ok: true })
|
|
542
|
+
// The local record and the server's evidence trail share one id, which is
|
|
543
|
+
// what makes `notifai logs --request <id>` and `notifai status <id>` two
|
|
544
|
+
// views of one thing.
|
|
545
|
+
expect(pushed[0]!.data!['request_id']).toBe(h.recorder.receipts[0])
|
|
307
546
|
})
|
|
308
547
|
|
|
309
|
-
it('
|
|
310
|
-
const h = harness([]
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
registerQuestion('
|
|
548
|
+
it('stores every request in a multi-question wait as a structured identity', async () => {
|
|
549
|
+
const h = harness([])
|
|
550
|
+
writeSessionState('request-log', h.env, { last_prompt_at: AWAY })
|
|
551
|
+
registerQuestion('request-log', h.env, { question: 'Ship it?' }, NOW)
|
|
552
|
+
registerQuestion('request-log', h.env, { question: 'Deploy it?' }, NOW + 1)
|
|
314
553
|
|
|
315
|
-
await hookRunCommand(
|
|
554
|
+
await hookRunCommand(
|
|
555
|
+
recording(h),
|
|
556
|
+
'stop',
|
|
557
|
+
stdin({ session_id: 'request-log', cwd: h.deps.cwd }),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
const unanswered = readLogRecords(h.env, { event: ['hook.answer'] }).records.at(-1)
|
|
561
|
+
expect(unanswered?.data?.['request_ids']).toEqual(h.recorder.receipts)
|
|
562
|
+
expect(readSessionState('request-log', h.env).pending).toBeUndefined()
|
|
316
563
|
|
|
317
|
-
|
|
318
|
-
|
|
564
|
+
for (const requestId of h.recorder.receipts) {
|
|
565
|
+
const matching = readLogRecords(h.env, { request: requestId }).records
|
|
566
|
+
expect(matching).toContainEqual(unanswered)
|
|
567
|
+
}
|
|
568
|
+
expect(readLogRecords(h.env, { request: 'req_hook' }).records).not.toContainEqual(unanswered)
|
|
319
569
|
})
|
|
320
570
|
|
|
321
|
-
it('
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 300\n')
|
|
326
|
-
writeSessionState('present2', h.env, { last_prompt_at: NOW })
|
|
327
|
-
registerQuestion('present2', h.env, { question: 'Ship it?' }, NOW)
|
|
571
|
+
it('records a config parse failure as a complete fail-open lifecycle', async () => {
|
|
572
|
+
const h = harness()
|
|
573
|
+
mkdirSync(path.join(h.deps.cwd, '.notifai'), { recursive: true })
|
|
574
|
+
writeFileSync(path.join(h.deps.cwd, '.notifai', 'config.toml'), 'not valid = [toml')
|
|
328
575
|
|
|
329
|
-
await hookRunCommand(
|
|
576
|
+
await hookRunCommand(
|
|
577
|
+
{ ...h.deps, logger: createLogger({ env: h.env }) },
|
|
578
|
+
'stop',
|
|
579
|
+
stdin({ session_id: 'bad-config', cwd: h.deps.cwd }),
|
|
580
|
+
)
|
|
330
581
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
|
|
582
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
583
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
584
|
+
expect(records.at(-1)?.data).toMatchObject({ outcome: 'failed', reason: 'config-failed' })
|
|
335
585
|
})
|
|
336
586
|
|
|
337
|
-
it('
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
587
|
+
it('records a hook failure with the server own words, not just that it failed', async () => {
|
|
588
|
+
const h = harness()
|
|
589
|
+
const deps: CommandDeps = {
|
|
590
|
+
...recording(h),
|
|
591
|
+
clientFactory: () => {
|
|
592
|
+
throw new ApiCallError(422, 'validation_failed', 'draft rejected', null, [
|
|
593
|
+
{ path: '/draft/lifecycle' },
|
|
594
|
+
])
|
|
595
|
+
},
|
|
596
|
+
}
|
|
597
|
+
registerQuestion('broken', h.env, { question: 'Ship it?' })
|
|
598
|
+
writeSessionState('broken', h.env, { last_prompt_at: AWAY })
|
|
344
599
|
|
|
345
|
-
await hookRunCommand(
|
|
600
|
+
await hookRunCommand(deps, 'stop', stdin({ session_id: 'broken', cwd: h.deps.cwd }))
|
|
346
601
|
|
|
347
|
-
|
|
348
|
-
expect(
|
|
349
|
-
expect(
|
|
602
|
+
const failures = readLogRecords(h.env, { level: 'error' }).records
|
|
603
|
+
expect(failures.length).toBeGreaterThan(0)
|
|
604
|
+
expect(JSON.stringify(failures)).toContain('/draft/lifecycle')
|
|
605
|
+
expect(JSON.stringify(failures)).toContain('validation_failed')
|
|
350
606
|
})
|
|
351
607
|
|
|
352
|
-
it('
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
608
|
+
it('never writes the machine credential, whatever the hook did', async () => {
|
|
609
|
+
const h = harness([])
|
|
610
|
+
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
611
|
+
mkdirSync(dir, { recursive: true })
|
|
612
|
+
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
613
|
+
writeSessionState('secret', h.env, { last_prompt_at: AWAY })
|
|
614
|
+
registerQuestion('secret', h.env, { question: 'Ship it?' })
|
|
358
615
|
|
|
359
|
-
await hookRunCommand(h
|
|
616
|
+
await hookRunCommand(recording(h), 'stop', stdin({ session_id: 'secret', cwd: h.deps.cwd }))
|
|
360
617
|
|
|
361
|
-
|
|
618
|
+
// The log is the artefact most likely to be pasted into a conversation.
|
|
619
|
+
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
620
|
+
expect(raw).not.toContain('test-secret')
|
|
621
|
+
expect(raw).not.toContain('nfm_mac_test')
|
|
362
622
|
})
|
|
363
623
|
})
|
|
364
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Wanting notifications while still at the machine is a legitimate setting —
|
|
627
|
+
* nobody should have to stop using their computer in order to be reached.
|
|
628
|
+
* Presence is a precondition only while the user wants it to be, and switching
|
|
629
|
+
* it off must not disturb the grace timer.
|
|
630
|
+
*/
|
|
365
631
|
describe('terminal-first grace window', () => {
|
|
366
|
-
/** Registers a question asked `agoMs` ago
|
|
632
|
+
/** Registers a question asked `agoMs` ago. */
|
|
367
633
|
function pending(h: Harness, session: string, agoMs: number): void {
|
|
368
634
|
writeSessionState(session, h.env, { last_prompt_at: AWAY })
|
|
369
635
|
registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
|
|
370
636
|
}
|
|
371
637
|
|
|
638
|
+
it('pushes as soon as the turn ends when no window is configured', async () => {
|
|
639
|
+
const h = harness([])
|
|
640
|
+
let submittedAt: number | undefined
|
|
641
|
+
h.recorder.beforeQuestionSubmit = () => {
|
|
642
|
+
submittedAt = h.deps.now?.()
|
|
643
|
+
}
|
|
644
|
+
// The user typed this very instant. It changes nothing: the waiter does
|
|
645
|
+
// not hold their terminal, so their being here is not a reason to wait.
|
|
646
|
+
writeSessionState('present1', h.env, { last_prompt_at: NOW })
|
|
647
|
+
registerQuestion('present1', h.env, { question: 'Ship it?' })
|
|
648
|
+
|
|
649
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present1' }))
|
|
650
|
+
|
|
651
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
652
|
+
expect(submittedAt).toBe(NOW)
|
|
653
|
+
})
|
|
654
|
+
|
|
372
655
|
it('holds the question in the terminal until the window elapses', async () => {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const h = harness([reply({ text: 'Yes' })], 900)
|
|
656
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
657
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
376
658
|
pending(h, 'g1', 0)
|
|
377
659
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
|
|
378
660
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
379
|
-
//
|
|
661
|
+
// Sleeps advance the virtual clock, so this is proof the wait happened.
|
|
380
662
|
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
|
|
381
663
|
})
|
|
382
664
|
|
|
383
665
|
it('counts the window from when the question was sent, not from the turn end', async () => {
|
|
384
666
|
// Asked 290s ago while the agent kept working: only 10s of wait remains.
|
|
385
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
667
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
668
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
386
669
|
pending(h, 'g2', 290_000)
|
|
387
670
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g2' }))
|
|
388
671
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
389
672
|
expect(h.deps.now?.()).toBeLessThan(NOW + 60_000)
|
|
390
673
|
})
|
|
391
674
|
|
|
392
|
-
it('
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
h
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
expect(h.recorder.submitted).toHaveLength(0)
|
|
404
|
-
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
675
|
+
it('waits the window out on a machine with no idle signal at all', async () => {
|
|
676
|
+
// The timer once refused to run without an idle source to watch, because
|
|
677
|
+
// it was holding a terminal it could not monitor. It holds nothing now.
|
|
678
|
+
const h = harness([])
|
|
679
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
|
|
680
|
+
registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
|
|
681
|
+
|
|
682
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
683
|
+
|
|
684
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
685
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
405
686
|
})
|
|
406
687
|
|
|
407
|
-
it('
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
expect(h.
|
|
688
|
+
it('is not a way to switch escalation on when the user has switched it off', async () => {
|
|
689
|
+
// ask_notifications is the "do not reach me" switch and outranks the timer.
|
|
690
|
+
const h = harness([])
|
|
691
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\nask_notifications = false\n')
|
|
692
|
+
registerQuestion('present4', h.env, { question: 'Ship it?' })
|
|
693
|
+
|
|
694
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
|
|
695
|
+
|
|
696
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
416
697
|
})
|
|
417
698
|
|
|
418
|
-
it('never lets the window crowd out
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
path.join(dir, 'config.toml'),
|
|
427
|
-
'ask_grace_seconds = 540\nhook_reply_timeout_seconds = 540\n',
|
|
428
|
-
)
|
|
699
|
+
it('never lets the longest window crowd out a reply window the server accepts', async () => {
|
|
700
|
+
// The maximum grace a user can configure still has to leave the protocol's
|
|
701
|
+
// 60s minimum inside the waiter ceiling. Real timers do not wake on the
|
|
702
|
+
// exact requested millisecond, so the sleep deliberately overshoots.
|
|
703
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
704
|
+
const virtualSleep = h.deps.sleep!
|
|
705
|
+
h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
|
|
706
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 360\n')
|
|
429
707
|
pending(h, 'g5', 0)
|
|
430
708
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
|
|
431
709
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
432
|
-
expect(h.deps.now?.()).
|
|
710
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
|
|
711
|
+
expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
|
|
712
|
+
expect(
|
|
713
|
+
h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
|
|
714
|
+
?.expires_in_seconds,
|
|
715
|
+
).toBeGreaterThanOrEqual(60)
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('charges transport overrun and close time to the one waiter ceiling', async () => {
|
|
719
|
+
const h = harness([])
|
|
720
|
+
const factory = h.deps.clientFactory
|
|
721
|
+
h.deps.clientFactory = () => {
|
|
722
|
+
const client = factory!()
|
|
723
|
+
return {
|
|
724
|
+
...client,
|
|
725
|
+
listDevices: async () => {
|
|
726
|
+
h.advanceClock(20_000)
|
|
727
|
+
return client.listDevices()
|
|
728
|
+
},
|
|
729
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
|
|
730
|
+
h.advanceClock(20_000)
|
|
731
|
+
return client.submit(body, waitSeconds)
|
|
732
|
+
},
|
|
733
|
+
replies: async (
|
|
734
|
+
requestId: string,
|
|
735
|
+
options: { waitSeconds: number; afterSeq: number },
|
|
736
|
+
) => {
|
|
737
|
+
// Server hold plus the client's 20s AbortSignal allowance.
|
|
738
|
+
h.advanceClock((options.waitSeconds + 20) * 1000)
|
|
739
|
+
return {
|
|
740
|
+
request_id: requestId,
|
|
741
|
+
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
742
|
+
replies: [],
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
closeReplies: async (requestId: string) => {
|
|
746
|
+
h.advanceClock(20_000)
|
|
747
|
+
return client.closeReplies(requestId)
|
|
748
|
+
},
|
|
749
|
+
} as ApiClient
|
|
750
|
+
}
|
|
751
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 250\n')
|
|
752
|
+
pending(h, 'transport-budget', 0)
|
|
753
|
+
|
|
754
|
+
await hookRunCommand(h.deps, 'stop', async () => {
|
|
755
|
+
// stdin/config/setup belong to the same invocation budget as polling.
|
|
756
|
+
h.advanceClock(30_000)
|
|
757
|
+
return JSON.stringify({ session_id: 'transport-budget' })
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// The waiter's 480s ceiling is the last moment it will accept an answer;
|
|
761
|
+
// closing the window and retiring the card happens after it, which is what
|
|
762
|
+
// the blocking hosts' extra minute of declared headroom is there for.
|
|
763
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 540_000)
|
|
764
|
+
expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
|
|
433
765
|
})
|
|
434
766
|
})
|
|
435
767
|
|
|
@@ -465,7 +797,7 @@ describe('nagging guards', () => {
|
|
|
465
797
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
466
798
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n1' }))
|
|
467
799
|
|
|
468
|
-
expect(h.recorder.submitted).toHaveLength(1)
|
|
800
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
469
801
|
})
|
|
470
802
|
|
|
471
803
|
it('respects the harness recursion guard', async () => {
|
|
@@ -480,7 +812,7 @@ describe('nagging guards', () => {
|
|
|
480
812
|
})
|
|
481
813
|
|
|
482
814
|
it('delivers a new question registered during an answer continuation', async () => {
|
|
483
|
-
const h = harness([reply({ text: 'First answer' })]
|
|
815
|
+
const h = harness([reply({ text: 'First answer' })])
|
|
484
816
|
writeSessionState('n3', h.env, { last_prompt_at: AWAY })
|
|
485
817
|
registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
|
|
486
818
|
|
|
@@ -500,7 +832,7 @@ describe('nagging guards', () => {
|
|
|
500
832
|
})
|
|
501
833
|
|
|
502
834
|
it('stops chained questions at the consecutive continuation cap', async () => {
|
|
503
|
-
const h = harness([]
|
|
835
|
+
const h = harness([])
|
|
504
836
|
writeSessionState('n4', h.env, {
|
|
505
837
|
last_prompt_at: AWAY,
|
|
506
838
|
continuation: { answered_at: NOW - 1, count: 3 },
|
|
@@ -513,24 +845,43 @@ describe('nagging guards', () => {
|
|
|
513
845
|
expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
|
|
514
846
|
expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
|
|
515
847
|
})
|
|
848
|
+
|
|
849
|
+
it('starts the consecutive continuation count over when the user takes a turn', async () => {
|
|
850
|
+
// The cap bounds an agent running on its own. A human at the keyboard is
|
|
851
|
+
// the end of that chain, not another link in it.
|
|
852
|
+
const h = harness([])
|
|
853
|
+
writeSessionState('n5', h.env, {
|
|
854
|
+
continuation: { answered_at: NOW - 1, count: MAX_CONTINUATION_COUNT },
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'n5' }))
|
|
858
|
+
|
|
859
|
+
expect(readSessionState('n5', h.env).continuation).toEqual({
|
|
860
|
+
answered_at: NOW - 1,
|
|
861
|
+
count: 0,
|
|
862
|
+
})
|
|
863
|
+
})
|
|
516
864
|
})
|
|
517
865
|
|
|
518
866
|
describe('late answer collection', () => {
|
|
519
|
-
it('collects
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
867
|
+
it('collects an answer that arrived during the resumed model turn', async () => {
|
|
868
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
869
|
+
writeSessionState('late-stop', h.env, {
|
|
870
|
+
last_prompt_at: AWAY,
|
|
871
|
+
pending: [{
|
|
872
|
+
question: 'Ship it?',
|
|
873
|
+
asked_at: NOW,
|
|
874
|
+
request_id: 'req_live',
|
|
875
|
+
collapse_key: 'collapse-live',
|
|
876
|
+
device_ids: ['dev_iphone'],
|
|
877
|
+
reply_deadline_at: NOW + 60_000,
|
|
878
|
+
}],
|
|
879
|
+
})
|
|
529
880
|
|
|
530
881
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-stop' }))
|
|
531
882
|
|
|
532
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(
|
|
533
|
-
expect(h.recorder.closed).toContain('
|
|
883
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
|
|
884
|
+
expect(h.recorder.closed).toContain('req_live')
|
|
534
885
|
expect(readSessionState('late-stop', h.env).pending).toBeUndefined()
|
|
535
886
|
expect(h.io.outLines).toHaveLength(1)
|
|
536
887
|
expect(JSON.parse(h.io.outLines[0] ?? '{}')).toMatchObject({
|
|
@@ -540,13 +891,18 @@ describe('late answer collection', () => {
|
|
|
540
891
|
})
|
|
541
892
|
|
|
542
893
|
it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
894
|
+
const h = harness([reply({ text: 'Hold' })])
|
|
895
|
+
writeSessionState('late-prompt', h.env, {
|
|
896
|
+
last_prompt_at: AWAY,
|
|
897
|
+
pending: [{
|
|
898
|
+
question: 'Ship it?',
|
|
899
|
+
asked_at: NOW,
|
|
900
|
+
request_id: 'req_live',
|
|
901
|
+
collapse_key: 'collapse-live',
|
|
902
|
+
device_ids: ['dev_iphone'],
|
|
903
|
+
reply_deadline_at: NOW + 60_000,
|
|
904
|
+
}],
|
|
905
|
+
})
|
|
550
906
|
|
|
551
907
|
await hookRunCommand(
|
|
552
908
|
h.deps,
|
|
@@ -554,71 +910,302 @@ describe('late answer collection', () => {
|
|
|
554
910
|
stdin({ session_id: 'late-prompt', cwd: h.deps.cwd }),
|
|
555
911
|
)
|
|
556
912
|
|
|
913
|
+
expect(readSessionState('late-prompt', h.env).accepted?.answers[0]?.reply.text).toBe('Hold')
|
|
914
|
+
expect(h.io.outLines).toEqual([])
|
|
915
|
+
|
|
916
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
|
|
917
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
|
|
918
|
+
await hookRunCommand(
|
|
919
|
+
h.deps,
|
|
920
|
+
'stop',
|
|
921
|
+
stdin({ session_id: 'late-prompt', stop_hook_active: true }),
|
|
922
|
+
)
|
|
923
|
+
|
|
557
924
|
const retirement = h.recorder.submitted.find(
|
|
558
925
|
(entry) => entry.draft.event === 'question_retired',
|
|
559
926
|
)?.draft
|
|
560
927
|
expect(retirement?.lifecycle).toMatchObject({
|
|
561
928
|
state: 'answered',
|
|
562
|
-
retires_request_id: '
|
|
929
|
+
retires_request_id: 'req_live',
|
|
563
930
|
})
|
|
564
931
|
expect(retirement?.lifecycle?.state).not.toBe('answered_elsewhere')
|
|
565
932
|
expect(readSessionState('late-prompt', h.env).pending).toBeUndefined()
|
|
566
|
-
expect(
|
|
567
|
-
hookSpecificOutput: {
|
|
568
|
-
hookEventName: 'UserPromptSubmit',
|
|
569
|
-
additionalContext: expect.stringContaining('Hold'),
|
|
570
|
-
},
|
|
571
|
-
})
|
|
933
|
+
expect(readSessionState('late-prompt', h.env).accepted).toBeUndefined()
|
|
572
934
|
})
|
|
573
935
|
})
|
|
574
936
|
|
|
575
|
-
describe('OpenCode answer
|
|
576
|
-
it('
|
|
577
|
-
const h = harness([reply({ text: 'Approve' })]
|
|
937
|
+
describe('OpenCode answer continuation', () => {
|
|
938
|
+
it('fails closed instead of accepting an answer the idle event cannot deliver', async () => {
|
|
939
|
+
const h = harness([reply({ text: 'Approve' })])
|
|
578
940
|
writeSessionState('open1', h.env, { last_prompt_at: AWAY })
|
|
579
941
|
registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
|
|
580
942
|
|
|
581
943
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'open1' }), 'opencode')
|
|
582
944
|
|
|
583
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(
|
|
945
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(0)
|
|
584
946
|
expect(h.recorder.closed).toEqual([])
|
|
585
947
|
expect(
|
|
586
948
|
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
|
-
})
|
|
949
|
+
).toHaveLength(0)
|
|
950
|
+
expect(readSessionState('open1', h.env).pending).toBeUndefined()
|
|
593
951
|
expect(h.io.outLines).toEqual([])
|
|
952
|
+
expect(h.io.errLines.join('\n')).toContain('no proven answer continuation')
|
|
594
953
|
})
|
|
595
954
|
})
|
|
596
955
|
|
|
597
|
-
describe('
|
|
598
|
-
it('
|
|
599
|
-
const h = harness([]
|
|
600
|
-
|
|
601
|
-
h.deps.
|
|
602
|
-
|
|
603
|
-
return
|
|
956
|
+
describe('the waiter owning one question to the end', () => {
|
|
957
|
+
it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
|
|
958
|
+
const h = harness([])
|
|
959
|
+
const factory = h.deps.clientFactory
|
|
960
|
+
h.deps.clientFactory = () => {
|
|
961
|
+
const client = factory!()
|
|
962
|
+
return {
|
|
963
|
+
...client,
|
|
964
|
+
replies: async (requestId: string) => ({
|
|
965
|
+
request_id: requestId,
|
|
966
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
967
|
+
replies: [],
|
|
968
|
+
}),
|
|
969
|
+
closeReplies: async (requestId: string) => {
|
|
970
|
+
h.recorder.closed.push(requestId)
|
|
971
|
+
return {
|
|
972
|
+
request_id: requestId,
|
|
973
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
974
|
+
replies: [reply({ text: 'Committed at the fence' })],
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
} as ApiClient
|
|
978
|
+
}
|
|
979
|
+
writeSessionState('fenced-reply', h.env, { last_prompt_at: AWAY })
|
|
980
|
+
registerQuestion('fenced-reply', h.env, { question: 'Deploy?' }, NOW)
|
|
981
|
+
|
|
982
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fenced-reply' }))
|
|
983
|
+
|
|
984
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain(
|
|
985
|
+
'Committed at the fence',
|
|
986
|
+
)
|
|
987
|
+
expect(readSessionState('fenced-reply', h.env).accepted).toBeDefined()
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
it('preserves a silent question when the close fence cannot be proven', async () => {
|
|
991
|
+
const h = harness([])
|
|
992
|
+
const factory = h.deps.clientFactory
|
|
993
|
+
h.deps.clientFactory = () => {
|
|
994
|
+
const client = factory!()
|
|
995
|
+
return {
|
|
996
|
+
...client,
|
|
997
|
+
closeReplies: async () => {
|
|
998
|
+
throw new Error('partitioned during close')
|
|
999
|
+
},
|
|
1000
|
+
} as ApiClient
|
|
604
1001
|
}
|
|
605
|
-
writeSessionState('
|
|
606
|
-
registerQuestion('
|
|
1002
|
+
writeSessionState('unproven-close', h.env, { last_prompt_at: AWAY })
|
|
1003
|
+
registerQuestion('unproven-close', h.env, { question: 'Deploy?' }, NOW)
|
|
607
1004
|
|
|
608
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1005
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'unproven-close' }))
|
|
609
1006
|
|
|
610
|
-
|
|
611
|
-
expect(h.recorder.
|
|
612
|
-
expect(
|
|
613
|
-
expect(
|
|
614
|
-
|
|
615
|
-
|
|
1007
|
+
const state = readSessionState('unproven-close', h.env)
|
|
1008
|
+
expect(state.pending?.[0]?.request_id).toBe(h.recorder.receipts[0])
|
|
1009
|
+
expect(state.retiring).toBeUndefined()
|
|
1010
|
+
expect(h.io.errLines.join('\n')).toContain('preserving ownership')
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
it('recovers an accepted question after its submit response is lost', async () => {
|
|
1014
|
+
const h = harness([])
|
|
1015
|
+
const factory = h.deps.clientFactory
|
|
1016
|
+
let acceptedRequestId: string | undefined
|
|
1017
|
+
h.deps.clientFactory = () => {
|
|
1018
|
+
const client = factory!()
|
|
1019
|
+
return {
|
|
1020
|
+
...client,
|
|
1021
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1022
|
+
if (body.draft.event !== 'agent_question') return client.submit(body, 0)
|
|
1023
|
+
acceptedRequestId = body.request_id
|
|
1024
|
+
h.recorder.submitted.push(body)
|
|
1025
|
+
h.recorder.receipts.push(body.request_id!)
|
|
1026
|
+
h.recorder.aliases.set(body.request_id!, 1)
|
|
1027
|
+
throw new Error('response lost after commit')
|
|
1028
|
+
},
|
|
1029
|
+
replies: async (requestId: string) => ({
|
|
1030
|
+
request_id: requestId,
|
|
1031
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1032
|
+
replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
|
|
1033
|
+
}),
|
|
1034
|
+
closeReplies: async (requestId: string) => ({
|
|
1035
|
+
request_id: requestId,
|
|
1036
|
+
reply_expires_at: new Date(NOW).toISOString(),
|
|
1037
|
+
replies: requestId === acceptedRequestId ? [reply({ text: 'Yes, recover it' })] : [],
|
|
1038
|
+
}),
|
|
1039
|
+
} as ApiClient
|
|
1040
|
+
}
|
|
1041
|
+
writeSessionState('ambiguous-submit', h.env, { last_prompt_at: AWAY })
|
|
1042
|
+
registerQuestion('ambiguous-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1043
|
+
|
|
1044
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ambiguous-submit' }))
|
|
1045
|
+
|
|
1046
|
+
expect(acceptedRequestId).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1047
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Yes, recover it')
|
|
1048
|
+
expect(readSessionState('ambiguous-submit', h.env).accepted?.answers[0]?.pending.request_id)
|
|
1049
|
+
.toBe(acceptedRequestId)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
it('replays the frozen intent when an ambiguous submit never reached the server', async () => {
|
|
1053
|
+
const h = harness([])
|
|
1054
|
+
const factory = h.deps.clientFactory
|
|
1055
|
+
let firstAttempt: SubmitNotificationRequestT | undefined
|
|
1056
|
+
h.deps.clientFactory = () => {
|
|
1057
|
+
const client = factory!()
|
|
1058
|
+
return {
|
|
1059
|
+
...client,
|
|
1060
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1061
|
+
firstAttempt = body
|
|
1062
|
+
throw new Error('connection failed before commit')
|
|
1063
|
+
},
|
|
1064
|
+
replies: async () => {
|
|
1065
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1066
|
+
},
|
|
1067
|
+
closeReplies: async () => {
|
|
1068
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1069
|
+
},
|
|
1070
|
+
} as ApiClient
|
|
1071
|
+
}
|
|
1072
|
+
writeSessionState('precommit-loss', h.env, { last_prompt_at: AWAY })
|
|
1073
|
+
registerQuestion('precommit-loss', h.env, { question: 'Deploy?' }, NOW)
|
|
1074
|
+
|
|
1075
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
|
|
1076
|
+
|
|
1077
|
+
const stranded = readSessionState('precommit-loss', h.env).pending?.[0]
|
|
1078
|
+
expect(stranded?.request_id).toBeUndefined()
|
|
1079
|
+
expect(stranded?.submission).toBeDefined()
|
|
1080
|
+
expect(h.io.errLines.join('\n')).toContain('response was ambiguous')
|
|
1081
|
+
|
|
1082
|
+
h.advanceClock(600_000)
|
|
1083
|
+
h.deps.clientFactory = factory
|
|
1084
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'precommit-loss' }))
|
|
1085
|
+
|
|
1086
|
+
expect(h.recorder.submitted[0]).toEqual(firstAttempt)
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
|
|
1090
|
+
const h = harness([])
|
|
1091
|
+
const factory = h.deps.clientFactory
|
|
1092
|
+
let polls = 0
|
|
1093
|
+
h.deps.clientFactory = () => {
|
|
1094
|
+
const client = factory!()
|
|
1095
|
+
return {
|
|
1096
|
+
...client,
|
|
1097
|
+
submit: async () => {
|
|
1098
|
+
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1099
|
+
},
|
|
1100
|
+
replies: async () => {
|
|
1101
|
+
polls += 1
|
|
1102
|
+
throw new Error('must not poll an unaccepted request')
|
|
1103
|
+
},
|
|
1104
|
+
} as ApiClient
|
|
1105
|
+
}
|
|
1106
|
+
writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
|
|
1107
|
+
registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1108
|
+
|
|
1109
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1110
|
+
|
|
1111
|
+
const pending = readSessionState('rejected-submit', h.env).pending?.[0]
|
|
1112
|
+
expect(pending?.request_id).toBeUndefined()
|
|
1113
|
+
expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1114
|
+
expect(polls).toBe(0)
|
|
1115
|
+
expect(h.io.errLines.join('\n')).toContain('submission was rejected')
|
|
1116
|
+
|
|
1117
|
+
const frozen = pending?.submission
|
|
1118
|
+
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1119
|
+
writeGlobalConfig(h, 'project = "changed-after-crash"\n')
|
|
1120
|
+
h.advanceClock(600_000)
|
|
1121
|
+
h.deps.clientFactory = factory
|
|
1122
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1123
|
+
|
|
1124
|
+
expect(h.recorder.submitted[0]).toEqual({
|
|
1125
|
+
request_id: frozen.request_id,
|
|
1126
|
+
idempotency_key: frozen.idempotency_key,
|
|
1127
|
+
draft: frozen.draft,
|
|
1128
|
+
})
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
it('closes and retires silence at the absolute owner deadline', async () => {
|
|
1132
|
+
const h = harness([])
|
|
1133
|
+
const factory = h.deps.clientFactory
|
|
1134
|
+
h.deps.clientFactory = () => {
|
|
1135
|
+
const client = factory!()
|
|
1136
|
+
return {
|
|
1137
|
+
...client,
|
|
1138
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => ({
|
|
1139
|
+
...(await client.submit(body, waitSeconds)),
|
|
1140
|
+
// The real server commits the configured reply window, rather than
|
|
1141
|
+
// the generic recorder's deliberately broad 480s fixture.
|
|
1142
|
+
reply_expires_at: new Date(NOW + 60_000).toISOString(),
|
|
1143
|
+
}),
|
|
1144
|
+
} as ApiClient
|
|
1145
|
+
}
|
|
1146
|
+
writeSessionState('deadline', h.env, { last_prompt_at: AWAY })
|
|
1147
|
+
registerQuestion('deadline', h.env, { question: 'Deploy?' }, NOW)
|
|
1148
|
+
|
|
1149
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'deadline' }))
|
|
1150
|
+
|
|
1151
|
+
expect(h.deps.now?.()).toBe(NOW + 60_000)
|
|
1152
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1153
|
+
expect(readSessionState('deadline', h.env).pending).toBeUndefined()
|
|
1154
|
+
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
it('charges sequential submission latency to one deadline', async () => {
|
|
1158
|
+
const h = harness([reply({ text: 'Done' })])
|
|
1159
|
+
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
1160
|
+
registerQuestion('latency', h.env, { question: 'First?' }, NOW)
|
|
1161
|
+
registerQuestion('latency', h.env, { question: 'Second?' }, NOW)
|
|
1162
|
+
h.recorder.beforeQuestionSubmit = () => h.advanceClock(40_000)
|
|
1163
|
+
|
|
1164
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latency' }))
|
|
1165
|
+
|
|
1166
|
+
const windows = h.recorder.submitted
|
|
1167
|
+
.filter((entry) => entry.draft.event === 'agent_question')
|
|
1168
|
+
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1169
|
+
// Both windows end at the same absolute moment: the second question's is
|
|
1170
|
+
// shorter by exactly the 40s the first submission spent.
|
|
1171
|
+
expect(windows).toEqual([480, 440])
|
|
1172
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
it('re-arms the next Stop for an independently pending answer', async () => {
|
|
1176
|
+
const h = harness([])
|
|
1177
|
+
const answers = new Map<string, ReplyView[]>([
|
|
1178
|
+
['req_hook_1', [reply({ text: 'First answer' })]],
|
|
1179
|
+
['req_hook_2', []],
|
|
1180
|
+
])
|
|
1181
|
+
h.recorder.repliesFor = (requestId) => answers.get(requestId) ?? []
|
|
1182
|
+
writeSessionState('successor', h.env, { last_prompt_at: AWAY })
|
|
1183
|
+
registerQuestion('successor', h.env, { question: 'First?' }, NOW)
|
|
1184
|
+
registerQuestion('successor', h.env, { question: 'Second?' }, NOW)
|
|
1185
|
+
|
|
1186
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'successor' }))
|
|
1187
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('First answer')
|
|
1188
|
+
expect(readSessionState('successor', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
1189
|
+
'Second?',
|
|
1190
|
+
])
|
|
1191
|
+
|
|
1192
|
+
answers.set('req_hook_2', [reply({ text: 'Second answer' })])
|
|
1193
|
+
h.io.outLines = []
|
|
1194
|
+
await hookRunCommand(
|
|
1195
|
+
h.deps,
|
|
1196
|
+
'stop',
|
|
1197
|
+
stdin({ session_id: 'successor', stop_hook_active: true }),
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Second answer')
|
|
1201
|
+
expect(readSessionState('successor', h.env).pending).toBeUndefined()
|
|
1202
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(2)
|
|
616
1203
|
})
|
|
617
1204
|
})
|
|
618
1205
|
|
|
619
1206
|
describe('Cursor stop output', () => {
|
|
620
|
-
it('
|
|
621
|
-
const h = harness([reply({ text: 'Ship it' })]
|
|
1207
|
+
it('fails closed when the invoking shell cannot name the exact conversation', async () => {
|
|
1208
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
622
1209
|
writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
|
|
623
1210
|
registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
|
|
624
1211
|
|
|
@@ -633,12 +1220,9 @@ describe('Cursor stop output', () => {
|
|
|
633
1220
|
'cursor',
|
|
634
1221
|
)
|
|
635
1222
|
|
|
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')
|
|
1223
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
1224
|
+
expect(h.io.outLines).toHaveLength(0)
|
|
1225
|
+
expect(h.io.errLines.join('\n')).toContain('asynchronous ask is unsupported')
|
|
642
1226
|
})
|
|
643
1227
|
})
|
|
644
1228
|
|
|
@@ -661,36 +1245,40 @@ describe('question queueing and retirement debt', () => {
|
|
|
661
1245
|
}))
|
|
662
1246
|
}
|
|
663
1247
|
|
|
664
|
-
|
|
1248
|
+
function seedLive(h: Harness, sessionId: string, requestId = 'req_live'): void {
|
|
1249
|
+
writeSessionState(sessionId, h.env, {
|
|
1250
|
+
last_prompt_at: AWAY,
|
|
1251
|
+
pending: [{
|
|
1252
|
+
question: 'Ship it?',
|
|
1253
|
+
asked_at: NOW,
|
|
1254
|
+
request_id: requestId,
|
|
1255
|
+
collapse_key: 'collapse-live',
|
|
1256
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1257
|
+
reply_deadline_at: NOW + 60_000,
|
|
1258
|
+
}],
|
|
1259
|
+
})
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
it('registers a second question without mutating the live first question', () => {
|
|
665
1263
|
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()
|
|
1264
|
+
seedLive(h, 'sup1')
|
|
671
1265
|
|
|
672
1266
|
// The agent carried on and asked something else. The first question is
|
|
673
1267
|
// still the user's to answer — a second question never ends the first;
|
|
674
1268
|
// superseding is reply semantics, not question semantics.
|
|
675
1269
|
registerQuestion('sup1', h.env, { question: 'Deploy it?' })
|
|
676
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup1' }))
|
|
677
1270
|
|
|
678
1271
|
expect(retirements(h)).toEqual([])
|
|
679
|
-
expect(
|
|
680
|
-
h.recorder.submitted.filter((s) => s.draft.presentation.body === 'Deploy it?'),
|
|
681
|
-
).toHaveLength(1)
|
|
682
1272
|
const live = readSessionState('sup1', h.env).pending
|
|
683
1273
|
expect(live).toHaveLength(2)
|
|
684
1274
|
expect(live?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
|
|
685
|
-
expect(live?.
|
|
1275
|
+
expect(live?.map((entry) => entry.request_id)).toEqual(['req_live', undefined])
|
|
686
1276
|
})
|
|
687
1277
|
|
|
688
1278
|
it('keeps the ids when the retirement cannot be sent, and retries later', async () => {
|
|
689
1279
|
const h = harness([])
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup2' }))
|
|
693
|
-
const first = h.recorder.receipts[0]
|
|
1280
|
+
seedLive(h, 'sup2')
|
|
1281
|
+
const first = 'req_live'
|
|
694
1282
|
|
|
695
1283
|
// Offline when the user returns: the wipe parks the retirement, the
|
|
696
1284
|
// drain fails, and it must not forget what it was for.
|
|
@@ -706,13 +1294,11 @@ describe('question queueing and retirement debt', () => {
|
|
|
706
1294
|
|
|
707
1295
|
it('keeps the original delivery targets when routing changes before retirement', async () => {
|
|
708
1296
|
const h = harness([])
|
|
709
|
-
|
|
710
|
-
registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
|
|
711
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
1297
|
+
seedLive(h, 'sup-targets')
|
|
712
1298
|
|
|
713
1299
|
const live = readSessionState('sup-targets', h.env).pending?.[0]
|
|
714
1300
|
expect(live).toMatchObject({
|
|
715
|
-
request_id: '
|
|
1301
|
+
request_id: 'req_live',
|
|
716
1302
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
717
1303
|
})
|
|
718
1304
|
|
|
@@ -727,7 +1313,7 @@ describe('question queueing and retirement debt', () => {
|
|
|
727
1313
|
const retirement = h.recorder.submitted.find(
|
|
728
1314
|
(submission) => submission.draft.event === 'question_retired',
|
|
729
1315
|
)
|
|
730
|
-
expect(retirement?.draft.lifecycle?.retires_request_id).toBe('
|
|
1316
|
+
expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_live')
|
|
731
1317
|
expect(retirement?.draft.targets).toEqual({
|
|
732
1318
|
mode: 'selected',
|
|
733
1319
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
@@ -739,10 +1325,8 @@ describe('question queueing and retirement debt', () => {
|
|
|
739
1325
|
// before every guard — retirement debt has nothing to do with whether
|
|
740
1326
|
// this turn may ask.
|
|
741
1327
|
const h = harness([])
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup3' }))
|
|
745
|
-
const first = h.recorder.receipts[0]
|
|
1328
|
+
seedLive(h, 'sup3')
|
|
1329
|
+
const first = 'req_live'
|
|
746
1330
|
|
|
747
1331
|
h.recorder.failSubmits = true
|
|
748
1332
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'sup3' }))
|
|
@@ -759,10 +1343,8 @@ describe('question queueing and retirement debt', () => {
|
|
|
759
1343
|
// UserPromptSubmit resets session state to record presence, and that reset
|
|
760
1344
|
// used to take the retirement queue with it.
|
|
761
1345
|
const h = harness([])
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup4' }))
|
|
765
|
-
const first = h.recorder.receipts[0]
|
|
1346
|
+
seedLive(h, 'sup4')
|
|
1347
|
+
const first = 'req_live'
|
|
766
1348
|
|
|
767
1349
|
// The first return is offline: the wipe parks, the drain fails, and the
|
|
768
1350
|
// next prompt must still find the debt.
|
|
@@ -795,22 +1377,11 @@ describe('question queueing and retirement debt', () => {
|
|
|
795
1377
|
describe('several questions in flight', () => {
|
|
796
1378
|
/** Route each live request its own replies; the shared list cannot say who answered what. */
|
|
797
1379
|
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
|
-
}
|
|
1380
|
+
h.recorder.repliesFor = (requestId) => byRequest.get(requestId) ?? []
|
|
810
1381
|
}
|
|
811
1382
|
|
|
812
1383
|
it('escalates every registered question in one pass, each as its own notification', async () => {
|
|
813
|
-
const h = harness([]
|
|
1384
|
+
const h = harness([])
|
|
814
1385
|
writeSessionState('multi1', h.env, { last_prompt_at: AWAY })
|
|
815
1386
|
registerQuestion('multi1', h.env, { question: 'Ship it?' }, NOW)
|
|
816
1387
|
registerQuestion('multi1', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
@@ -822,12 +1393,159 @@ describe('several questions in flight', () => {
|
|
|
822
1393
|
// Each is its own notification with its own collapse key — one ask never
|
|
823
1394
|
// stands in for, or replaces, another.
|
|
824
1395
|
expect(new Set(questions.map((s) => s.draft.delivery.collapse_key)).size).toBe(2)
|
|
825
|
-
|
|
826
|
-
expect(
|
|
1396
|
+
expect(readSessionState('multi1', h.env).pending).toBeUndefined()
|
|
1397
|
+
expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
|
|
1398
|
+
})
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* A registered form/ask must become a durable server request at turn-end.
|
|
1402
|
+
* Codex and Claude both enter through the same Stop
|
|
1403
|
+
* handler; the harness flag only changes answer injection, not settlement.
|
|
1404
|
+
*/
|
|
1405
|
+
it('settles a registered form into a durable request_id on the Codex Stop path', async () => {
|
|
1406
|
+
const h = harness([])
|
|
1407
|
+
writeSessionState('codex-form', h.env, { last_prompt_at: AWAY })
|
|
1408
|
+
const built = buildQuestions(
|
|
1409
|
+
{ form: JSON.stringify({
|
|
1410
|
+
questions: [
|
|
1411
|
+
{ text: 'Start personally?', choices: ['Yes', 'No'] },
|
|
1412
|
+
{ text: 'Which region?', choices: ['US', 'EU'] },
|
|
1413
|
+
],
|
|
1414
|
+
}) },
|
|
1415
|
+
undefined,
|
|
1416
|
+
)
|
|
1417
|
+
expect(built.ok).toBe(true)
|
|
1418
|
+
if (!built.ok) return
|
|
1419
|
+
registerQuestion('codex-form', h.env, {
|
|
1420
|
+
question: built.questions[0]!.text,
|
|
1421
|
+
questions: built.questions,
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
// Registration alone must not submit; durability happens at turn-end.
|
|
1425
|
+
expect(h.recorder.submitted).toEqual([])
|
|
1426
|
+
expect(readSessionState('codex-form', h.env).pending?.[0]?.request_id).toBeUndefined()
|
|
1427
|
+
|
|
1428
|
+
await hookRunCommand(
|
|
1429
|
+
h.deps,
|
|
1430
|
+
'stop',
|
|
1431
|
+
stdin({ session_id: 'codex-form' }),
|
|
1432
|
+
'codex',
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
expect(h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')).toHaveLength(1)
|
|
1436
|
+
expect(readSessionState('codex-form', h.env).pending).toBeUndefined()
|
|
1437
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1438
|
+
expect(h.io.errLines.join('\n')).toMatch(/expired with its continuation owner/)
|
|
1439
|
+
})
|
|
1440
|
+
|
|
1441
|
+
it('keeps a settled ask durable and collects the answer after a transient internal_error', async () => {
|
|
1442
|
+
const h = harness([])
|
|
1443
|
+
let polls = 0
|
|
1444
|
+
const factory = h.deps.clientFactory
|
|
1445
|
+
h.deps.clientFactory = () => {
|
|
1446
|
+
const client = factory!()
|
|
1447
|
+
return {
|
|
1448
|
+
...client,
|
|
1449
|
+
replies: async (requestId: string) => {
|
|
1450
|
+
polls += 1
|
|
1451
|
+
if (polls <= 2) {
|
|
1452
|
+
throw new ApiCallError(500, 'internal_error', 'An unexpected server error occurred.')
|
|
1453
|
+
}
|
|
1454
|
+
return {
|
|
1455
|
+
request_id: requestId,
|
|
1456
|
+
reply_expires_at: null,
|
|
1457
|
+
replies: [reply({ text: 'Yes — start personally' })],
|
|
1458
|
+
} satisfies ListRepliesResponse
|
|
1459
|
+
},
|
|
1460
|
+
} as ApiClient
|
|
1461
|
+
}
|
|
1462
|
+
writeSessionState('recover-500', h.env, { last_prompt_at: AWAY })
|
|
1463
|
+
registerQuestion('recover-500', h.env, { question: 'Start personally?' }, NOW - 300_000)
|
|
1464
|
+
|
|
1465
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'recover-500' }), 'codex')
|
|
1466
|
+
|
|
1467
|
+
// Submit recorded the durable id before any wait; a 500 must not erase it
|
|
1468
|
+
// mid-flight, and recovery must still surface the late answer.
|
|
1469
|
+
const questions = h.recorder.submitted.filter((s) => s.draft.event === 'agent_question')
|
|
1470
|
+
expect(questions).toHaveLength(1)
|
|
1471
|
+
expect(h.recorder.receipts[0]).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1472
|
+
expect(polls).toBeGreaterThanOrEqual(3)
|
|
1473
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1474
|
+
expect(readSessionState('recover-500', h.env).pending).toBeUndefined()
|
|
1475
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
1476
|
+
expect(output.decision).toBe('block')
|
|
1477
|
+
expect(output.reason).toContain('Yes — start personally')
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
it('stops retrying and names a permanent rejection during the blocking multi-wait', async () => {
|
|
1481
|
+
const h = harness([])
|
|
1482
|
+
let polls = 0
|
|
1483
|
+
const factory = h.deps.clientFactory
|
|
1484
|
+
h.deps.clientFactory = () => {
|
|
1485
|
+
const client = factory!()
|
|
1486
|
+
return {
|
|
1487
|
+
...client,
|
|
1488
|
+
replies: async () => {
|
|
1489
|
+
polls += 1
|
|
1490
|
+
throw new ApiCallError(404, 'not_found', 'No such request.')
|
|
1491
|
+
},
|
|
1492
|
+
} as ApiClient
|
|
1493
|
+
}
|
|
1494
|
+
writeSessionState('permanent-wait', h.env, { last_prompt_at: AWAY })
|
|
1495
|
+
registerQuestion('permanent-wait', h.env, { question: 'Still there?' }, NOW - 300_000)
|
|
1496
|
+
|
|
1497
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-wait' }), 'codex')
|
|
1498
|
+
|
|
1499
|
+
expect(polls).toBe(1)
|
|
1500
|
+
expect(readSessionState('permanent-wait', h.env).pending).toBeUndefined()
|
|
1501
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
1502
|
+
`${h.recorder.receipts[0]}: not_found (HTTP 404): No such request.`,
|
|
1503
|
+
)
|
|
1504
|
+
expect(h.io.errLines.join('\n')).not.toContain('could not reach the server')
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
it('distinguishes a permanent late-poll rejection from transient trouble', async () => {
|
|
1508
|
+
const h = harness([])
|
|
1509
|
+
let polls = 0
|
|
1510
|
+
const factory = h.deps.clientFactory
|
|
1511
|
+
h.deps.clientFactory = () => {
|
|
1512
|
+
const client = factory!()
|
|
1513
|
+
return {
|
|
1514
|
+
...client,
|
|
1515
|
+
replies: async () => {
|
|
1516
|
+
polls += 1
|
|
1517
|
+
throw new ApiCallError(401, 'machine_revoked', 'This machine was revoked.')
|
|
1518
|
+
},
|
|
1519
|
+
} as ApiClient
|
|
1520
|
+
}
|
|
1521
|
+
writeSessionState('permanent-late', h.env, {
|
|
1522
|
+
last_prompt_at: AWAY,
|
|
1523
|
+
pending: [
|
|
1524
|
+
{
|
|
1525
|
+
question: 'Deploy?',
|
|
1526
|
+
asked_at: NOW - 300_000,
|
|
1527
|
+
request_id: 'req_existing',
|
|
1528
|
+
collapse_key: 'question-existing',
|
|
1529
|
+
device_ids: ['dev_iphone'],
|
|
1530
|
+
reply_deadline_at: NOW + 60_000,
|
|
1531
|
+
},
|
|
1532
|
+
],
|
|
1533
|
+
})
|
|
1534
|
+
|
|
1535
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'permanent-late' }), 'codex')
|
|
1536
|
+
|
|
1537
|
+
expect(polls).toBe(1)
|
|
1538
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
1539
|
+
'req_existing: machine_revoked (HTTP 401): This machine was revoked.',
|
|
1540
|
+
)
|
|
1541
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
1542
|
+
'reply polling stopped after a permanent server rejection',
|
|
1543
|
+
)
|
|
1544
|
+
expect(h.io.errLines.join('\n')).not.toContain('could not check whether its answer arrived')
|
|
827
1545
|
})
|
|
828
1546
|
|
|
829
1547
|
it('resumes with every answer that arrived, each tied to its question', async () => {
|
|
830
|
-
const h = harness([]
|
|
1548
|
+
const h = harness([])
|
|
831
1549
|
repliesByRequest(
|
|
832
1550
|
h,
|
|
833
1551
|
new Map([
|
|
@@ -843,7 +1561,7 @@ describe('several questions in flight', () => {
|
|
|
843
1561
|
|
|
844
1562
|
// Both reply windows close immediately (first answer claims each
|
|
845
1563
|
// question); the retirement drain closes again, which is idempotent.
|
|
846
|
-
expect(new Set(h.recorder.closed)).toEqual(new Set(
|
|
1564
|
+
expect(new Set(h.recorder.closed)).toEqual(new Set(h.recorder.receipts))
|
|
847
1565
|
expect(readSessionState('multi2', h.env).pending).toBeUndefined()
|
|
848
1566
|
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
849
1567
|
expect(output.decision).toBe('block')
|
|
@@ -852,8 +1570,51 @@ describe('several questions in flight', () => {
|
|
|
852
1570
|
expect(output.reason).toContain('"Deploy where?" → "Staging"')
|
|
853
1571
|
})
|
|
854
1572
|
|
|
1573
|
+
it('journals an accepted answer until a successor Stop proves delivery', async () => {
|
|
1574
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1575
|
+
writeSessionState('answer-handoff', h.env, {
|
|
1576
|
+
last_prompt_at: AWAY,
|
|
1577
|
+
pending: [
|
|
1578
|
+
{
|
|
1579
|
+
question: 'Ship it?',
|
|
1580
|
+
asked_at: NOW,
|
|
1581
|
+
request_id: 'req_existing',
|
|
1582
|
+
collapse_key: 'question-existing',
|
|
1583
|
+
device_ids: ['dev_iphone'],
|
|
1584
|
+
reply_deadline_at: NOW + 60_000,
|
|
1585
|
+
},
|
|
1586
|
+
],
|
|
1587
|
+
})
|
|
1588
|
+
|
|
1589
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
|
|
1590
|
+
const staged = readSessionState('answer-handoff', h.env)
|
|
1591
|
+
expect(staged.accepted?.answers[0]?.reply.text).toBe('Ship it')
|
|
1592
|
+
expect(JSON.parse(h.io.outLines.at(-1)!).decision).toBe('block')
|
|
1593
|
+
|
|
1594
|
+
h.io.outLines = []
|
|
1595
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'answer-handoff' }))
|
|
1596
|
+
expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
|
|
1597
|
+
expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
|
|
1598
|
+
|
|
1599
|
+
h.io.outLines = []
|
|
1600
|
+
await hookRunCommand(
|
|
1601
|
+
h.deps,
|
|
1602
|
+
'stop',
|
|
1603
|
+
stdin({ session_id: 'answer-handoff', stop_hook_active: true }),
|
|
1604
|
+
)
|
|
1605
|
+
expect(h.io.outLines).toEqual([])
|
|
1606
|
+
expect(readSessionState('answer-handoff', h.env).accepted).toBeUndefined()
|
|
1607
|
+
expect(
|
|
1608
|
+
h.recorder.submitted.find(
|
|
1609
|
+
(entry) =>
|
|
1610
|
+
entry.draft.event === 'question_retired' &&
|
|
1611
|
+
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
1612
|
+
)?.draft.lifecycle?.state,
|
|
1613
|
+
).toBe('answered')
|
|
1614
|
+
})
|
|
1615
|
+
|
|
855
1616
|
it('resumes with a partial answer and keeps the rest registered', async () => {
|
|
856
|
-
const h = harness([]
|
|
1617
|
+
const h = harness([])
|
|
857
1618
|
const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
|
|
858
1619
|
repliesByRequest(h, byRequest)
|
|
859
1620
|
writeSessionState('multi3', h.env, { last_prompt_at: AWAY })
|
|
@@ -867,7 +1628,7 @@ describe('several questions in flight', () => {
|
|
|
867
1628
|
expect(output.reason).toContain('1 more registered question is still waiting')
|
|
868
1629
|
const live = readSessionState('multi3', h.env).pending
|
|
869
1630
|
expect(live?.map((entry) => entry.question)).toEqual(['Deploy where?'])
|
|
870
|
-
expect(live?.[0]?.request_id).toBe(
|
|
1631
|
+
expect(live?.[0]?.request_id).toBe(h.recorder.receipts[1])
|
|
871
1632
|
|
|
872
1633
|
// The remaining question's answer arrives before the next turn ends; the
|
|
873
1634
|
// late-answer path hands it over without asking anything twice.
|
|
@@ -894,14 +1655,20 @@ describe('a question that outlives its session', () => {
|
|
|
894
1655
|
}))
|
|
895
1656
|
}
|
|
896
1657
|
|
|
897
|
-
/**
|
|
1658
|
+
/** State a still-owned request can have when SessionEnd races its Stop. */
|
|
898
1659
|
async function pushUnanswered(h: Harness, sessionId: string): Promise<string> {
|
|
899
|
-
writeSessionState(sessionId, h.env, {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1660
|
+
writeSessionState(sessionId, h.env, {
|
|
1661
|
+
last_prompt_at: AWAY,
|
|
1662
|
+
pending: [{
|
|
1663
|
+
question: 'Ship it?',
|
|
1664
|
+
asked_at: NOW,
|
|
1665
|
+
request_id: 'req_live',
|
|
1666
|
+
collapse_key: 'collapse-live',
|
|
1667
|
+
device_ids: ['dev_iphone'],
|
|
1668
|
+
reply_deadline_at: NOW + 60_000,
|
|
1669
|
+
}],
|
|
1670
|
+
})
|
|
1671
|
+
return 'req_live'
|
|
905
1672
|
}
|
|
906
1673
|
|
|
907
1674
|
it('retires a question the harness exited on, from a later session', async () => {
|
|
@@ -1008,14 +1775,15 @@ describe('hostile input', () => {
|
|
|
1008
1775
|
expect(seen).toBe('https://test.notifai.invalid')
|
|
1009
1776
|
})
|
|
1010
1777
|
|
|
1011
|
-
it('clamps an out-of-range
|
|
1778
|
+
it('clamps an out-of-range grace window instead of trusting it', () => {
|
|
1012
1779
|
const h = harness()
|
|
1013
1780
|
const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-bounds-'))
|
|
1014
1781
|
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
1015
|
-
writeFileSync(path.join(project, '.notifai', 'config.toml'), '
|
|
1782
|
+
writeFileSync(path.join(project, '.notifai', 'config.toml'), 'ask_grace_seconds = 99999\n')
|
|
1016
1783
|
const config = loadConfig({ cwd: project, env: h.env })
|
|
1017
|
-
//
|
|
1018
|
-
|
|
1784
|
+
// A committed repository file must not be able to consume the waiter's
|
|
1785
|
+
// whole ceiling and leave no window in which an answer is still accepted.
|
|
1786
|
+
expect(config.ask_grace_seconds.value).toBeLessThanOrEqual(360)
|
|
1019
1787
|
})
|
|
1020
1788
|
})
|
|
1021
1789
|
|
|
@@ -1030,12 +1798,10 @@ describe('ask registration', () => {
|
|
|
1030
1798
|
|
|
1031
1799
|
it('stores the validated question set, comma-bearing labels verbatim', () => {
|
|
1032
1800
|
const h = harness()
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}),
|
|
1038
|
-
).toBe(EXIT.ok)
|
|
1801
|
+
const built = buildQuestions({ choice: ['Yes, ship it', 'No, hold'] }, 'Ship it?')
|
|
1802
|
+
expect(built.ok).toBe(true)
|
|
1803
|
+
if (!built.ok) return
|
|
1804
|
+
registerQuestion('a2', h.env, { question: built.questions[0]!.text, questions: built.questions })
|
|
1039
1805
|
expect(readSessionState('a2', h.env).pending?.[0]?.questions).toEqual([
|
|
1040
1806
|
{
|
|
1041
1807
|
id: 'ship-it',
|
|
@@ -1049,20 +1815,25 @@ describe('ask registration', () => {
|
|
|
1049
1815
|
})
|
|
1050
1816
|
|
|
1051
1817
|
it('registers a multi-question form and pushes it as one set', async () => {
|
|
1052
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
1818
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
1053
1819
|
writeSessionState('form1', h.env, { last_prompt_at: AWAY })
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
session: 'form1',
|
|
1057
|
-
form: JSON.stringify({
|
|
1820
|
+
const built = buildQuestions(
|
|
1821
|
+
{ form: JSON.stringify({
|
|
1058
1822
|
questions: [
|
|
1059
1823
|
{ text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
|
|
1060
1824
|
{ text: 'Anything to watch?' },
|
|
1061
1825
|
],
|
|
1062
1826
|
detail: '## Context\nThe long story.',
|
|
1063
|
-
}),
|
|
1064
|
-
|
|
1065
|
-
)
|
|
1827
|
+
}) },
|
|
1828
|
+
undefined,
|
|
1829
|
+
)
|
|
1830
|
+
expect(built.ok).toBe(true)
|
|
1831
|
+
if (!built.ok) return
|
|
1832
|
+
registerQuestion('form1', h.env, {
|
|
1833
|
+
question: built.questions[0]!.text,
|
|
1834
|
+
questions: built.questions,
|
|
1835
|
+
...(built.detail === undefined ? {} : { detail: built.detail }),
|
|
1836
|
+
})
|
|
1066
1837
|
const pending = readSessionState('form1', h.env).pending?.[0]
|
|
1067
1838
|
expect(pending?.questions).toHaveLength(2)
|
|
1068
1839
|
expect(pending?.detail).toContain('long story')
|
|
@@ -1087,6 +1858,34 @@ describe('ask registration', () => {
|
|
|
1087
1858
|
])
|
|
1088
1859
|
})
|
|
1089
1860
|
|
|
1861
|
+
it('relays the real registered identity, question text, and answer without trust or approval claims', async () => {
|
|
1862
|
+
const h = harness(
|
|
1863
|
+
[
|
|
1864
|
+
reply({
|
|
1865
|
+
text: 'BETA',
|
|
1866
|
+
answers: [{ question_id: 'rollout-option', choice_ids: ['beta'], text: null }],
|
|
1867
|
+
}),
|
|
1868
|
+
],
|
|
1869
|
+
900,
|
|
1870
|
+
)
|
|
1871
|
+
writeSessionState('framing1', h.env, { last_prompt_at: AWAY })
|
|
1872
|
+
const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
|
|
1873
|
+
expect(built.ok).toBe(true)
|
|
1874
|
+
if (!built.ok) return
|
|
1875
|
+
registerQuestion('framing1', h.env, {
|
|
1876
|
+
question: 'Which rollout option?',
|
|
1877
|
+
questions: built.questions,
|
|
1878
|
+
})
|
|
1879
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'framing1' }))
|
|
1880
|
+
|
|
1881
|
+
const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
|
|
1882
|
+
expect(decision.decision).toBe('block')
|
|
1883
|
+
expect(decision.reason).toContain('question_id rollout-option')
|
|
1884
|
+
expect(decision.reason).toContain('"Which rollout option?"')
|
|
1885
|
+
expect(decision.reason).toContain('"BETA"')
|
|
1886
|
+
expect(decision.reason).not.toMatch(/trusted|urgent|permission|approval/i)
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1090
1889
|
it('acts on the latest reply when answers conflict, because it is a correction', async () => {
|
|
1091
1890
|
const h = harness(
|
|
1092
1891
|
[
|
|
@@ -1101,9 +1900,10 @@ describe('ask registration', () => {
|
|
|
1101
1900
|
900,
|
|
1102
1901
|
)
|
|
1103
1902
|
writeSessionState('latest1', h.env, { last_prompt_at: AWAY })
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1903
|
+
const built = buildQuestions({ choice: ['Yes', 'No'] }, 'Ship it?')
|
|
1904
|
+
expect(built.ok).toBe(true)
|
|
1905
|
+
if (!built.ok) return
|
|
1906
|
+
registerQuestion('latest1', h.env, { question: 'Ship it?', questions: built.questions })
|
|
1107
1907
|
|
|
1108
1908
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'latest1' }))
|
|
1109
1909
|
|
|
@@ -1127,7 +1927,7 @@ describe('ask registration', () => {
|
|
|
1127
1927
|
900,
|
|
1128
1928
|
)
|
|
1129
1929
|
writeSessionState('parts1', h.env, { last_prompt_at: AWAY })
|
|
1130
|
-
|
|
1930
|
+
registerQuestion('parts1', h.env, { question: 'Which color?' })
|
|
1131
1931
|
|
|
1132
1932
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'parts1' }))
|
|
1133
1933
|
|
|
@@ -1136,27 +1936,17 @@ describe('ask registration', () => {
|
|
|
1136
1936
|
expect(decision.reason).toContain('"Use blue", then "actually teal"')
|
|
1137
1937
|
})
|
|
1138
1938
|
|
|
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.
|
|
1939
|
+
it('does not let an explicit session bypass exact active-harness proof', () => {
|
|
1142
1940
|
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()
|
|
1941
|
+
expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
|
|
1942
|
+
expect(readSessionState('guessed', h.env).pending).toBeUndefined()
|
|
1153
1943
|
})
|
|
1154
1944
|
|
|
1155
|
-
it('
|
|
1945
|
+
it('does not route from NOTIFAI_SESSION where no exact harness has spoken', () => {
|
|
1156
1946
|
const h = harness()
|
|
1157
1947
|
h.deps.env['NOTIFAI_SESSION'] = 'solo-session'
|
|
1158
|
-
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.
|
|
1159
|
-
expect(readSessionState('solo-session', h.env).pending
|
|
1948
|
+
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
1949
|
+
expect(readSessionState('solo-session', h.env).pending).toBeUndefined()
|
|
1160
1950
|
})
|
|
1161
1951
|
|
|
1162
1952
|
it('queues alongside incomplete live state without touching it', () => {
|
|
@@ -1174,7 +1964,7 @@ describe('ask registration', () => {
|
|
|
1174
1964
|
],
|
|
1175
1965
|
})
|
|
1176
1966
|
|
|
1177
|
-
|
|
1967
|
+
registerQuestion('incomplete', h.env, { question: 'Another?' })
|
|
1178
1968
|
const pending = readSessionState('incomplete', h.env).pending
|
|
1179
1969
|
expect(pending?.map((entry) => entry.question)).toEqual(['Original?', 'Another?'])
|
|
1180
1970
|
expect(pending?.[0]).toMatchObject({
|
|
@@ -1186,11 +1976,10 @@ describe('ask registration', () => {
|
|
|
1186
1976
|
it('refuses a fifth question and names the form alternative', () => {
|
|
1187
1977
|
const h = harness()
|
|
1188
1978
|
for (const question of ['One?', 'Two?', 'Three?', 'Four?']) {
|
|
1189
|
-
|
|
1979
|
+
registerQuestion('crowded', h.env, { question })
|
|
1190
1980
|
}
|
|
1191
1981
|
|
|
1192
|
-
expect(
|
|
1193
|
-
expect(h.io.errLines.join('\n')).toContain('ask --form')
|
|
1982
|
+
expect(() => registerQuestion('crowded', h.env, { question: 'Five?' })).toThrow(/ask --form/)
|
|
1194
1983
|
expect(readSessionState('crowded', h.env).pending).toHaveLength(4)
|
|
1195
1984
|
})
|
|
1196
1985
|
})
|
|
@@ -1202,14 +1991,52 @@ describe('user-prompt-submit hook', () => {
|
|
|
1202
1991
|
expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
|
|
1203
1992
|
})
|
|
1204
1993
|
|
|
1205
|
-
it('
|
|
1994
|
+
it('records Stop separately so a prompt hook cannot impersonate turn-end routing', async () => {
|
|
1206
1995
|
const h = harness()
|
|
1996
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'events' }))
|
|
1997
|
+
expect(readSessionState('events', h.env).last_stop_at).toBeUndefined()
|
|
1207
1998
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1999
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'events' }))
|
|
2000
|
+
expect(readSessionState('events', h.env).last_stop_at).toBe(NOW)
|
|
2001
|
+
})
|
|
2002
|
+
|
|
2003
|
+
it('diagnoses partial production input as malformed or truncated', async () => {
|
|
2004
|
+
const h = harness()
|
|
2005
|
+
const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
|
|
2006
|
+
const input = new PassThrough()
|
|
2007
|
+
input.write('{"session_id":')
|
|
2008
|
+
|
|
2009
|
+
expect(
|
|
2010
|
+
await hookRunCommand(
|
|
2011
|
+
deps,
|
|
2012
|
+
'user-prompt-submit',
|
|
2013
|
+
() => readStdinWithTimeout(input, 5),
|
|
2014
|
+
),
|
|
2015
|
+
).toBe(EXIT.ok)
|
|
1211
2016
|
|
|
1212
2017
|
expect(h.io.errLines.join('\n')).toMatch(/malformed|truncated/i)
|
|
2018
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
2019
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
2020
|
+
expect(records.at(-1)?.data).toMatchObject({ reason: 'malformed-input' })
|
|
2021
|
+
})
|
|
2022
|
+
|
|
2023
|
+
it('records an empty production input timeout before failing open', async () => {
|
|
2024
|
+
const h = harness()
|
|
2025
|
+
const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
|
|
2026
|
+
const input = new PassThrough()
|
|
2027
|
+
|
|
2028
|
+
expect(
|
|
2029
|
+
await hookRunCommand(
|
|
2030
|
+
deps,
|
|
2031
|
+
'user-prompt-submit',
|
|
2032
|
+
() => readStdinWithTimeout(input, 5),
|
|
2033
|
+
),
|
|
2034
|
+
).toBe(EXIT.ok)
|
|
2035
|
+
|
|
2036
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
2037
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
2038
|
+
expect(records.at(-1)?.data).toMatchObject({ reason: 'input-read-failed' })
|
|
2039
|
+
expect(records.at(-1)?.data?.['message']).toMatch(/timed out waiting/)
|
|
1213
2040
|
})
|
|
1214
2041
|
|
|
1215
2042
|
it('explains when a missing machine credential makes a hook skip routing', async () => {
|
|
@@ -1224,29 +2051,21 @@ describe('user-prompt-submit hook', () => {
|
|
|
1224
2051
|
})
|
|
1225
2052
|
|
|
1226
2053
|
it('preserves a delivered question through a second ask and the prompt transition', async () => {
|
|
1227
|
-
const h = harness([]
|
|
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({
|
|
2054
|
+
const h = harness([])
|
|
2055
|
+
const live = {
|
|
1239
2056
|
question: 'Ship it?',
|
|
1240
|
-
request_id: '
|
|
2057
|
+
request_id: 'req_live',
|
|
2058
|
+
collapse_key: 'collapse-live',
|
|
1241
2059
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
1242
|
-
|
|
1243
|
-
|
|
2060
|
+
reply_deadline_at: NOW + 60_000,
|
|
2061
|
+
}
|
|
2062
|
+
writeSessionState('transition', h.env, { last_prompt_at: AWAY, pending: [live] })
|
|
1244
2063
|
|
|
1245
2064
|
// A second plain `ask` resolves through the project pointer and joins the
|
|
1246
2065
|
// queue behind the live question — it must not touch it. The next prompt
|
|
1247
2066
|
// then resets presence before another Stop can run — the transition that
|
|
1248
2067
|
// used to erase the retirement debt.
|
|
1249
|
-
|
|
2068
|
+
registerQuestion('transition', h.env, { question: 'Deploy it?' })
|
|
1250
2069
|
const queued = readSessionState('transition', h.env)
|
|
1251
2070
|
expect(queued.retiring ?? []).toEqual([])
|
|
1252
2071
|
expect(queued.pending?.map((entry) => entry.question)).toEqual(['Ship it?', 'Deploy it?'])
|
|
@@ -1258,16 +2077,17 @@ describe('user-prompt-submit hook', () => {
|
|
|
1258
2077
|
'claude-code',
|
|
1259
2078
|
)
|
|
1260
2079
|
|
|
1261
|
-
// The delivered question retires truthfully
|
|
1262
|
-
// one
|
|
2080
|
+
// The delivered question retires truthfully. The never-delivered second
|
|
2081
|
+
// one must remain queued: if Codex skipped Stop, erasing it here loses the
|
|
2082
|
+
// only copy before it ever reached a device.
|
|
1263
2083
|
const retirement = h.recorder.submitted.find(
|
|
1264
2084
|
(submission) => submission.draft.event === 'question_retired',
|
|
1265
2085
|
)?.draft
|
|
1266
|
-
expect(retirement?.delivery.collapse_key).toBe(live
|
|
2086
|
+
expect(retirement?.delivery.collapse_key).toBe(live.collapse_key)
|
|
1267
2087
|
expect(retirement?.lifecycle).toEqual({
|
|
1268
2088
|
tier: 'done',
|
|
1269
2089
|
state: 'answered_elsewhere',
|
|
1270
|
-
retires_request_id: live
|
|
2090
|
+
retires_request_id: live.request_id,
|
|
1271
2091
|
})
|
|
1272
2092
|
expect(retirement?.targets).toEqual({
|
|
1273
2093
|
mode: 'selected',
|
|
@@ -1276,28 +2096,29 @@ describe('user-prompt-submit hook', () => {
|
|
|
1276
2096
|
expect(
|
|
1277
2097
|
h.recorder.submitted.filter((s) => s.draft.event === 'question_retired'),
|
|
1278
2098
|
).toHaveLength(1)
|
|
2099
|
+
expect(readSessionState('transition', h.env).pending?.map((entry) => entry.question)).toEqual([
|
|
2100
|
+
'Deploy it?',
|
|
2101
|
+
])
|
|
2102
|
+
|
|
2103
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
2104
|
+
expect(readSessionState('transition', h.env).pending).toBeUndefined()
|
|
1279
2105
|
})
|
|
1280
2106
|
|
|
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.
|
|
2107
|
+
it('retires a real timed-out Stop before a later prompt can accept an answer', async () => {
|
|
1285
2108
|
const h = harness([])
|
|
1286
2109
|
writeSessionState('s11', h.env, { last_prompt_at: AWAY })
|
|
1287
2110
|
registerQuestion('s11', h.env, { question: 'Which environment?' })
|
|
1288
2111
|
|
|
1289
2112
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's11' }))
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
2113
|
+
expect(readSessionState('s11', h.env).pending).toBeUndefined()
|
|
2114
|
+
const retirementCount = h.recorder.submitted.filter(
|
|
2115
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
2116
|
+
).length
|
|
1293
2117
|
|
|
1294
2118
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's11', cwd: '/repo' }))
|
|
1295
2119
|
|
|
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()
|
|
2120
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2121
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'question_retired')).toHaveLength(retirementCount + 1)
|
|
1301
2122
|
})
|
|
1302
2123
|
|
|
1303
2124
|
it('marks the retirement done/answered_elsewhere so it ships silently', async () => {
|
|
@@ -1305,11 +2126,16 @@ describe('user-prompt-submit hook', () => {
|
|
|
1305
2126
|
// lifecycle update, which the server renders as a background push — the
|
|
1306
2127
|
// old "Answered" tombstone alert told the user what they just did.
|
|
1307
2128
|
const h = harness([])
|
|
1308
|
-
writeSessionState('s15', h.env, {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
2129
|
+
writeSessionState('s15', h.env, {
|
|
2130
|
+
last_prompt_at: AWAY,
|
|
2131
|
+
pending: [{
|
|
2132
|
+
question: 'Which environment?',
|
|
2133
|
+
request_id: 'req_live',
|
|
2134
|
+
collapse_key: 'collapse-live',
|
|
2135
|
+
device_ids: ['dev_iphone'],
|
|
2136
|
+
reply_deadline_at: NOW + 60_000,
|
|
2137
|
+
}],
|
|
2138
|
+
})
|
|
1313
2139
|
|
|
1314
2140
|
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 's15', cwd: '/repo' }))
|
|
1315
2141
|
|
|
@@ -1319,21 +2145,26 @@ describe('user-prompt-submit hook', () => {
|
|
|
1319
2145
|
state: 'answered_elsewhere',
|
|
1320
2146
|
// The history-entry correlation id: companions key entries by request
|
|
1321
2147
|
// id and never persisted the collapse key.
|
|
1322
|
-
retires_request_id: '
|
|
2148
|
+
retires_request_id: 'req_live',
|
|
1323
2149
|
})
|
|
1324
2150
|
})
|
|
1325
2151
|
|
|
1326
2152
|
it('retires as done/answered when the answer came from a device', async () => {
|
|
1327
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
2153
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
1328
2154
|
registerQuestion('s16', h.env, { question: 'Ship it?' })
|
|
1329
2155
|
|
|
1330
2156
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
|
|
2157
|
+
await hookRunCommand(
|
|
2158
|
+
h.deps,
|
|
2159
|
+
'stop',
|
|
2160
|
+
stdin({ session_id: 's16', stop_hook_active: true }),
|
|
2161
|
+
)
|
|
1331
2162
|
|
|
1332
2163
|
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
1333
2164
|
expect(retirement?.draft.lifecycle).toEqual({
|
|
1334
2165
|
tier: 'done',
|
|
1335
2166
|
state: 'answered',
|
|
1336
|
-
retires_request_id:
|
|
2167
|
+
retires_request_id: h.recorder.receipts[0],
|
|
1337
2168
|
})
|
|
1338
2169
|
})
|
|
1339
2170
|
|
|
@@ -1364,19 +2195,101 @@ describe('user-prompt-submit hook', () => {
|
|
|
1364
2195
|
})
|
|
1365
2196
|
|
|
1366
2197
|
describe('session-end hook', () => {
|
|
1367
|
-
it('drops local state without touching the network
|
|
2198
|
+
it('drops local state without touching the network and records the lifecycle', async () => {
|
|
1368
2199
|
const h = harness()
|
|
1369
2200
|
writeSessionState('s12', h.env, { last_prompt_at: NOW })
|
|
2201
|
+
const deps = { ...h.deps, logger: createLogger({ env: h.env }) }
|
|
1370
2202
|
|
|
1371
|
-
const code = await hookRunCommand(
|
|
2203
|
+
const code = await hookRunCommand(deps, 'session-end', stdin({ session_id: 's12' }))
|
|
1372
2204
|
|
|
1373
2205
|
expect(code).toBe(EXIT.ok)
|
|
1374
2206
|
expect(readSessionState('s12', h.env)).toEqual({})
|
|
1375
2207
|
expect(h.recorder.closed).toEqual([])
|
|
2208
|
+
const records = readLogRecords(h.env, { limit: 10 }).records
|
|
2209
|
+
expect(records.map((record) => record.event)).toEqual(['hook.start', 'hook.end'])
|
|
2210
|
+
expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
|
|
2211
|
+
})
|
|
2212
|
+
|
|
2213
|
+
it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
|
|
2214
|
+
const h = harness()
|
|
2215
|
+
const sessionId = 'session-end-budget'
|
|
2216
|
+
writeSessionState(sessionId, h.env, { last_prompt_at: NOW })
|
|
2217
|
+
writeProjectSession(h.deps.cwd, h.env, sessionId, Date.now(), 'codex')
|
|
2218
|
+
const configFile = sessionConfigPath(sessionId, h.env)
|
|
2219
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
2220
|
+
writeFileSync(configFile, 'ask_notifications = false\nlog_level = "info"\n')
|
|
2221
|
+
expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toMatchObject({
|
|
2222
|
+
value: false,
|
|
2223
|
+
source: expect.stringMatching(/^session:/),
|
|
2224
|
+
})
|
|
2225
|
+
|
|
2226
|
+
const stateFile = path.join(
|
|
2227
|
+
stateDir(h.env),
|
|
2228
|
+
'sessions',
|
|
2229
|
+
`${sanitizeSessionId(sessionId)}.json`,
|
|
2230
|
+
)
|
|
2231
|
+
const projectPointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
2232
|
+
expect(existsSync(stateFile)).toBe(true)
|
|
2233
|
+
expect(existsSync(configFile)).toBe(true)
|
|
2234
|
+
expect(existsSync(projectPointer)).toBe(true)
|
|
2235
|
+
|
|
2236
|
+
const logLock = `${activeLogPath(h.env)}.lock`
|
|
2237
|
+
const holder = runSessionEndLogLockHolder(h.deps.cwd, h.env, logLock)
|
|
2238
|
+
let cli: ReturnType<typeof runSessionEndCli> | undefined
|
|
2239
|
+
try {
|
|
2240
|
+
await waitUntil(
|
|
2241
|
+
() => existsSync(holder.readyPath),
|
|
2242
|
+
10_000,
|
|
2243
|
+
'SessionEnd log lock worker did not acquire its bakery ticket',
|
|
2244
|
+
)
|
|
2245
|
+
expect(readdirSync(logLock).filter((name) => name.startsWith('ticket-'))).toHaveLength(1)
|
|
2246
|
+
|
|
2247
|
+
cli = runSessionEndCli(h.env, h.deps.cwd, sessionId)
|
|
2248
|
+
await waitUntil(
|
|
2249
|
+
() =>
|
|
2250
|
+
readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
|
|
2251
|
+
700,
|
|
2252
|
+
'the real Codex hook did not reach contended diagnostics before its deadline',
|
|
2253
|
+
)
|
|
2254
|
+
|
|
2255
|
+
// Reaching the second bakery ticket proves main.ts preAction did not write
|
|
2256
|
+
// first. Every durable cleanup must already be visible while diagnostics
|
|
2257
|
+
// are still blocked behind the live holder.
|
|
2258
|
+
expect(existsSync(stateFile)).toBe(false)
|
|
2259
|
+
expect(existsSync(configFile)).toBe(false)
|
|
2260
|
+
expect(existsSync(projectPointer)).toBe(false)
|
|
2261
|
+
|
|
2262
|
+
const result = await cli.done
|
|
2263
|
+
expect(result).toMatchObject({ code: EXIT.ok, signal: null, stdout: '', stderr: '' })
|
|
2264
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(900)
|
|
2265
|
+
expect(result.durationMs).toBeLessThan(2_000)
|
|
2266
|
+
// The diagnostic contender timed out rather than stealing the held lock,
|
|
2267
|
+
// disabled its sink, and still let the real CLI exit successfully.
|
|
2268
|
+
expect(existsSync(activeLogPath(h.env))).toBe(false)
|
|
2269
|
+
|
|
2270
|
+
writeFileSync(holder.releasePath, 'release')
|
|
2271
|
+
await holder.done
|
|
2272
|
+
|
|
2273
|
+
expect(readSessionState(sessionId, h.env)).toEqual({})
|
|
2274
|
+
expect(readProjectSession(h.deps.cwd, h.env, Date.now())).toBeNull()
|
|
2275
|
+
expect(loadConfig({ cwd: h.deps.cwd, env: h.env, sessionId }).ask_notifications).toEqual({
|
|
2276
|
+
value: true,
|
|
2277
|
+
source: 'default',
|
|
2278
|
+
})
|
|
2279
|
+
} finally {
|
|
2280
|
+
if (!existsSync(holder.releasePath)) writeFileSync(holder.releasePath, 'release')
|
|
2281
|
+
if (cli?.child.exitCode === null && cli.child.signalCode === null) cli.child.kill('SIGKILL')
|
|
2282
|
+
if (holder.child.exitCode === null && holder.child.signalCode === null) {
|
|
2283
|
+
holder.child.kill('SIGKILL')
|
|
2284
|
+
}
|
|
2285
|
+
rmSync(h.deps.cwd, { recursive: true, force: true })
|
|
2286
|
+
}
|
|
1376
2287
|
})
|
|
1377
2288
|
|
|
1378
|
-
it('clears only the ending session project
|
|
2289
|
+
it('clears only the ending session from a concurrent project index', async () => {
|
|
1379
2290
|
const h = harness()
|
|
2291
|
+
writeSessionState('still-running', h.env, { last_prompt_at: NOW })
|
|
2292
|
+
writeProjectSession(h.deps.cwd, h.env, 'still-running', NOW - 1, 'codex')
|
|
1380
2293
|
await hookRunCommand(
|
|
1381
2294
|
h.deps,
|
|
1382
2295
|
'user-prompt-submit',
|
|
@@ -1384,6 +2297,15 @@ describe('session-end hook', () => {
|
|
|
1384
2297
|
'claude-code',
|
|
1385
2298
|
)
|
|
1386
2299
|
expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('ending')
|
|
2300
|
+
expect(
|
|
2301
|
+
readMatchingProjectSessionPointer(
|
|
2302
|
+
h.deps.cwd,
|
|
2303
|
+
h.env,
|
|
2304
|
+
NOW,
|
|
2305
|
+
'still-running',
|
|
2306
|
+
'codex',
|
|
2307
|
+
),
|
|
2308
|
+
).toEqual({ sessionId: 'still-running', harness: 'codex' })
|
|
1387
2309
|
|
|
1388
2310
|
await hookRunCommand(
|
|
1389
2311
|
h.deps,
|
|
@@ -1392,7 +2314,16 @@ describe('session-end hook', () => {
|
|
|
1392
2314
|
'claude-code',
|
|
1393
2315
|
)
|
|
1394
2316
|
|
|
1395
|
-
expect(readProjectSession(h.deps.cwd, h.env, NOW)).
|
|
2317
|
+
expect(readProjectSession(h.deps.cwd, h.env, NOW)).toBe('still-running')
|
|
2318
|
+
expect(
|
|
2319
|
+
readMatchingProjectSessionPointer(
|
|
2320
|
+
h.deps.cwd,
|
|
2321
|
+
h.env,
|
|
2322
|
+
NOW,
|
|
2323
|
+
'still-running',
|
|
2324
|
+
'codex',
|
|
2325
|
+
),
|
|
2326
|
+
).toEqual({ sessionId: 'still-running', harness: 'codex' })
|
|
1396
2327
|
})
|
|
1397
2328
|
|
|
1398
2329
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
@@ -1430,7 +2361,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
1430
2361
|
// The hook has always known session_id and never passed it on, so two
|
|
1431
2362
|
// agents in separate worktrees produced identical notifications and the
|
|
1432
2363
|
// user could answer the wrong one's question.
|
|
1433
|
-
const h = harness([]
|
|
2364
|
+
const h = harness([])
|
|
1434
2365
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
1435
2366
|
|
|
1436
2367
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
@@ -1439,17 +2370,22 @@ describe('telling concurrent agents apart', () => {
|
|
|
1439
2370
|
})
|
|
1440
2371
|
|
|
1441
2372
|
it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
|
|
1442
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
2373
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
1443
2374
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
1444
2375
|
|
|
1445
2376
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2377
|
+
await hookRunCommand(
|
|
2378
|
+
h.deps,
|
|
2379
|
+
'stop',
|
|
2380
|
+
stdin({ session_id: 'sess-abc', stop_hook_active: true }),
|
|
2381
|
+
)
|
|
1446
2382
|
|
|
1447
2383
|
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
1448
2384
|
expect(retirement?.draft.session).toBe('sess-abc')
|
|
1449
2385
|
})
|
|
1450
2386
|
|
|
1451
2387
|
it('prefers a name the user chose over the harness UUID', async () => {
|
|
1452
|
-
const h = harness([]
|
|
2388
|
+
const h = harness([])
|
|
1453
2389
|
h.env['NOTIFAI_SESSION'] = 'migration-worktree'
|
|
1454
2390
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
1455
2391
|
|
|
@@ -1460,44 +2396,19 @@ describe('telling concurrent agents apart', () => {
|
|
|
1460
2396
|
})
|
|
1461
2397
|
|
|
1462
2398
|
describe('clock jumps', () => {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
//
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const state = { last_prompt_at: NOW - 400 * 24 * 3600 * 1000 }
|
|
1470
|
-
expect(isUserAway(state, config, NOW, null)).toBe(false)
|
|
1471
|
-
})
|
|
1472
|
-
|
|
1473
|
-
it('does not read a backward jump as the user being present either', () => {
|
|
1474
|
-
// A negative delta is nonsense, not freshness. It resolves the same way:
|
|
1475
|
-
// no evidence, so leave the terminal alone.
|
|
1476
|
-
expect(isUserAway({ last_prompt_at: NOW + 60_000 }, config, NOW, null)).toBe(false)
|
|
1477
|
-
})
|
|
1478
|
-
|
|
1479
|
-
it('still escalates on an ordinary long silence', () => {
|
|
1480
|
-
expect(isUserAway({ last_prompt_at: NOW - 3600_000 }, config, NOW, null)).toBe(true)
|
|
1481
|
-
})
|
|
1482
|
-
|
|
1483
|
-
it('lets the OS idle signal decide regardless of the wall clock', () => {
|
|
1484
|
-
// The idle probe measures elapsed time directly, so it is unaffected — and
|
|
1485
|
-
// it outranks the proxy anyway.
|
|
1486
|
-
const nonsense = { last_prompt_at: NOW + 999_999_999 }
|
|
1487
|
-
expect(isUserAway(nonsense, config, NOW, 900)).toBe(true)
|
|
1488
|
-
expect(isUserAway(nonsense, config, NOW, 1)).toBe(false)
|
|
1489
|
-
})
|
|
1490
|
-
|
|
1491
|
-
it('does not let a stamp from the future hold the terminal past the budget', async () => {
|
|
1492
|
-
// asked_at is wall-clock too. A future stamp used to make the grace window
|
|
1493
|
-
// unreachable, blocking Stop until the harness killed it.
|
|
1494
|
-
const h = harness([], 900)
|
|
2399
|
+
it('does not let a stamp from the future consume the whole waiter ceiling', async () => {
|
|
2400
|
+
// `asked_at` is wall-clock, and an NTP correction or VM resume can move it.
|
|
2401
|
+
// A stamp thirty days out would otherwise make the grace window unreachable
|
|
2402
|
+
// and leave nothing of the ceiling for an answer to arrive in.
|
|
2403
|
+
const h = harness([])
|
|
2404
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
1495
2405
|
registerQuestion('sess-jump', h.env, { question: 'Ship it?' }, NOW + 30 * 24 * 3600 * 1000)
|
|
1496
2406
|
|
|
1497
2407
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-jump' }))
|
|
1498
2408
|
|
|
1499
|
-
// It escalated rather than spinning: the window restarted from now.
|
|
1500
2409
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
2410
|
+
// The window ran from now, not from the impossible stamp.
|
|
2411
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
|
|
1501
2412
|
})
|
|
1502
2413
|
})
|
|
1503
2414
|
|
|
@@ -1694,13 +2605,33 @@ describe('two hooks racing one question', () => {
|
|
|
1694
2605
|
// A crashed hook must not suppress every question for this session for
|
|
1695
2606
|
// ever — that is worse than the duplicate the claim prevents.
|
|
1696
2607
|
const h = harness()
|
|
1697
|
-
|
|
2608
|
+
const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId('race3')}.claim`)
|
|
2609
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
2610
|
+
writeFileSync(
|
|
2611
|
+
file,
|
|
2612
|
+
`${JSON.stringify({ pid: 999_999_999, at: REAL - 1, token: 'dead' })}\n`,
|
|
2613
|
+
)
|
|
1698
2614
|
expect(claimQuestionPush('race3', h.env, REAL)).toBe(true)
|
|
1699
2615
|
})
|
|
1700
2616
|
|
|
2617
|
+
it('never steals an old claim while its process is still alive', () => {
|
|
2618
|
+
const h = harness()
|
|
2619
|
+
expect(claimQuestionPush('race-live', h.env, REAL - 10 * 60_000)).toBe(true)
|
|
2620
|
+
expect(claimQuestionPush('race-live', h.env, REAL)).toBe(false)
|
|
2621
|
+
})
|
|
2622
|
+
|
|
1701
2623
|
it('serializes a contender that arrives while a stale claim is being replaced', () => {
|
|
1702
2624
|
const h = harness()
|
|
1703
|
-
|
|
2625
|
+
const file = path.join(
|
|
2626
|
+
stateDir(h.env),
|
|
2627
|
+
'sessions',
|
|
2628
|
+
`${sanitizeSessionId('race-break')}.claim`,
|
|
2629
|
+
)
|
|
2630
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
2631
|
+
writeFileSync(
|
|
2632
|
+
file,
|
|
2633
|
+
`${JSON.stringify({ pid: 999_999_999, at: REAL - 10 * 60_000, token: 'dead' })}\n`,
|
|
2634
|
+
)
|
|
1704
2635
|
let contender: ReturnType<typeof claimQuestionPush> | undefined
|
|
1705
2636
|
|
|
1706
2637
|
const recovered = claimQuestionPush('race-break', h.env, REAL, () => {
|
|
@@ -1718,7 +2649,7 @@ describe('two hooks racing one question', () => {
|
|
|
1718
2649
|
})
|
|
1719
2650
|
|
|
1720
2651
|
it('sends one notification when a second Stop arrives mid-flight', async () => {
|
|
1721
|
-
const h = harness([]
|
|
2652
|
+
const h = harness([])
|
|
1722
2653
|
writeSessionState('race5', h.env, { last_prompt_at: AWAY })
|
|
1723
2654
|
registerQuestion('race5', h.env, { question: 'Ship it?' })
|
|
1724
2655
|
// Standing in for the other process: the claim is already held.
|
|
@@ -1729,11 +2660,57 @@ describe('two hooks racing one question', () => {
|
|
|
1729
2660
|
expect(h.recorder.submitted).toHaveLength(0)
|
|
1730
2661
|
expect(h.io.errLines.join(" ")).toContain('already handling')
|
|
1731
2662
|
})
|
|
2663
|
+
|
|
2664
|
+
it('resumes the agent once when two Stops collect the same late answer', async () => {
|
|
2665
|
+
const answers: ReplyView[] = []
|
|
2666
|
+
const h = harness(answers)
|
|
2667
|
+
writeSessionState('race-late-answer', h.env, {
|
|
2668
|
+
last_prompt_at: AWAY,
|
|
2669
|
+
pending: [
|
|
2670
|
+
{
|
|
2671
|
+
question: 'Ship it?',
|
|
2672
|
+
asked_at: NOW,
|
|
2673
|
+
request_id: 'req_existing',
|
|
2674
|
+
collapse_key: 'question-existing',
|
|
2675
|
+
device_ids: ['dev_iphone'],
|
|
2676
|
+
reply_deadline_at: NOW + 60_000,
|
|
2677
|
+
},
|
|
2678
|
+
],
|
|
2679
|
+
})
|
|
2680
|
+
answers.push(reply({ text: 'Ship it' }))
|
|
2681
|
+
h.io.outLines = []
|
|
2682
|
+
|
|
2683
|
+
let polls = 0
|
|
2684
|
+
let markFirstPoll: (() => void) | undefined
|
|
2685
|
+
const firstPoll = new Promise<void>((resolve) => {
|
|
2686
|
+
markFirstPoll = resolve
|
|
2687
|
+
})
|
|
2688
|
+
let releasePolls: (() => void) | undefined
|
|
2689
|
+
const heldPoll = new Promise<void>((resolve) => {
|
|
2690
|
+
releasePolls = resolve
|
|
2691
|
+
})
|
|
2692
|
+
h.recorder.beforeReplies = async () => {
|
|
2693
|
+
polls += 1
|
|
2694
|
+
markFirstPoll?.()
|
|
2695
|
+
await heldPoll
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
const firstStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
|
|
2699
|
+
await firstPoll
|
|
2700
|
+
const secondStop = hookRunCommand(h.deps, 'stop', stdin({ session_id: 'race-late-answer' }))
|
|
2701
|
+
await Promise.race([secondStop, new Promise<void>((resolve) => setImmediate(resolve))])
|
|
2702
|
+
releasePolls?.()
|
|
2703
|
+
await Promise.all([firstStop, secondStop])
|
|
2704
|
+
|
|
2705
|
+
const resumes = h.io.outLines.map((line) => JSON.parse(line) as { decision?: string })
|
|
2706
|
+
expect(polls).toBe(1)
|
|
2707
|
+
expect(resumes.filter((entry) => entry.decision === 'block')).toHaveLength(1)
|
|
2708
|
+
})
|
|
1732
2709
|
})
|
|
1733
2710
|
|
|
1734
2711
|
describe('question registration racing a Stop submission', () => {
|
|
1735
|
-
it('keeps
|
|
1736
|
-
const h = harness([]
|
|
2712
|
+
it('keeps the new question when the older owner expires', async () => {
|
|
2713
|
+
const h = harness([])
|
|
1737
2714
|
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
1738
2715
|
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
1739
2716
|
h.recorder.beforeQuestionSubmit = () => {
|
|
@@ -1743,20 +2720,20 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1743
2720
|
|
|
1744
2721
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'submit-race' }))
|
|
1745
2722
|
|
|
1746
|
-
// The racing ask appended
|
|
1747
|
-
//
|
|
2723
|
+
// The racing ask appended and remains unasked. The already-delivered old
|
|
2724
|
+
// question is retired when this Stop owner reaches its deadline, so it
|
|
2725
|
+
// cannot accept a phone answer after the hook has returned.
|
|
1748
2726
|
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([])
|
|
2727
|
+
expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
|
|
2728
|
+
expect(state.pending?.[0]?.request_id).toBeUndefined()
|
|
2729
|
+
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
2730
|
+
expect(state.retiring).toContainEqual(
|
|
2731
|
+
expect.objectContaining({ request_id: h.recorder.receipts[0], state: 'expired' }),
|
|
2732
|
+
)
|
|
1756
2733
|
})
|
|
1757
2734
|
|
|
1758
2735
|
it('keeps the newer question when the older in-flight question receives an answer', async () => {
|
|
1759
|
-
const h = harness([reply({ text: 'Old answer' })]
|
|
2736
|
+
const h = harness([reply({ text: 'Old answer' })])
|
|
1760
2737
|
writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
|
|
1761
2738
|
registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
|
|
1762
2739
|
h.recorder.beforeQuestionSubmit = () => {
|
|
@@ -1768,12 +2745,603 @@ describe('question registration racing a Stop submission', () => {
|
|
|
1768
2745
|
|
|
1769
2746
|
const state = readSessionState('answer-race', h.env)
|
|
1770
2747
|
expect(state.pending?.map((entry) => entry.question)).toEqual(['New question?'])
|
|
2748
|
+
expect(state.accepted?.answers[0]?.pending.request_id).toBe(h.recorder.receipts[0])
|
|
2749
|
+
expect(state.accepted?.answers[0]?.reply.text).toBe('Old answer')
|
|
2750
|
+
})
|
|
2751
|
+
})
|
|
2752
|
+
|
|
2753
|
+
describe('Claude Code Stop wake route', () => {
|
|
2754
|
+
function claudeWake(status: 'idle' | 'busy' = 'idle'): ClaudeWakeAdapters & {
|
|
2755
|
+
sent: string[]
|
|
2756
|
+
resumed: string[]
|
|
2757
|
+
sleeps: number[]
|
|
2758
|
+
} {
|
|
2759
|
+
const sent: string[] = []
|
|
2760
|
+
const resumed: string[] = []
|
|
2761
|
+
const sleeps: number[] = []
|
|
2762
|
+
return {
|
|
2763
|
+
sent,
|
|
2764
|
+
resumed,
|
|
2765
|
+
sleeps,
|
|
2766
|
+
async listAgents() {
|
|
2767
|
+
return [
|
|
2768
|
+
{
|
|
2769
|
+
pid: 12345,
|
|
2770
|
+
sessionId: 'claude-route',
|
|
2771
|
+
startedAt: NOW,
|
|
2772
|
+
status,
|
|
2773
|
+
},
|
|
2774
|
+
]
|
|
2775
|
+
},
|
|
2776
|
+
readDescriptor() {
|
|
2777
|
+
return {
|
|
2778
|
+
pid: 12345,
|
|
2779
|
+
sessionId: 'claude-route',
|
|
2780
|
+
cwd: '/tmp/claude-route',
|
|
2781
|
+
startedAt: NOW,
|
|
2782
|
+
procStart: 'Wed Aug 12 08:17:53 2026',
|
|
2783
|
+
version: '2.1.228',
|
|
2784
|
+
peerProtocol: 1,
|
|
2785
|
+
messagingSocketPath: '/tmp/cc-socks/12345.sock',
|
|
2786
|
+
status,
|
|
2787
|
+
}
|
|
2788
|
+
},
|
|
2789
|
+
async sendSocket(_socketPath, line) {
|
|
2790
|
+
sent.push(line)
|
|
2791
|
+
},
|
|
2792
|
+
async resume(_sessionId, _cwd, context) {
|
|
2793
|
+
resumed.push(context)
|
|
2794
|
+
},
|
|
2795
|
+
async sleep(milliseconds) {
|
|
2796
|
+
sleeps.push(milliseconds)
|
|
2797
|
+
},
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
it('routes an accepted answer through the Claude inbox instead of hook stdout', async () => {
|
|
2802
|
+
const h = harness([reply({ text: 'BETA' })])
|
|
2803
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
2804
|
+
writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
|
|
2805
|
+
const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
|
|
2806
|
+
expect(built.ok).toBe(true)
|
|
2807
|
+
if (!built.ok) return
|
|
2808
|
+
registerQuestion('claude-route', h.env, {
|
|
2809
|
+
question: 'Which rollout option?',
|
|
2810
|
+
questions: built.questions,
|
|
2811
|
+
})
|
|
2812
|
+
const wake = claudeWake()
|
|
2813
|
+
|
|
2814
|
+
await hookRunCommand(
|
|
2815
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
2816
|
+
'stop',
|
|
2817
|
+
stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
|
|
2818
|
+
'claude-code',
|
|
2819
|
+
)
|
|
2820
|
+
|
|
2821
|
+
expect(h.io.outLines).toEqual([])
|
|
2822
|
+
expect(wake.sent).toHaveLength(1)
|
|
2823
|
+
const message = JSON.parse(wake.sent[0]!) as {
|
|
2824
|
+
type: string
|
|
2825
|
+
message: { role: string; content: string }
|
|
2826
|
+
}
|
|
2827
|
+
expect(message).toMatchObject({ type: 'user', message: { role: 'user' } })
|
|
2828
|
+
expect(message.message.content).toContain('question_id')
|
|
2829
|
+
expect(message.message.content).toContain('"Which rollout option?"')
|
|
2830
|
+
expect(message.message.content).toContain('"BETA"')
|
|
2831
|
+
expect(message.message.content).not.toMatch(/trusted|urgent|permission|approval/i)
|
|
2832
|
+
expect(wake.sleeps).toEqual([CLAUDE_POST_SEND_LIVENESS_MS])
|
|
2833
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeDefined()
|
|
2834
|
+
})
|
|
2835
|
+
|
|
2836
|
+
/** One answer already accepted and journaled, ready for the next Stop. */
|
|
2837
|
+
function journaledAnswer(
|
|
2838
|
+
h: Harness,
|
|
2839
|
+
options: { recordedAt?: number; deliveredAt?: number } = {},
|
|
2840
|
+
): void {
|
|
2841
|
+
const recordedAt = options.recordedAt ?? NOW
|
|
2842
|
+
writeSessionState('claude-route', h.env, {
|
|
2843
|
+
...readSessionState('claude-route', h.env),
|
|
2844
|
+
accepted: {
|
|
2845
|
+
...(options.deliveredAt === undefined
|
|
2846
|
+
? {}
|
|
2847
|
+
: { delivered_at: options.deliveredAt, delivered_route: 'inbox-socket' }),
|
|
2848
|
+
answers: [
|
|
2849
|
+
{
|
|
2850
|
+
pending: {
|
|
2851
|
+
question: 'Which rollout option?',
|
|
2852
|
+
request_id: 'req_existing',
|
|
2853
|
+
collapse_key: 'question-existing',
|
|
2854
|
+
device_ids: ['dev_iphone'],
|
|
2855
|
+
},
|
|
2856
|
+
reply: reply({ text: 'BETA' }),
|
|
2857
|
+
replies: [reply({ text: 'BETA' })],
|
|
2858
|
+
},
|
|
2859
|
+
],
|
|
2860
|
+
remaining: 0,
|
|
2861
|
+
recorded_at: recordedAt,
|
|
2862
|
+
},
|
|
2863
|
+
})
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
it('settles a socket delivery so the woken turn cannot redeliver the answer', async () => {
|
|
2867
|
+
// The exact device-proof failure. A socket delivery starts a brand-new turn
|
|
2868
|
+
// rather than continuing this one, so the Stop that ends the woken turn
|
|
2869
|
+
// reports stop_hook_active=false — for ever. While that flag was the only
|
|
2870
|
+
// acknowledgement, the journal never settled and every turn-end delivered
|
|
2871
|
+
// the same answer again: 250 times, in the transcript that found this.
|
|
2872
|
+
const h = harness([])
|
|
2873
|
+
journaledAnswer(h)
|
|
2874
|
+
const wake = claudeWake()
|
|
2875
|
+
const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
|
|
2876
|
+
const stop = (stopHookActive: boolean) =>
|
|
2877
|
+
hookRunCommand(
|
|
2878
|
+
deps,
|
|
2879
|
+
'stop',
|
|
2880
|
+
stdin({
|
|
2881
|
+
session_id: 'claude-route',
|
|
2882
|
+
cwd: '/tmp/claude-route',
|
|
2883
|
+
stop_hook_active: stopHookActive,
|
|
2884
|
+
}),
|
|
2885
|
+
'claude-code',
|
|
2886
|
+
)
|
|
2887
|
+
|
|
2888
|
+
await stop(false)
|
|
2889
|
+
|
|
2890
|
+
expect(wake.sent).toHaveLength(1)
|
|
2891
|
+
const delivered = readSessionState('claude-route', h.env).accepted
|
|
2892
|
+
expect(delivered?.delivered_route).toBe('inbox-socket')
|
|
2893
|
+
expect(typeof delivered?.delivered_at).toBe('number')
|
|
2894
|
+
|
|
2895
|
+
// Three more turn-ends of the woken session, none of them a continuation.
|
|
2896
|
+
await stop(false)
|
|
2897
|
+
await stop(false)
|
|
2898
|
+
await stop(false)
|
|
2899
|
+
|
|
2900
|
+
expect(wake.sent).toHaveLength(1)
|
|
2901
|
+
expect(wake.resumed).toEqual([])
|
|
2902
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
2903
|
+
// Settled, not merely dropped: the answered question is retired truthfully.
|
|
1771
2904
|
expect(
|
|
1772
2905
|
h.recorder.submitted.find(
|
|
1773
2906
|
(entry) =>
|
|
1774
2907
|
entry.draft.event === 'question_retired' &&
|
|
1775
|
-
entry.draft.lifecycle?.retires_request_id === '
|
|
2908
|
+
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
1776
2909
|
)?.draft.lifecycle?.state,
|
|
1777
2910
|
).toBe('answered')
|
|
1778
2911
|
})
|
|
2912
|
+
|
|
2913
|
+
it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
|
|
2914
|
+
// The chained loop the cap exists for, on a route that starts new turns.
|
|
2915
|
+
// Keyed to stop_hook_active the cap could never fire here at all.
|
|
2916
|
+
const h = harness([])
|
|
2917
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
2918
|
+
writeSessionState('claude-route', h.env, {
|
|
2919
|
+
last_prompt_at: AWAY,
|
|
2920
|
+
continuation: { answered_at: NOW - 2000, count: MAX_CONTINUATION_COUNT - 1 },
|
|
2921
|
+
})
|
|
2922
|
+
journaledAnswer(h, { recordedAt: NOW - 1000, deliveredAt: NOW - 900 })
|
|
2923
|
+
// The woken turn asked a genuinely new question before it ended.
|
|
2924
|
+
registerQuestion('claude-route', h.env, { question: 'And after that?' }, NOW)
|
|
2925
|
+
const wake = claudeWake()
|
|
2926
|
+
|
|
2927
|
+
await hookRunCommand(
|
|
2928
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
2929
|
+
'stop',
|
|
2930
|
+
stdin({
|
|
2931
|
+
session_id: 'claude-route',
|
|
2932
|
+
cwd: '/tmp/claude-route',
|
|
2933
|
+
stop_hook_active: false,
|
|
2934
|
+
}),
|
|
2935
|
+
'claude-code',
|
|
2936
|
+
)
|
|
2937
|
+
|
|
2938
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
|
|
2939
|
+
[],
|
|
2940
|
+
)
|
|
2941
|
+
expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
|
|
2942
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
2943
|
+
`continuation limit (${MAX_CONTINUATION_COUNT}) reached`,
|
|
2944
|
+
)
|
|
2945
|
+
})
|
|
2946
|
+
|
|
2947
|
+
it('uses the accepted journal as the loop guard after a socket wake fires Stop again', async () => {
|
|
2948
|
+
const h = harness([])
|
|
2949
|
+
writeSessionState('claude-route', h.env, {
|
|
2950
|
+
accepted: {
|
|
2951
|
+
answers: [
|
|
2952
|
+
{
|
|
2953
|
+
pending: {
|
|
2954
|
+
question: 'Which rollout option?',
|
|
2955
|
+
request_id: 'req_existing',
|
|
2956
|
+
collapse_key: 'question-existing',
|
|
2957
|
+
device_ids: ['dev_iphone'],
|
|
2958
|
+
},
|
|
2959
|
+
reply: reply({ text: 'BETA' }),
|
|
2960
|
+
replies: [reply({ text: 'BETA' })],
|
|
2961
|
+
},
|
|
2962
|
+
],
|
|
2963
|
+
remaining: 0,
|
|
2964
|
+
recorded_at: NOW,
|
|
2965
|
+
},
|
|
2966
|
+
})
|
|
2967
|
+
const wake = claudeWake()
|
|
2968
|
+
|
|
2969
|
+
await hookRunCommand(
|
|
2970
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
2971
|
+
'stop',
|
|
2972
|
+
stdin({
|
|
2973
|
+
session_id: 'claude-route',
|
|
2974
|
+
cwd: '/tmp/claude-route',
|
|
2975
|
+
stop_hook_active: true,
|
|
2976
|
+
}),
|
|
2977
|
+
'claude-code',
|
|
2978
|
+
)
|
|
2979
|
+
|
|
2980
|
+
expect(wake.sent).toEqual([])
|
|
2981
|
+
expect(wake.resumed).toEqual([])
|
|
2982
|
+
expect(h.io.outLines).toEqual([])
|
|
2983
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
2984
|
+
})
|
|
2985
|
+
})
|
|
2986
|
+
|
|
2987
|
+
describe('Codex Stop wake route', () => {
|
|
2988
|
+
const CODEX_THREAD = '019ff69d-a07f-7161-ab6e-bd06b3b93c8e'
|
|
2989
|
+
|
|
2990
|
+
function codexWake(
|
|
2991
|
+
options: { sourceAlive?: boolean; probe?: CodexWakeObservation } = {},
|
|
2992
|
+
): CodexWakeAdapters & { resumed: string[]; probed: string[] } {
|
|
2993
|
+
const resumed: string[] = []
|
|
2994
|
+
const probed: string[] = []
|
|
2995
|
+
return {
|
|
2996
|
+
resumed,
|
|
2997
|
+
probed,
|
|
2998
|
+
probeThreadWriter(lockPath) {
|
|
2999
|
+
probed.push(lockPath)
|
|
3000
|
+
return options.probe ?? { state: 'stopped' }
|
|
3001
|
+
},
|
|
3002
|
+
sourceAlive() {
|
|
3003
|
+
return options.sourceAlive ?? true
|
|
3004
|
+
},
|
|
3005
|
+
async resume(_threadId, _cwd, context) {
|
|
3006
|
+
resumed.push(context)
|
|
3007
|
+
},
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function journaledAnswer(h: Harness): void {
|
|
3012
|
+
writeSessionState(CODEX_THREAD, h.env, {
|
|
3013
|
+
accepted: {
|
|
3014
|
+
answers: [
|
|
3015
|
+
{
|
|
3016
|
+
pending: {
|
|
3017
|
+
question: 'Which rollout option?',
|
|
3018
|
+
request_id: 'req_existing',
|
|
3019
|
+
collapse_key: 'question-existing',
|
|
3020
|
+
device_ids: ['dev_iphone'],
|
|
3021
|
+
},
|
|
3022
|
+
reply: reply({ text: 'BETA' }),
|
|
3023
|
+
replies: [reply({ text: 'BETA' })],
|
|
3024
|
+
},
|
|
3025
|
+
],
|
|
3026
|
+
remaining: 0,
|
|
3027
|
+
recorded_at: NOW,
|
|
3028
|
+
},
|
|
3029
|
+
})
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
it('continues the held turn with decision:block when the answer arrives during the hold', async () => {
|
|
3033
|
+
const h = harness([reply({ text: 'BETA' })])
|
|
3034
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
3035
|
+
writeSessionState(CODEX_THREAD, h.env, { last_prompt_at: AWAY })
|
|
3036
|
+
const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
|
|
3037
|
+
expect(built.ok).toBe(true)
|
|
3038
|
+
if (!built.ok) return
|
|
3039
|
+
registerQuestion(CODEX_THREAD, h.env, {
|
|
3040
|
+
question: 'Which rollout option?',
|
|
3041
|
+
questions: built.questions,
|
|
3042
|
+
})
|
|
3043
|
+
const wake = codexWake()
|
|
3044
|
+
|
|
3045
|
+
await hookRunCommand(
|
|
3046
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3047
|
+
'stop',
|
|
3048
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3049
|
+
'codex',
|
|
3050
|
+
)
|
|
3051
|
+
|
|
3052
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
3053
|
+
const decision = JSON.parse(h.io.outLines[0]!) as { decision: string; reason: string }
|
|
3054
|
+
expect(decision.decision).toBe('block')
|
|
3055
|
+
expect(decision.reason).toContain('"BETA"')
|
|
3056
|
+
// The default route owes the thread-writer lock nothing: a live Codex is
|
|
3057
|
+
// reading this stdout, and a probe could only take the answer away.
|
|
3058
|
+
expect(wake.probed).toEqual([])
|
|
3059
|
+
expect(wake.resumed).toEqual([])
|
|
3060
|
+
expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
|
|
3061
|
+
})
|
|
3062
|
+
|
|
3063
|
+
it('replays an answer journaled after the hold on the next Stop', async () => {
|
|
3064
|
+
const h = harness([])
|
|
3065
|
+
journaledAnswer(h)
|
|
3066
|
+
const wake = codexWake()
|
|
3067
|
+
|
|
3068
|
+
await hookRunCommand(
|
|
3069
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3070
|
+
'stop',
|
|
3071
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3072
|
+
'codex',
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
3076
|
+
expect(JSON.parse(h.io.outLines[0]!)).toMatchObject({ decision: 'block' })
|
|
3077
|
+
// Still journaled: only a successor Stop proves the continued turn ran.
|
|
3078
|
+
expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
|
|
3079
|
+
})
|
|
3080
|
+
|
|
3081
|
+
it('cold-resumes the journaled answer when the Codex process is gone and no writer holds the thread', async () => {
|
|
3082
|
+
const h = harness([])
|
|
3083
|
+
journaledAnswer(h)
|
|
3084
|
+
const wake = codexWake({ sourceAlive: false, probe: { state: 'stopped' } })
|
|
3085
|
+
|
|
3086
|
+
await hookRunCommand(
|
|
3087
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3088
|
+
'stop',
|
|
3089
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3090
|
+
'codex',
|
|
3091
|
+
)
|
|
3092
|
+
|
|
3093
|
+
expect(h.io.outLines).toEqual([])
|
|
3094
|
+
expect(wake.resumed).toHaveLength(1)
|
|
3095
|
+
expect(wake.resumed[0]).toContain('"BETA"')
|
|
3096
|
+
expect(wake.probed).toHaveLength(2)
|
|
3097
|
+
})
|
|
3098
|
+
|
|
3099
|
+
it('holds the answer rather than resuming a thread a live writer owns', async () => {
|
|
3100
|
+
const h = harness([])
|
|
3101
|
+
journaledAnswer(h)
|
|
3102
|
+
const wake = codexWake({ sourceAlive: false, probe: { state: 'live' } })
|
|
3103
|
+
|
|
3104
|
+
await hookRunCommand(
|
|
3105
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3106
|
+
'stop',
|
|
3107
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3108
|
+
'codex',
|
|
3109
|
+
)
|
|
3110
|
+
|
|
3111
|
+
expect(h.io.outLines).toEqual([])
|
|
3112
|
+
expect(wake.resumed).toEqual([])
|
|
3113
|
+
expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
|
|
3114
|
+
expect(h.io.errLines.join('\n')).toContain('holding the accepted answer for the next turn')
|
|
3115
|
+
})
|
|
3116
|
+
})
|
|
3117
|
+
|
|
3118
|
+
describe('escalation waiter delivery seam', () => {
|
|
3119
|
+
function waiterContext(h: Harness) {
|
|
3120
|
+
const client = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer test')
|
|
3121
|
+
return {
|
|
3122
|
+
client,
|
|
3123
|
+
config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
|
|
3124
|
+
env: h.env,
|
|
3125
|
+
now: h.deps.now!,
|
|
3126
|
+
sleep: h.deps.sleep!,
|
|
3127
|
+
waitForFirstReply: async (requestId: string, timeoutSeconds: number) => {
|
|
3128
|
+
const response = await client.replies(requestId, {
|
|
3129
|
+
waitSeconds: timeoutSeconds,
|
|
3130
|
+
afterSeq: 0,
|
|
3131
|
+
})
|
|
3132
|
+
return { replies: response.replies, timedOut: response.replies.length === 0 }
|
|
3133
|
+
},
|
|
3134
|
+
harness: 'codex' as const,
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
it('waits through grace, routes the fenced answer, and holds the claim until delivery settles', async () => {
|
|
3139
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
3140
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
|
|
3141
|
+
writeSessionState('waiter-route', h.env, { last_prompt_at: AWAY })
|
|
3142
|
+
registerQuestion('waiter-route', h.env, { question: 'Ship it?' }, NOW)
|
|
3143
|
+
|
|
3144
|
+
const context = waiterContext(h)
|
|
3145
|
+
const deliveries: Array<{ route: string; context: string }> = []
|
|
3146
|
+
let claimHeldDuringDelivery = false
|
|
3147
|
+
const deliver = vi.fn(async (event: { context: string }) => {
|
|
3148
|
+
deliveries.push({ route: 'hook-continuation', context: event.context })
|
|
3149
|
+
claimHeldDuringDelivery = !claimQuestionPush('waiter-route', h.env)
|
|
3150
|
+
return { stdout: JSON.stringify({ decision: 'block', reason: event.context }) }
|
|
3151
|
+
})
|
|
3152
|
+
|
|
3153
|
+
const outcome = await runEscalationWaiter(context, {
|
|
3154
|
+
sessionId: 'waiter-route',
|
|
3155
|
+
envelope: { session_id: 'waiter-route' },
|
|
3156
|
+
route: { kind: 'hook-continuation', deliver },
|
|
3157
|
+
processDeadlineAt: NOW + 480_000,
|
|
3158
|
+
})
|
|
3159
|
+
|
|
3160
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
3161
|
+
expect(deliver).toHaveBeenCalledTimes(1)
|
|
3162
|
+
expect(deliver).toHaveBeenCalledWith(
|
|
3163
|
+
expect.objectContaining({ journal_recorded_at: expect.any(Number) }),
|
|
3164
|
+
)
|
|
3165
|
+
expect(deliveries).toEqual([
|
|
3166
|
+
{ route: 'hook-continuation', context: expect.stringContaining('Ship it') },
|
|
3167
|
+
])
|
|
3168
|
+
expect(claimHeldDuringDelivery).toBe(true)
|
|
3169
|
+
expect(JSON.parse(outcome.stdout ?? '{}')).toMatchObject({
|
|
3170
|
+
decision: 'block',
|
|
3171
|
+
reason: expect.stringContaining('Ship it'),
|
|
3172
|
+
})
|
|
3173
|
+
expect(claimQuestionPush('waiter-route', h.env)).toBe(true)
|
|
3174
|
+
releaseQuestionPush('waiter-route', h.env)
|
|
3175
|
+
})
|
|
3176
|
+
|
|
3177
|
+
it('races a second Stop while grace, polling, fencing, and route delivery are still owned', async () => {
|
|
3178
|
+
const h = harness([])
|
|
3179
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 10\n')
|
|
3180
|
+
writeSessionState('waiter-race-lifetime', h.env, { last_prompt_at: AWAY })
|
|
3181
|
+
registerQuestion('waiter-race-lifetime', h.env, { question: 'Ship it?' }, NOW)
|
|
3182
|
+
const context = waiterContext(h)
|
|
3183
|
+
const phases: string[] = []
|
|
3184
|
+
|
|
3185
|
+
let waitingForReply = false
|
|
3186
|
+
context.sleep = async (milliseconds: number) => {
|
|
3187
|
+
if (!waitingForReply) {
|
|
3188
|
+
phases.push('grace')
|
|
3189
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3190
|
+
}
|
|
3191
|
+
h.advanceClock(milliseconds)
|
|
3192
|
+
}
|
|
3193
|
+
context.waitForFirstReply = async () => {
|
|
3194
|
+
waitingForReply = true
|
|
3195
|
+
phases.push('reply-wait')
|
|
3196
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3197
|
+
return { replies: [reply({ text: 'Ship it' })], timedOut: false }
|
|
3198
|
+
}
|
|
3199
|
+
const closeReplies = context.client.closeReplies.bind(context.client)
|
|
3200
|
+
context.client.closeReplies = async (requestId) => {
|
|
3201
|
+
phases.push('close-fence')
|
|
3202
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3203
|
+
return closeReplies(requestId)
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
await runEscalationWaiter(context, {
|
|
3207
|
+
sessionId: 'waiter-race-lifetime',
|
|
3208
|
+
envelope: { session_id: 'waiter-race-lifetime' },
|
|
3209
|
+
route: {
|
|
3210
|
+
kind: 'hook-continuation',
|
|
3211
|
+
deliver: async (event) => {
|
|
3212
|
+
phases.push('delivery')
|
|
3213
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3214
|
+
return { stdout: JSON.stringify({ decision: 'block', reason: event.context }), notes: [] }
|
|
3215
|
+
},
|
|
3216
|
+
},
|
|
3217
|
+
processDeadlineAt: NOW + 480_000,
|
|
3218
|
+
})
|
|
3219
|
+
|
|
3220
|
+
expect(phases.filter((phase) => phase === 'grace').length).toBeGreaterThan(0)
|
|
3221
|
+
expect(phases.filter((phase) => phase !== 'grace')).toEqual([
|
|
3222
|
+
'reply-wait',
|
|
3223
|
+
'close-fence',
|
|
3224
|
+
'delivery',
|
|
3225
|
+
])
|
|
3226
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(true)
|
|
3227
|
+
releaseQuestionPush('waiter-race-lifetime', h.env)
|
|
3228
|
+
})
|
|
3229
|
+
|
|
3230
|
+
/** One accepted answer on the journal, whatever route is about to own it. */
|
|
3231
|
+
function journaled(h: Harness, sessionId: string): void {
|
|
3232
|
+
writeSessionState(sessionId, h.env, {
|
|
3233
|
+
accepted: {
|
|
3234
|
+
answers: [
|
|
3235
|
+
{
|
|
3236
|
+
pending: {
|
|
3237
|
+
question: 'Ship it?',
|
|
3238
|
+
request_id: 'req_existing',
|
|
3239
|
+
collapse_key: 'question-existing',
|
|
3240
|
+
device_ids: ['dev_iphone'],
|
|
3241
|
+
},
|
|
3242
|
+
reply: reply({ text: 'Ship it' }),
|
|
3243
|
+
replies: [reply({ text: 'Ship it' })],
|
|
3244
|
+
},
|
|
3245
|
+
],
|
|
3246
|
+
remaining: 0,
|
|
3247
|
+
recorded_at: NOW,
|
|
3248
|
+
},
|
|
3249
|
+
})
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
it('delivers an accepted answer once on a route that acknowledges its own write', async () => {
|
|
3253
|
+
const h = harness([])
|
|
3254
|
+
journaled(h, 'waiter-once')
|
|
3255
|
+
const context = waiterContext(h)
|
|
3256
|
+
const deliveries: string[] = []
|
|
3257
|
+
const route = {
|
|
3258
|
+
kind: 'inbox-socket' as const,
|
|
3259
|
+
deliver: async (event: { context: string }) => {
|
|
3260
|
+
deliveries.push(event.context)
|
|
3261
|
+
return {
|
|
3262
|
+
notes: ['posted the accepted answer'],
|
|
3263
|
+
log: { route: 'inbox-socket', stage: 'delivered' },
|
|
3264
|
+
acknowledgement: 'delivered' as const,
|
|
3265
|
+
}
|
|
3266
|
+
},
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
// Five turn-ends, none of them a harness continuation — the shape of every
|
|
3270
|
+
// route that wakes a new turn instead of continuing this one.
|
|
3271
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
3272
|
+
await runEscalationWaiter(context, {
|
|
3273
|
+
sessionId: 'waiter-once',
|
|
3274
|
+
envelope: { session_id: 'waiter-once', stop_hook_active: false },
|
|
3275
|
+
route,
|
|
3276
|
+
processDeadlineAt: NOW + 480_000,
|
|
3277
|
+
})
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
expect(deliveries).toHaveLength(1)
|
|
3281
|
+
expect(deliveries[0]).toContain('Ship it')
|
|
3282
|
+
expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
|
|
3283
|
+
})
|
|
3284
|
+
|
|
3285
|
+
it('bounds deliveries by the continuation cap on every route', async () => {
|
|
3286
|
+
// A route that never acknowledges must still stop. The cap lives where all
|
|
3287
|
+
// routes pass, so no future route can be the one it does not cover.
|
|
3288
|
+
for (const kind of ['inbox-socket', 'hook-continuation'] as const) {
|
|
3289
|
+
const h = harness([])
|
|
3290
|
+
const sessionId = `waiter-cap-${kind}`
|
|
3291
|
+
journaled(h, sessionId)
|
|
3292
|
+
const context = waiterContext(h)
|
|
3293
|
+
let deliveries = 0
|
|
3294
|
+
const notes: string[] = []
|
|
3295
|
+
|
|
3296
|
+
for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
|
|
3297
|
+
const outcome = await runEscalationWaiter(context, {
|
|
3298
|
+
sessionId,
|
|
3299
|
+
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
3300
|
+
route: {
|
|
3301
|
+
kind,
|
|
3302
|
+
deliver: async () => {
|
|
3303
|
+
deliveries += 1
|
|
3304
|
+
return { notes: [], acknowledgement: 'held' as const }
|
|
3305
|
+
},
|
|
3306
|
+
},
|
|
3307
|
+
processDeadlineAt: NOW + 480_000,
|
|
3308
|
+
})
|
|
3309
|
+
notes.push(...outcome.notes)
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
|
|
3313
|
+
expect(notes.join('\n')).toContain(
|
|
3314
|
+
`${MAX_CONTINUATION_COUNT} times without being acknowledged`,
|
|
3315
|
+
)
|
|
3316
|
+
expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
|
|
3317
|
+
}
|
|
3318
|
+
})
|
|
3319
|
+
|
|
3320
|
+
it('keeps the accepted journal when an out-of-band route rejects delivery', async () => {
|
|
3321
|
+
const h = harness([reply({ text: 'Hold it' })])
|
|
3322
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
3323
|
+
writeSessionState('waiter-failed-route', h.env, { last_prompt_at: AWAY })
|
|
3324
|
+
registerQuestion('waiter-failed-route', h.env, { question: 'Ship it?' }, NOW)
|
|
3325
|
+
const context = waiterContext(h)
|
|
3326
|
+
|
|
3327
|
+
await expect(
|
|
3328
|
+
runEscalationWaiter(context, {
|
|
3329
|
+
sessionId: 'waiter-failed-route',
|
|
3330
|
+
envelope: { session_id: 'waiter-failed-route' },
|
|
3331
|
+
route: {
|
|
3332
|
+
kind: 'inbox-socket',
|
|
3333
|
+
deliver: async () => {
|
|
3334
|
+
throw new Error('socket unavailable')
|
|
3335
|
+
},
|
|
3336
|
+
},
|
|
3337
|
+
processDeadlineAt: NOW + 480_000,
|
|
3338
|
+
}),
|
|
3339
|
+
).rejects.toThrow('socket unavailable')
|
|
3340
|
+
|
|
3341
|
+
expect(readSessionState('waiter-failed-route', h.env).accepted?.answers[0]?.reply.text).toBe(
|
|
3342
|
+
'Hold it',
|
|
3343
|
+
)
|
|
3344
|
+
expect(claimQuestionPush('waiter-failed-route', h.env)).toBe(true)
|
|
3345
|
+
releaseQuestionPush('waiter-failed-route', h.env)
|
|
3346
|
+
})
|
|
1779
3347
|
})
|