@raidiant/notifai 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +7 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -378
- package/dist/commands.js +12 -4420
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +15 -6
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +4 -2
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +74 -7
- package/dist/hooks.js +522 -166
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +5 -5
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +9 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +45 -18
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +6 -0
- package/dist/send.js +25 -14
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +677 -89
- package/src/commands.ts +58 -5472
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +17 -6
- package/src/config.test.ts +50 -9
- package/src/config.ts +19 -1
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +703 -26
- package/src/hooks.ts +590 -182
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +5 -5
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +48 -20
- package/src/logging.ts +12 -0
- package/src/main.ts +58 -24
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/readiness.ts +3 -1
- package/src/send.ts +32 -16
- package/src/skill.test.ts +199 -146
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/hooks.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from 'node:child_process'
|
|
1
|
+
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
drainOrphanRetirements,
|
|
50
50
|
runEscalationWaiter,
|
|
51
51
|
MAX_CONTINUATION_COUNT,
|
|
52
|
+
MAX_HELD_DELIVERIES,
|
|
52
53
|
orphanRetirements,
|
|
53
54
|
pruneAbandonedSessions,
|
|
54
55
|
releaseQuestionPush,
|
|
@@ -60,7 +61,15 @@ import {
|
|
|
60
61
|
writeSessionState as persistSessionState,
|
|
61
62
|
type PendingQuestion,
|
|
62
63
|
type SessionState,
|
|
64
|
+
waiterCeilingSeconds,
|
|
65
|
+
WAITER_CEILING_SECONDS,
|
|
66
|
+
DETACHED_WAITER_CEILING_SECONDS,
|
|
63
67
|
} from './hooks.js'
|
|
68
|
+
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
69
|
+
import {
|
|
70
|
+
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
71
|
+
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
72
|
+
} from './install-hooks.js'
|
|
64
73
|
|
|
65
74
|
/** New-format test fixtures always carry the canonical body explicitly. */
|
|
66
75
|
function registerQuestion(
|
|
@@ -126,6 +135,10 @@ interface Recorder {
|
|
|
126
135
|
/** Per-request answer stream for multi-question ownership tests. */
|
|
127
136
|
repliesFor?: (requestId: string) => ReplyView[]
|
|
128
137
|
acknowledgementRequiredFor?: (requestId: string) => boolean
|
|
138
|
+
/** The window the server commits to, when a test needs one unlike the waiter's. */
|
|
139
|
+
replyExpiresAt?: string
|
|
140
|
+
/** Whether that request's acknowledgement must carry text; default true. */
|
|
141
|
+
acknowledgementTextRequiredFor?: (requestId: string) => boolean
|
|
129
142
|
acknowledged?: Set<string>
|
|
130
143
|
acknowledgementChecks?: string[]
|
|
131
144
|
}
|
|
@@ -154,7 +167,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
154
167
|
platform: 'ios' as const,
|
|
155
168
|
permission_status: 'authorized',
|
|
156
169
|
registration_healthy: true,
|
|
157
|
-
|
|
170
|
+
capabilities: ['answer'],
|
|
171
|
+
derived_status: 'working',
|
|
158
172
|
last_seen_at: null,
|
|
159
173
|
},
|
|
160
174
|
{
|
|
@@ -163,7 +177,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
163
177
|
platform: 'macos' as const,
|
|
164
178
|
permission_status: 'authorized',
|
|
165
179
|
registration_healthy: true,
|
|
166
|
-
|
|
180
|
+
capabilities: ['answer'],
|
|
181
|
+
derived_status: 'working',
|
|
167
182
|
last_seen_at: null,
|
|
168
183
|
},
|
|
169
184
|
],
|
|
@@ -176,7 +191,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
176
191
|
return {
|
|
177
192
|
status: 'recorded',
|
|
178
193
|
agent_acknowledgement: {
|
|
179
|
-
text: body.text,
|
|
194
|
+
text: body.text ?? '',
|
|
180
195
|
created_at: new Date(NOW).toISOString(),
|
|
181
196
|
},
|
|
182
197
|
}
|
|
@@ -188,6 +203,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
188
203
|
return {
|
|
189
204
|
request_id: requestId,
|
|
190
205
|
agent_acknowledgement_required: required,
|
|
206
|
+
agent_acknowledgement_text_required:
|
|
207
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
191
208
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
192
209
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
193
210
|
: null,
|
|
@@ -207,9 +224,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
207
224
|
recorder.receipts.push(requestId)
|
|
208
225
|
return {
|
|
209
226
|
request_id: requestId,
|
|
210
|
-
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
227
|
+
reply_expires_at: recorder.replyExpiresAt ?? new Date(NOW + 480_000).toISOString(),
|
|
211
228
|
agent_acknowledgement_required:
|
|
212
229
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
230
|
+
agent_acknowledgement_text_required:
|
|
231
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
213
232
|
replayed: false,
|
|
214
233
|
overall: 'provider_accepted_all',
|
|
215
234
|
deliveries: [],
|
|
@@ -223,6 +242,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
223
242
|
reply_expires_at: null,
|
|
224
243
|
agent_acknowledgement_required:
|
|
225
244
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
245
|
+
agent_acknowledgement_text_required:
|
|
246
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
226
247
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
227
248
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
228
249
|
: null,
|
|
@@ -236,6 +257,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
236
257
|
reply_expires_at: new Date(NOW).toISOString(),
|
|
237
258
|
agent_acknowledgement_required:
|
|
238
259
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
260
|
+
agent_acknowledgement_text_required:
|
|
261
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
239
262
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
240
263
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
241
264
|
: null,
|
|
@@ -316,6 +339,23 @@ function harness(replies: ReplyView[] = []): Harness {
|
|
|
316
339
|
}
|
|
317
340
|
}
|
|
318
341
|
|
|
342
|
+
function runFixtureGit(cwd: string, ...args: string[]): string {
|
|
343
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
344
|
+
encoding: 'utf8',
|
|
345
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
346
|
+
}).trim()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function initializeFixtureRepository(root: string, branch = 'branch-a'): void {
|
|
350
|
+
runFixtureGit(root, 'init')
|
|
351
|
+
runFixtureGit(root, 'config', 'user.email', 'notifai-tests@example.invalid')
|
|
352
|
+
runFixtureGit(root, 'config', 'user.name', 'Notifai Tests')
|
|
353
|
+
writeFileSync(path.join(root, 'tracked.txt'), 'fixture\n')
|
|
354
|
+
runFixtureGit(root, 'add', 'tracked.txt')
|
|
355
|
+
runFixtureGit(root, 'commit', '-m', 'fixture')
|
|
356
|
+
runFixtureGit(root, 'branch', '-M', branch)
|
|
357
|
+
}
|
|
358
|
+
|
|
319
359
|
function stdin(payload: unknown): () => Promise<string> {
|
|
320
360
|
return async () => JSON.stringify(payload)
|
|
321
361
|
}
|
|
@@ -451,7 +491,48 @@ function writeGlobalConfig(h: Harness, toml: string): void {
|
|
|
451
491
|
/** Long enough ago that no test is accidentally sensitive to the stamp. */
|
|
452
492
|
const AWAY = NOW - 600_000
|
|
453
493
|
|
|
494
|
+
describe('the waiter wall clock', () => {
|
|
495
|
+
it('spends a held turn sparingly and a detached one generously', () => {
|
|
496
|
+
// A held turn is somebody's terminal, so the short ceiling is the cost of
|
|
497
|
+
// waiting. Detached, nothing is held, and the same number only shortens how
|
|
498
|
+
// long a direct wake stays possible.
|
|
499
|
+
expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
|
|
500
|
+
expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
|
|
501
|
+
expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('stays under the timeout the async Stop handler declares', () => {
|
|
505
|
+
// That declaration's kill is silent: past it the waiter vanishes and the
|
|
506
|
+
// answer the user already gave is never delivered.
|
|
507
|
+
expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('leaves the blocking host headroom above the waiter it declares for', () => {
|
|
511
|
+
expect(BLOCKING_STOP_TIMEOUT_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
454
515
|
describe('pushing a registered question', () => {
|
|
516
|
+
it('keeps unknown future session keys through an older read-modify-write', () => {
|
|
517
|
+
const h = harness([])
|
|
518
|
+
const sessionId = 'future-session-format'
|
|
519
|
+
const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
|
|
520
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
521
|
+
writeFileSync(
|
|
522
|
+
file,
|
|
523
|
+
`${JSON.stringify({
|
|
524
|
+
last_prompt_at: NOW,
|
|
525
|
+
future_session_key: { nested: ['kept'] },
|
|
526
|
+
})}\n`,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
registerQuestion(sessionId, h.env, { question: 'Still preserved?' }, NOW)
|
|
530
|
+
|
|
531
|
+
const rewritten = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
532
|
+
expect(rewritten['future_session_key']).toEqual({ nested: ['kept'] })
|
|
533
|
+
expect(rewritten['pending']).toHaveLength(1)
|
|
534
|
+
})
|
|
535
|
+
|
|
455
536
|
// Found by a live Claude Code session: a spawned agent always has a
|
|
456
537
|
// just-set last_prompt_at, and the routing gate once read that as the user
|
|
457
538
|
// being present — so its FIRST question could never escalate, which is the
|
|
@@ -471,7 +552,8 @@ describe('pushing a registered question', () => {
|
|
|
471
552
|
title: 'Ship it?',
|
|
472
553
|
body: 'Ship it?',
|
|
473
554
|
})
|
|
474
|
-
|
|
555
|
+
// A pushed question is a question, and a question has an attention tone.
|
|
556
|
+
expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
|
|
475
557
|
})
|
|
476
558
|
|
|
477
559
|
it('keeps sparse alt text paired with its original media item', async () => {
|
|
@@ -821,7 +903,7 @@ describe('terminal-first grace window', () => {
|
|
|
821
903
|
).toBeGreaterThanOrEqual(60)
|
|
822
904
|
})
|
|
823
905
|
|
|
824
|
-
it('never pushes a question to a Companion
|
|
906
|
+
it('never pushes a question to a Companion that did not advertise answer', async () => {
|
|
825
907
|
const h = harness([])
|
|
826
908
|
const factory = h.deps.clientFactory
|
|
827
909
|
h.deps.clientFactory = () => {
|
|
@@ -831,7 +913,7 @@ describe('terminal-first grace window', () => {
|
|
|
831
913
|
listDevices: async () => ({
|
|
832
914
|
devices: (await client.listDevices()).devices.map((device) => ({
|
|
833
915
|
...device,
|
|
834
|
-
|
|
916
|
+
capabilities: [],
|
|
835
917
|
})),
|
|
836
918
|
}),
|
|
837
919
|
} as ApiClient
|
|
@@ -1045,6 +1127,12 @@ describe('late answer collection', () => {
|
|
|
1045
1127
|
|
|
1046
1128
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
|
|
1047
1129
|
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
|
|
1130
|
+
// The obligation survives the user typing — that is the whole point of
|
|
1131
|
+
// recording it — so the turn after the answer is held for it. Satisfy it
|
|
1132
|
+
// the way the agent would, then the retirement proceeds.
|
|
1133
|
+
expect(readSessionState('late-prompt', h.env).acknowledgement_due).toHaveLength(1)
|
|
1134
|
+
h.recorder.acknowledged = new Set(['req_live'])
|
|
1135
|
+
|
|
1048
1136
|
await hookRunCommand(
|
|
1049
1137
|
h.deps,
|
|
1050
1138
|
'stop',
|
|
@@ -1216,10 +1304,66 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1216
1304
|
expect(h.recorder.submitted[0]).toEqual(firstAttempt)
|
|
1217
1305
|
})
|
|
1218
1306
|
|
|
1219
|
-
it('
|
|
1307
|
+
it('remints a frozen draft after a terminal 422 instead of replaying it', async () => {
|
|
1308
|
+
const h = harness([])
|
|
1309
|
+
const factory = h.deps.clientFactory
|
|
1310
|
+
h.deps.clientFactory = () => {
|
|
1311
|
+
const client = factory!()
|
|
1312
|
+
return {
|
|
1313
|
+
...client,
|
|
1314
|
+
submit: async () => {
|
|
1315
|
+
throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
|
|
1316
|
+
},
|
|
1317
|
+
} as ApiClient
|
|
1318
|
+
}
|
|
1319
|
+
writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
|
|
1320
|
+
registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1321
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1322
|
+
const frozen = readSessionState('rejected-submit', h.env).pending?.[0]?.submission
|
|
1323
|
+
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1324
|
+
|
|
1325
|
+
let submits = 0
|
|
1326
|
+
h.deps.clientFactory = () => {
|
|
1327
|
+
const client = factory!()
|
|
1328
|
+
return {
|
|
1329
|
+
...client,
|
|
1330
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
|
|
1331
|
+
submits += 1
|
|
1332
|
+
if (body.request_id === frozen.request_id) {
|
|
1333
|
+
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.', null, [
|
|
1334
|
+
{ path: 'presentation.detail', message: 'Unexpected property' },
|
|
1335
|
+
])
|
|
1336
|
+
}
|
|
1337
|
+
return client.submit(body, waitSeconds)
|
|
1338
|
+
},
|
|
1339
|
+
} as ApiClient
|
|
1340
|
+
}
|
|
1341
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1342
|
+
|
|
1343
|
+
expect(submits).toBe(2)
|
|
1344
|
+
expect(h.recorder.submitted[0]?.request_id).toBeDefined()
|
|
1345
|
+
expect(h.recorder.submitted[0]?.request_id).not.toBe(frozen.request_id)
|
|
1346
|
+
expect(h.io.errLines.join('\n')).toContain('reminting the draft')
|
|
1347
|
+
expect(readSessionState('rejected-submit', h.env).pending).toBeUndefined()
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
it('retires a reminted draft that the server still rejects', async () => {
|
|
1220
1351
|
const h = harness([])
|
|
1221
1352
|
const factory = h.deps.clientFactory
|
|
1222
|
-
|
|
1353
|
+
h.deps.clientFactory = () => {
|
|
1354
|
+
const client = factory!()
|
|
1355
|
+
return {
|
|
1356
|
+
...client,
|
|
1357
|
+
submit: async () => {
|
|
1358
|
+
throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
|
|
1359
|
+
},
|
|
1360
|
+
} as ApiClient
|
|
1361
|
+
}
|
|
1362
|
+
writeSessionState('rejected-forever', h.env, { last_prompt_at: AWAY })
|
|
1363
|
+
registerQuestion('rejected-forever', h.env, { question: 'Deploy?' }, NOW)
|
|
1364
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
|
|
1365
|
+
expect(readSessionState('rejected-forever', h.env).pending?.[0]?.submission).toBeDefined()
|
|
1366
|
+
|
|
1223
1367
|
h.deps.clientFactory = () => {
|
|
1224
1368
|
const client = factory!()
|
|
1225
1369
|
return {
|
|
@@ -1227,29 +1371,72 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1227
1371
|
submit: async () => {
|
|
1228
1372
|
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1229
1373
|
},
|
|
1374
|
+
} as ApiClient
|
|
1375
|
+
}
|
|
1376
|
+
h.advanceClock(600_000)
|
|
1377
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
|
|
1378
|
+
expect(readSessionState('rejected-forever', h.env).pending).toBeUndefined()
|
|
1379
|
+
|
|
1380
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
|
|
1381
|
+
expect(h.recorder.submitted).toEqual([])
|
|
1382
|
+
expect(h.io.errLines.join('\n')).toContain('retiring the question so it is not retried forever')
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
it('retires a freshly minted draft the current contract rejects', async () => {
|
|
1386
|
+
const h = harness([])
|
|
1387
|
+
const factory = h.deps.clientFactory
|
|
1388
|
+
h.deps.clientFactory = () => {
|
|
1389
|
+
const client = factory!()
|
|
1390
|
+
return {
|
|
1391
|
+
...client,
|
|
1392
|
+
submit: async () => {
|
|
1393
|
+
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1394
|
+
},
|
|
1395
|
+
} as ApiClient
|
|
1396
|
+
}
|
|
1397
|
+
writeSessionState('fresh-422', h.env, { last_prompt_at: AWAY })
|
|
1398
|
+
registerQuestion('fresh-422', h.env, { question: 'Deploy?' }, NOW)
|
|
1399
|
+
|
|
1400
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fresh-422' }))
|
|
1401
|
+
|
|
1402
|
+
expect(readSessionState('fresh-422', h.env).pending).toBeUndefined()
|
|
1403
|
+
expect(h.io.errLines.join('\n')).toContain('retiring it because the current draft will never be accepted')
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
it('preserves a frozen intent after a retryable client rejection', async () => {
|
|
1407
|
+
const h = harness([])
|
|
1408
|
+
const factory = h.deps.clientFactory
|
|
1409
|
+
let polls = 0
|
|
1410
|
+
h.deps.clientFactory = () => {
|
|
1411
|
+
const client = factory!()
|
|
1412
|
+
return {
|
|
1413
|
+
...client,
|
|
1414
|
+
submit: async () => {
|
|
1415
|
+
throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
|
|
1416
|
+
},
|
|
1230
1417
|
replies: async () => {
|
|
1231
1418
|
polls += 1
|
|
1232
1419
|
throw new Error('must not poll an unaccepted request')
|
|
1233
1420
|
},
|
|
1234
1421
|
} as ApiClient
|
|
1235
1422
|
}
|
|
1236
|
-
writeSessionState('
|
|
1237
|
-
registerQuestion('
|
|
1423
|
+
writeSessionState('auth-submit', h.env, { last_prompt_at: AWAY })
|
|
1424
|
+
registerQuestion('auth-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1238
1425
|
|
|
1239
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1426
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
|
|
1240
1427
|
|
|
1241
|
-
const pending = readSessionState('
|
|
1428
|
+
const pending = readSessionState('auth-submit', h.env).pending?.[0]
|
|
1242
1429
|
expect(pending?.request_id).toBeUndefined()
|
|
1243
1430
|
expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1244
1431
|
expect(polls).toBe(0)
|
|
1245
|
-
expect(h.io.errLines.join('\n')).toContain('
|
|
1432
|
+
expect(h.io.errLines.join('\n')).toContain('preserving it for recovery')
|
|
1246
1433
|
|
|
1247
1434
|
const frozen = pending?.submission
|
|
1248
1435
|
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1249
1436
|
writeGlobalConfig(h, 'project = "changed-after-crash"\n')
|
|
1250
1437
|
h.advanceClock(600_000)
|
|
1251
1438
|
h.deps.clientFactory = factory
|
|
1252
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1439
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
|
|
1253
1440
|
|
|
1254
1441
|
expect(h.recorder.submitted[0]).toEqual({
|
|
1255
1442
|
request_id: frozen.request_id,
|
|
@@ -1284,7 +1471,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1284
1471
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
1285
1472
|
})
|
|
1286
1473
|
|
|
1287
|
-
it('charges sequential submission latency to one deadline', async () => {
|
|
1474
|
+
it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
|
|
1288
1475
|
const h = harness([reply({ text: 'Done' })])
|
|
1289
1476
|
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
1290
1477
|
registerQuestion('latency', h.env, { question: 'First?' }, NOW)
|
|
@@ -1296,9 +1483,12 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1296
1483
|
const windows = h.recorder.submitted
|
|
1297
1484
|
.filter((entry) => entry.draft.event === 'agent_question')
|
|
1298
1485
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
|
|
1486
|
+
// How long the user may answer does not shrink because this owner spent
|
|
1487
|
+
// part of its own budget getting the question out. Both questions stay
|
|
1488
|
+
// answerable for the configured window.
|
|
1489
|
+
expect(windows).toEqual([86_400, 86_400])
|
|
1490
|
+
// The owner's own budget is still one shared deadline, and it is still
|
|
1491
|
+
// charged for the latency: the whole pass fits inside the waiter ceiling.
|
|
1302
1492
|
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
1303
1493
|
})
|
|
1304
1494
|
|
|
@@ -1763,7 +1953,7 @@ describe('several questions in flight', () => {
|
|
|
1763
1953
|
|
|
1764
1954
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
|
|
1765
1955
|
expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
|
|
1766
|
-
{ request_id: 'req_ack_required', recorded_at: NOW },
|
|
1956
|
+
{ request_id: 'req_ack_required', recorded_at: NOW, text_required: true },
|
|
1767
1957
|
])
|
|
1768
1958
|
|
|
1769
1959
|
h.io.outLines = []
|
|
@@ -1783,6 +1973,66 @@ describe('several questions in flight', () => {
|
|
|
1783
1973
|
expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
|
|
1784
1974
|
})
|
|
1785
1975
|
|
|
1976
|
+
it('still blocks Stop, without asking for text, when the account turned text off', async () => {
|
|
1977
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1978
|
+
h.recorder.acknowledgementTextRequiredFor = () => false
|
|
1979
|
+
writeSessionState('ack-textless', h.env, {
|
|
1980
|
+
pending: [
|
|
1981
|
+
{
|
|
1982
|
+
question: 'Ship it?',
|
|
1983
|
+
request_id: 'req_ack_textless',
|
|
1984
|
+
collapse_key: 'collapse-ack-textless',
|
|
1985
|
+
device_ids: ['dev_iphone'],
|
|
1986
|
+
reply_deadline_at: NOW + 60_000,
|
|
1987
|
+
},
|
|
1988
|
+
],
|
|
1989
|
+
})
|
|
1990
|
+
|
|
1991
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-textless' }))
|
|
1992
|
+
// The obligation is unchanged by the preference: the acknowledgement still
|
|
1993
|
+
// has to happen, because the user's read-state depends on it.
|
|
1994
|
+
expect(readSessionState('ack-textless', h.env).acknowledgement_due).toEqual([
|
|
1995
|
+
{ request_id: 'req_ack_textless', recorded_at: NOW, text_required: false },
|
|
1996
|
+
])
|
|
1997
|
+
|
|
1998
|
+
h.io.outLines = []
|
|
1999
|
+
await hookRunCommand(
|
|
2000
|
+
h.deps,
|
|
2001
|
+
'stop',
|
|
2002
|
+
stdin({ session_id: 'ack-textless', stop_hook_active: true }),
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
2006
|
+
expect(blocked.decision).toBe('block')
|
|
2007
|
+
expect(blocked.reason).toContain('notifai acknowledge req_ack_textless`')
|
|
2008
|
+
expect(blocked.reason).not.toContain('--text')
|
|
2009
|
+
})
|
|
2010
|
+
|
|
2011
|
+
it('gives up holding the turn rather than wedging the session for ever', async () => {
|
|
2012
|
+
// The acknowledgement gate is the one place hooks break the fail-open rule.
|
|
2013
|
+
// Unbounded, an agent that never acknowledges — or a server that stays
|
|
2014
|
+
// unreachable, since an error counts as unresolved — held every turn of
|
|
2015
|
+
// this session for good.
|
|
2016
|
+
const h = harness([])
|
|
2017
|
+
writeSessionState('ack-wedge', h.env, {
|
|
2018
|
+
acknowledgement_due: [{ request_id: 'req_wedge', recorded_at: NOW }],
|
|
2019
|
+
})
|
|
2020
|
+
|
|
2021
|
+
const blocked: unknown[] = []
|
|
2022
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
2023
|
+
h.io.outLines.length = 0
|
|
2024
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
|
|
2025
|
+
blocked.push(h.io.outLines.length > 0)
|
|
2026
|
+
}
|
|
2027
|
+
expect(blocked).toEqual([true, true, true])
|
|
2028
|
+
|
|
2029
|
+
// The fourth turn is let through, and the obligation stops being owed.
|
|
2030
|
+
h.io.outLines.length = 0
|
|
2031
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
|
|
2032
|
+
expect(h.io.outLines).toEqual([])
|
|
2033
|
+
expect(readSessionState('ack-wedge', h.env).acknowledgement_due).toBeUndefined()
|
|
2034
|
+
})
|
|
2035
|
+
|
|
1786
2036
|
it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
|
|
1787
2037
|
const h = harness([])
|
|
1788
2038
|
writeSessionState('ack-heal', h.env, {
|
|
@@ -1953,7 +2203,7 @@ describe('a question that outlives its session', () => {
|
|
|
1953
2203
|
expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
|
|
1954
2204
|
})
|
|
1955
2205
|
|
|
1956
|
-
it('gives up on an orphan
|
|
2206
|
+
it('gives up on an orphan that outlived the longest answerable window', async () => {
|
|
1957
2207
|
const h = harness([])
|
|
1958
2208
|
orphanRetirements(
|
|
1959
2209
|
h.env,
|
|
@@ -1964,7 +2214,7 @@ describe('a question that outlives its session', () => {
|
|
|
1964
2214
|
question: 'Old?',
|
|
1965
2215
|
state: 'expired',
|
|
1966
2216
|
}],
|
|
1967
|
-
NOW -
|
|
2217
|
+
NOW - (REPLY_MAX_WINDOW_SECONDS + 2 * 3600) * 1000,
|
|
1968
2218
|
)
|
|
1969
2219
|
const drained = await drainOrphanRetirements(
|
|
1970
2220
|
{ client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
|
|
@@ -2427,6 +2677,58 @@ describe('user-prompt-submit hook', () => {
|
|
|
2427
2677
|
clearSessionState('s14', h.env)
|
|
2428
2678
|
expect(readProjectSession(project, h.env, NOW)).toBeNull()
|
|
2429
2679
|
})
|
|
2680
|
+
|
|
2681
|
+
it('round-trips future project-pointer fields while publishing a session', () => {
|
|
2682
|
+
const h = harness()
|
|
2683
|
+
const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
2684
|
+
const futureEntry = {
|
|
2685
|
+
format: 2,
|
|
2686
|
+
session: { id: 'future-session', runtime: 'future-harness' },
|
|
2687
|
+
}
|
|
2688
|
+
mkdirSync(path.dirname(pointer), { recursive: true })
|
|
2689
|
+
writeFileSync(
|
|
2690
|
+
pointer,
|
|
2691
|
+
`${JSON.stringify({
|
|
2692
|
+
future_root: { retained: true },
|
|
2693
|
+
sessions: [
|
|
2694
|
+
{
|
|
2695
|
+
session_id: 'still-running',
|
|
2696
|
+
updated_at: NOW - 1,
|
|
2697
|
+
harness: 'codex',
|
|
2698
|
+
future_entry: { retained: true },
|
|
2699
|
+
},
|
|
2700
|
+
{
|
|
2701
|
+
session_id: 'new-session',
|
|
2702
|
+
updated_at: NOW - 2,
|
|
2703
|
+
harness: 'claude-code',
|
|
2704
|
+
future_current_entry: { retained: true },
|
|
2705
|
+
},
|
|
2706
|
+
futureEntry,
|
|
2707
|
+
],
|
|
2708
|
+
})}\n`,
|
|
2709
|
+
)
|
|
2710
|
+
|
|
2711
|
+
writeProjectSession(h.deps.cwd, h.env, 'new-session', NOW, 'claude-code')
|
|
2712
|
+
|
|
2713
|
+
expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
|
|
2714
|
+
future_root: { retained: true },
|
|
2715
|
+
sessions: [
|
|
2716
|
+
{
|
|
2717
|
+
session_id: 'still-running',
|
|
2718
|
+
updated_at: NOW - 1,
|
|
2719
|
+
harness: 'codex',
|
|
2720
|
+
future_entry: { retained: true },
|
|
2721
|
+
},
|
|
2722
|
+
futureEntry,
|
|
2723
|
+
{
|
|
2724
|
+
session_id: 'new-session',
|
|
2725
|
+
updated_at: NOW,
|
|
2726
|
+
harness: 'claude-code',
|
|
2727
|
+
future_current_entry: { retained: true },
|
|
2728
|
+
},
|
|
2729
|
+
],
|
|
2730
|
+
})
|
|
2731
|
+
})
|
|
2430
2732
|
})
|
|
2431
2733
|
|
|
2432
2734
|
describe('session-end hook', () => {
|
|
@@ -2445,6 +2747,36 @@ describe('session-end hook', () => {
|
|
|
2445
2747
|
expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
|
|
2446
2748
|
})
|
|
2447
2749
|
|
|
2750
|
+
it('round-trips future session-state fields when SessionEnd preserves an obligation', async () => {
|
|
2751
|
+
const h = harness()
|
|
2752
|
+
const sessionId = 'session-end-future-state'
|
|
2753
|
+
const stateFile = path.join(
|
|
2754
|
+
stateDir(h.env),
|
|
2755
|
+
'sessions',
|
|
2756
|
+
`${sanitizeSessionId(sessionId)}.json`,
|
|
2757
|
+
)
|
|
2758
|
+
const acknowledgement = {
|
|
2759
|
+
request_id: 'req_future_ack',
|
|
2760
|
+
recorded_at: NOW,
|
|
2761
|
+
future_entry: { retained: true },
|
|
2762
|
+
}
|
|
2763
|
+
mkdirSync(path.dirname(stateFile), { recursive: true })
|
|
2764
|
+
writeFileSync(
|
|
2765
|
+
stateFile,
|
|
2766
|
+
`${JSON.stringify({
|
|
2767
|
+
future_root: { retained: true },
|
|
2768
|
+
acknowledgement_due: [acknowledgement],
|
|
2769
|
+
})}\n`,
|
|
2770
|
+
)
|
|
2771
|
+
|
|
2772
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: sessionId }))
|
|
2773
|
+
|
|
2774
|
+
expect(JSON.parse(readFileSync(stateFile, 'utf8'))).toEqual({
|
|
2775
|
+
future_root: { retained: true },
|
|
2776
|
+
acknowledgement_due: [acknowledgement],
|
|
2777
|
+
})
|
|
2778
|
+
})
|
|
2779
|
+
|
|
2448
2780
|
it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
|
|
2449
2781
|
const h = harness()
|
|
2450
2782
|
const sessionId = 'session-end-budget'
|
|
@@ -2561,6 +2893,54 @@ describe('session-end hook', () => {
|
|
|
2561
2893
|
).toEqual({ sessionId: 'still-running', harness: 'codex' })
|
|
2562
2894
|
})
|
|
2563
2895
|
|
|
2896
|
+
it('keeps future project-pointer data when clearing the ending session', async () => {
|
|
2897
|
+
const h = harness()
|
|
2898
|
+
const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
2899
|
+
const futureEntry = {
|
|
2900
|
+
format: 2,
|
|
2901
|
+
session: { id: 'future-session', runtime: 'future-harness' },
|
|
2902
|
+
}
|
|
2903
|
+
writeSessionState('still-running', h.env, { last_prompt_at: NOW })
|
|
2904
|
+
writeSessionState('ending', h.env, { last_prompt_at: NOW })
|
|
2905
|
+
mkdirSync(path.dirname(pointer), { recursive: true })
|
|
2906
|
+
writeFileSync(
|
|
2907
|
+
pointer,
|
|
2908
|
+
`${JSON.stringify({
|
|
2909
|
+
future_root: { retained: true },
|
|
2910
|
+
sessions: [
|
|
2911
|
+
{
|
|
2912
|
+
session_id: 'still-running',
|
|
2913
|
+
updated_at: NOW - 1,
|
|
2914
|
+
harness: 'codex',
|
|
2915
|
+
future_entry: { retained: true },
|
|
2916
|
+
},
|
|
2917
|
+
futureEntry,
|
|
2918
|
+
{ session_id: 'ending', updated_at: NOW, harness: 'claude-code' },
|
|
2919
|
+
],
|
|
2920
|
+
})}\n`,
|
|
2921
|
+
)
|
|
2922
|
+
|
|
2923
|
+
await hookRunCommand(
|
|
2924
|
+
h.deps,
|
|
2925
|
+
'session-end',
|
|
2926
|
+
stdin({ session_id: 'ending', cwd: h.deps.cwd }),
|
|
2927
|
+
'claude-code',
|
|
2928
|
+
)
|
|
2929
|
+
|
|
2930
|
+
expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
|
|
2931
|
+
future_root: { retained: true },
|
|
2932
|
+
sessions: [
|
|
2933
|
+
{
|
|
2934
|
+
session_id: 'still-running',
|
|
2935
|
+
updated_at: NOW - 1,
|
|
2936
|
+
harness: 'codex',
|
|
2937
|
+
future_entry: { retained: true },
|
|
2938
|
+
},
|
|
2939
|
+
futureEntry,
|
|
2940
|
+
],
|
|
2941
|
+
})
|
|
2942
|
+
})
|
|
2943
|
+
|
|
2564
2944
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
2565
2945
|
const h = harness()
|
|
2566
2946
|
writeSessionState('s-incomplete', h.env, {
|
|
@@ -2592,6 +2972,164 @@ describe('malformed input', () => {
|
|
|
2592
2972
|
})
|
|
2593
2973
|
|
|
2594
2974
|
describe('telling concurrent agents apart', () => {
|
|
2975
|
+
it('uses each hook event branch while keeping one immutable session identity', async () => {
|
|
2976
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
2977
|
+
initializeFixtureRepository(h.deps.cwd)
|
|
2978
|
+
registerQuestion(
|
|
2979
|
+
'session-branch-transition',
|
|
2980
|
+
h.env,
|
|
2981
|
+
{
|
|
2982
|
+
question: 'Ship it?',
|
|
2983
|
+
source: {
|
|
2984
|
+
session_id: 'session-branch-transition',
|
|
2985
|
+
session_label: 'Branch transition',
|
|
2986
|
+
harness: 'claude-code',
|
|
2987
|
+
branch: 'branch-a',
|
|
2988
|
+
},
|
|
2989
|
+
},
|
|
2990
|
+
NOW,
|
|
2991
|
+
)
|
|
2992
|
+
|
|
2993
|
+
runFixtureGit(h.deps.cwd, 'checkout', '-b', 'branch-b')
|
|
2994
|
+
await hookRunCommand(
|
|
2995
|
+
h.deps,
|
|
2996
|
+
'stop',
|
|
2997
|
+
stdin({ session_id: 'session-branch-transition', cwd: h.deps.cwd }),
|
|
2998
|
+
'claude-code',
|
|
2999
|
+
)
|
|
3000
|
+
|
|
3001
|
+
const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
|
|
3002
|
+
expect(question?.draft.source).toEqual({
|
|
3003
|
+
session_id: 'session-branch-transition',
|
|
3004
|
+
session_label: 'Branch transition',
|
|
3005
|
+
harness: 'claude-code',
|
|
3006
|
+
branch: 'branch-b',
|
|
3007
|
+
})
|
|
3008
|
+
|
|
3009
|
+
runFixtureGit(h.deps.cwd, 'checkout', 'branch-a')
|
|
3010
|
+
h.recorder.acknowledged ??= new Set()
|
|
3011
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
3012
|
+
await hookRunCommand(
|
|
3013
|
+
h.deps,
|
|
3014
|
+
'stop',
|
|
3015
|
+
stdin({
|
|
3016
|
+
session_id: 'session-branch-transition',
|
|
3017
|
+
cwd: h.deps.cwd,
|
|
3018
|
+
stop_hook_active: true,
|
|
3019
|
+
}),
|
|
3020
|
+
'claude-code',
|
|
3021
|
+
)
|
|
3022
|
+
|
|
3023
|
+
const retirement = h.recorder.submitted.find(
|
|
3024
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
3025
|
+
)
|
|
3026
|
+
expect(retirement?.draft.source).toEqual({
|
|
3027
|
+
session_id: 'session-branch-transition',
|
|
3028
|
+
session_label: 'Branch transition',
|
|
3029
|
+
harness: 'claude-code',
|
|
3030
|
+
branch: 'branch-a',
|
|
3031
|
+
})
|
|
3032
|
+
})
|
|
3033
|
+
|
|
3034
|
+
it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
|
|
3035
|
+
const h = harness([])
|
|
3036
|
+
initializeFixtureRepository(h.deps.cwd)
|
|
3037
|
+
registerQuestion(
|
|
3038
|
+
'session-detached',
|
|
3039
|
+
h.env,
|
|
3040
|
+
{
|
|
3041
|
+
question: 'Ship it?',
|
|
3042
|
+
source: {
|
|
3043
|
+
session_id: 'session-detached',
|
|
3044
|
+
session_label: 'Detached review',
|
|
3045
|
+
harness: 'claude-code',
|
|
3046
|
+
branch: 'branch-a',
|
|
3047
|
+
worktree: 'stale-worktree',
|
|
3048
|
+
},
|
|
3049
|
+
},
|
|
3050
|
+
NOW,
|
|
3051
|
+
)
|
|
3052
|
+
runFixtureGit(h.deps.cwd, 'checkout', '--detach', 'HEAD')
|
|
3053
|
+
|
|
3054
|
+
await hookRunCommand(
|
|
3055
|
+
h.deps,
|
|
3056
|
+
'stop',
|
|
3057
|
+
stdin({ session_id: 'session-detached', cwd: h.deps.cwd }),
|
|
3058
|
+
'claude-code',
|
|
3059
|
+
)
|
|
3060
|
+
|
|
3061
|
+
const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
|
|
3062
|
+
expect(question?.draft.source).toEqual({
|
|
3063
|
+
session_id: 'session-detached',
|
|
3064
|
+
session_label: 'Detached review',
|
|
3065
|
+
harness: 'claude-code',
|
|
3066
|
+
})
|
|
3067
|
+
})
|
|
3068
|
+
|
|
3069
|
+
it('captures entry into and exit from a linked worktree on the same session', async () => {
|
|
3070
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
3071
|
+
initializeFixtureRepository(h.deps.cwd, 'main')
|
|
3072
|
+
const linked = `${h.deps.cwd}-linked`
|
|
3073
|
+
runFixtureGit(h.deps.cwd, 'worktree', 'add', '-b', 'worktree-branch', linked)
|
|
3074
|
+
try {
|
|
3075
|
+
registerQuestion(
|
|
3076
|
+
'session-worktree-transition',
|
|
3077
|
+
h.env,
|
|
3078
|
+
{
|
|
3079
|
+
question: 'Ship it?',
|
|
3080
|
+
source: {
|
|
3081
|
+
session_id: 'session-worktree-transition',
|
|
3082
|
+
session_label: 'Worktree transition',
|
|
3083
|
+
harness: 'claude-code',
|
|
3084
|
+
branch: 'main',
|
|
3085
|
+
},
|
|
3086
|
+
},
|
|
3087
|
+
NOW,
|
|
3088
|
+
)
|
|
3089
|
+
|
|
3090
|
+
await hookRunCommand(
|
|
3091
|
+
h.deps,
|
|
3092
|
+
'stop',
|
|
3093
|
+
stdin({ session_id: 'session-worktree-transition', cwd: linked }),
|
|
3094
|
+
'claude-code',
|
|
3095
|
+
)
|
|
3096
|
+
const question = h.recorder.submitted.find(
|
|
3097
|
+
(entry) => entry.draft.event === 'agent_question',
|
|
3098
|
+
)
|
|
3099
|
+
expect(question?.draft.source).toEqual({
|
|
3100
|
+
session_id: 'session-worktree-transition',
|
|
3101
|
+
session_label: 'Worktree transition',
|
|
3102
|
+
harness: 'claude-code',
|
|
3103
|
+
branch: 'worktree-branch',
|
|
3104
|
+
worktree: path.basename(linked),
|
|
3105
|
+
})
|
|
3106
|
+
|
|
3107
|
+
h.recorder.acknowledged ??= new Set()
|
|
3108
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
3109
|
+
await hookRunCommand(
|
|
3110
|
+
h.deps,
|
|
3111
|
+
'stop',
|
|
3112
|
+
stdin({
|
|
3113
|
+
session_id: 'session-worktree-transition',
|
|
3114
|
+
cwd: h.deps.cwd,
|
|
3115
|
+
stop_hook_active: true,
|
|
3116
|
+
}),
|
|
3117
|
+
'claude-code',
|
|
3118
|
+
)
|
|
3119
|
+
const retirement = h.recorder.submitted.find(
|
|
3120
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
3121
|
+
)
|
|
3122
|
+
expect(retirement?.draft.source).toEqual({
|
|
3123
|
+
session_id: 'session-worktree-transition',
|
|
3124
|
+
session_label: 'Worktree transition',
|
|
3125
|
+
harness: 'claude-code',
|
|
3126
|
+
branch: 'main',
|
|
3127
|
+
})
|
|
3128
|
+
} finally {
|
|
3129
|
+
runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
|
|
3130
|
+
}
|
|
3131
|
+
})
|
|
3132
|
+
|
|
2595
3133
|
it('stamps structured Source Context on the question it pushes', async () => {
|
|
2596
3134
|
const h = harness([])
|
|
2597
3135
|
registerQuestion(
|
|
@@ -3561,6 +4099,47 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3561
4099
|
expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
|
|
3562
4100
|
})
|
|
3563
4101
|
|
|
4102
|
+
it('does not spend the delivery cap on turns where nothing was handed over', async () => {
|
|
4103
|
+
// The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
|
|
4104
|
+
// where a liveness probe could not reach a busy session were enough to
|
|
4105
|
+
// settle an answer the user had already given, unread.
|
|
4106
|
+
const h = harness([])
|
|
4107
|
+
const sessionId = 'waiter-transient-holds'
|
|
4108
|
+
journaled(h, sessionId)
|
|
4109
|
+
const context = waiterContext(h)
|
|
4110
|
+
let handedOver = 0
|
|
4111
|
+
let pass = 0
|
|
4112
|
+
|
|
4113
|
+
for (; pass < MAX_CONTINUATION_COUNT + 2; pass += 1) {
|
|
4114
|
+
await runEscalationWaiter(context, {
|
|
4115
|
+
sessionId,
|
|
4116
|
+
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
4117
|
+
route: {
|
|
4118
|
+
kind: 'inbox-socket',
|
|
4119
|
+
deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
|
|
4120
|
+
},
|
|
4121
|
+
processDeadlineAt: NOW + 480_000,
|
|
4122
|
+
})
|
|
4123
|
+
}
|
|
4124
|
+
// Still journaled after more holds than the delivery cap allows.
|
|
4125
|
+
expect(readSessionState(sessionId, h.env).accepted).toBeDefined()
|
|
4126
|
+
|
|
4127
|
+
// And when a route can finally take it, it is delivered rather than lost.
|
|
4128
|
+
await runEscalationWaiter(context, {
|
|
4129
|
+
sessionId,
|
|
4130
|
+
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
4131
|
+
route: {
|
|
4132
|
+
kind: 'inbox-socket',
|
|
4133
|
+
deliver: async () => {
|
|
4134
|
+
handedOver += 1
|
|
4135
|
+
return { notes: [], acknowledgement: 'delivered' as const }
|
|
4136
|
+
},
|
|
4137
|
+
},
|
|
4138
|
+
processDeadlineAt: NOW + 480_000,
|
|
4139
|
+
})
|
|
4140
|
+
expect(handedOver).toBe(1)
|
|
4141
|
+
})
|
|
4142
|
+
|
|
3564
4143
|
it('bounds deliveries by the continuation cap on every route', async () => {
|
|
3565
4144
|
// A route that never acknowledges must still stop. The cap lives where all
|
|
3566
4145
|
// routes pass, so no future route can be the one it does not cover.
|
|
@@ -3572,7 +4151,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3572
4151
|
let deliveries = 0
|
|
3573
4152
|
const notes: string[] = []
|
|
3574
4153
|
|
|
3575
|
-
for (let pass = 0; pass <
|
|
4154
|
+
for (let pass = 0; pass < MAX_HELD_DELIVERIES + 3; pass += 1) {
|
|
3576
4155
|
const outcome = await runEscalationWaiter(context, {
|
|
3577
4156
|
sessionId,
|
|
3578
4157
|
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
@@ -3588,9 +4167,11 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3588
4167
|
notes.push(...outcome.notes)
|
|
3589
4168
|
}
|
|
3590
4169
|
|
|
3591
|
-
|
|
4170
|
+
// A hold is not a delivery — nothing was handed over — so it does not
|
|
4171
|
+
// spend the wake-loop cap. It still has to end, on its own looser bound.
|
|
4172
|
+
expect(deliveries).toBe(MAX_HELD_DELIVERIES)
|
|
3592
4173
|
expect(notes.join('\n')).toContain(
|
|
3593
|
-
|
|
4174
|
+
`could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns`,
|
|
3594
4175
|
)
|
|
3595
4176
|
expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
|
|
3596
4177
|
}
|
|
@@ -3624,3 +4205,99 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3624
4205
|
releaseQuestionPush('waiter-failed-route', h.env)
|
|
3625
4206
|
})
|
|
3626
4207
|
})
|
|
4208
|
+
|
|
4209
|
+
|
|
4210
|
+
describe('session state across a prompt', () => {
|
|
4211
|
+
it('carries known and future session state across a typed prompt', async () => {
|
|
4212
|
+
// The prompt transition deliberately changes pending/retiring state and the
|
|
4213
|
+
// continuation count, but fields introduced by a newer CLI remain opaque.
|
|
4214
|
+
const h = harness([])
|
|
4215
|
+
const before = {
|
|
4216
|
+
last_prompt_at: AWAY,
|
|
4217
|
+
last_stop_at: NOW - 1_000,
|
|
4218
|
+
retiring: [
|
|
4219
|
+
{
|
|
4220
|
+
request_id: 'req_carry',
|
|
4221
|
+
collapse_key: 'collapse-carry',
|
|
4222
|
+
device_ids: ['dev_iphone'],
|
|
4223
|
+
question: 'Carried?',
|
|
4224
|
+
state: 'expired' as const,
|
|
4225
|
+
},
|
|
4226
|
+
],
|
|
4227
|
+
continuation: { answered_at: NOW - 2_000, count: 2 },
|
|
4228
|
+
acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
|
|
4229
|
+
acknowledgement_blocks: 2,
|
|
4230
|
+
}
|
|
4231
|
+
const futureSessionValue = { nested: ['kept'], revision: 2 }
|
|
4232
|
+
writeSessionState('carry-all', h.env, {
|
|
4233
|
+
...before,
|
|
4234
|
+
future_session_key: futureSessionValue,
|
|
4235
|
+
} as SessionState & { future_session_key: typeof futureSessionValue })
|
|
4236
|
+
|
|
4237
|
+
await hookRunCommand(
|
|
4238
|
+
h.deps,
|
|
4239
|
+
'user-prompt-submit',
|
|
4240
|
+
stdin({ session_id: 'carry-all', cwd: h.deps.cwd }),
|
|
4241
|
+
)
|
|
4242
|
+
|
|
4243
|
+
const after = readSessionState('carry-all', h.env)
|
|
4244
|
+
for (const key of Object.keys(before) as (keyof typeof before)[]) {
|
|
4245
|
+
expect(after[key], `${key} did not survive the prompt`).toBeDefined()
|
|
4246
|
+
}
|
|
4247
|
+
const persisted = JSON.parse(
|
|
4248
|
+
readFileSync(
|
|
4249
|
+
path.join(
|
|
4250
|
+
stateDir(h.env),
|
|
4251
|
+
'sessions',
|
|
4252
|
+
`${sanitizeSessionId('carry-all')}.json`,
|
|
4253
|
+
),
|
|
4254
|
+
'utf8',
|
|
4255
|
+
),
|
|
4256
|
+
) as Record<string, unknown>
|
|
4257
|
+
expect(persisted['future_session_key']).toEqual(futureSessionValue)
|
|
4258
|
+
// The one field a prompt resets rather than carries.
|
|
4259
|
+
expect(after.continuation?.count).toBe(0)
|
|
4260
|
+
expect(after.acknowledgement_blocks).toBe(2)
|
|
4261
|
+
})
|
|
4262
|
+
})
|
|
4263
|
+
|
|
4264
|
+
describe('a question outliving the waiter that pushed it', () => {
|
|
4265
|
+
it('records when the server stops accepting, not when this owner stops waiting', async () => {
|
|
4266
|
+
// These were the same number until the window became a setting, and taking
|
|
4267
|
+
// the minimum capped every question at the waiter's ceiling: a day-long
|
|
4268
|
+
// window was swept as stale about eight minutes later and retired, so the
|
|
4269
|
+
// answer given over lunch met a closed question.
|
|
4270
|
+
const h = harness([])
|
|
4271
|
+
const aDayOut = NOW + 86_400_000
|
|
4272
|
+
h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
|
|
4273
|
+
writeSessionState('outlive', h.env, { last_prompt_at: AWAY })
|
|
4274
|
+
registerQuestion('outlive', h.env, { question: 'Ship it?' }, NOW)
|
|
4275
|
+
|
|
4276
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
|
|
4277
|
+
|
|
4278
|
+
const state = readSessionState('outlive', h.env)
|
|
4279
|
+
const live = state.pending?.[0]
|
|
4280
|
+
// Still live, still carrying the server's window — not the waiter's.
|
|
4281
|
+
expect(live?.request_id).toBeDefined()
|
|
4282
|
+
expect(live?.reply_deadline_at).toBe(aDayOut)
|
|
4283
|
+
// Not closed and not retired: those are what capped it at eight minutes.
|
|
4284
|
+
expect(state.retiring).toBeUndefined()
|
|
4285
|
+
expect(h.recorder.closed).toEqual([])
|
|
4286
|
+
// And the waiter still stopped at its own ceiling rather than waiting a day.
|
|
4287
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
4288
|
+
})
|
|
4289
|
+
|
|
4290
|
+
it('still closes a question the server has genuinely stopped accepting', async () => {
|
|
4291
|
+
const h = harness([])
|
|
4292
|
+
h.recorder.replyExpiresAt = new Date(NOW + 90_000).toISOString()
|
|
4293
|
+
writeSessionState('short-window', h.env, { last_prompt_at: AWAY })
|
|
4294
|
+
registerQuestion('short-window', h.env, { question: 'Ship it?' }, NOW)
|
|
4295
|
+
|
|
4296
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
|
|
4297
|
+
|
|
4298
|
+
// Its window closed inside the waiter's ceiling, so it is retired here
|
|
4299
|
+
// rather than left for a turn that could never collect it.
|
|
4300
|
+
expect(readSessionState('short-window', h.env).pending).toBeUndefined()
|
|
4301
|
+
expect(h.recorder.closed.length).toBe(1)
|
|
4302
|
+
})
|
|
4303
|
+
})
|