@raidiant/notifai 3.0.1 → 5.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 +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.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 +84 -12
- package/dist/hooks.js +433 -125
- 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 +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- 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/send.d.ts +38 -7
- package/dist/send.js +97 -41
- 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/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- 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 +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/hooks.test.ts
CHANGED
|
@@ -14,11 +14,12 @@ import {
|
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { PassThrough } from 'node:stream'
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
import {
|
|
18
|
+
sessionLabelFromId,
|
|
19
|
+
type ListRepliesResponse,
|
|
20
|
+
type ReplyView,
|
|
21
|
+
type SubmissionReceipt,
|
|
22
|
+
type SubmitNotificationRequestT,
|
|
22
23
|
} from '@raidiant/notifai-protocol'
|
|
23
24
|
import { describe, expect, it, vi } from 'vitest'
|
|
24
25
|
import { ApiCallError, type ApiClient } from './client.js'
|
|
@@ -48,16 +49,60 @@ import {
|
|
|
48
49
|
drainOrphanRetirements,
|
|
49
50
|
runEscalationWaiter,
|
|
50
51
|
MAX_CONTINUATION_COUNT,
|
|
52
|
+
MAX_HELD_DELIVERIES,
|
|
51
53
|
orphanRetirements,
|
|
52
54
|
pruneAbandonedSessions,
|
|
53
55
|
releaseQuestionPush,
|
|
54
56
|
readMatchingProjectSessionPointer,
|
|
55
57
|
readProjectSession,
|
|
56
58
|
readSessionState,
|
|
57
|
-
registerQuestion,
|
|
59
|
+
registerQuestion as persistQuestion,
|
|
58
60
|
writeProjectSession,
|
|
59
|
-
writeSessionState,
|
|
61
|
+
writeSessionState as persistSessionState,
|
|
62
|
+
type PendingQuestion,
|
|
63
|
+
type SessionState,
|
|
64
|
+
waiterCeilingSeconds,
|
|
65
|
+
WAITER_CEILING_SECONDS,
|
|
66
|
+
DETACHED_WAITER_CEILING_SECONDS,
|
|
60
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'
|
|
73
|
+
|
|
74
|
+
/** New-format test fixtures always carry the canonical body explicitly. */
|
|
75
|
+
function registerQuestion(
|
|
76
|
+
sessionId: string,
|
|
77
|
+
env: NodeJS.ProcessEnv,
|
|
78
|
+
question: PendingQuestion,
|
|
79
|
+
now?: number,
|
|
80
|
+
): void {
|
|
81
|
+
persistQuestion(
|
|
82
|
+
sessionId,
|
|
83
|
+
env,
|
|
84
|
+
{ ...question, body: question.body ?? question.question },
|
|
85
|
+
now,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeSessionState(
|
|
90
|
+
sessionId: string,
|
|
91
|
+
env: NodeJS.ProcessEnv,
|
|
92
|
+
state: SessionState,
|
|
93
|
+
): void {
|
|
94
|
+
persistSessionState(sessionId, env, {
|
|
95
|
+
...state,
|
|
96
|
+
...(state.pending === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: {
|
|
99
|
+
pending: state.pending.map((entry) => ({
|
|
100
|
+
...entry,
|
|
101
|
+
body: entry.body ?? entry.question,
|
|
102
|
+
})),
|
|
103
|
+
}),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
61
106
|
|
|
62
107
|
class CapturedIo implements CommandIo {
|
|
63
108
|
outLines: string[] = []
|
|
@@ -90,6 +135,10 @@ interface Recorder {
|
|
|
90
135
|
/** Per-request answer stream for multi-question ownership tests. */
|
|
91
136
|
repliesFor?: (requestId: string) => ReplyView[]
|
|
92
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
|
|
93
142
|
acknowledged?: Set<string>
|
|
94
143
|
acknowledgementChecks?: string[]
|
|
95
144
|
}
|
|
@@ -140,7 +189,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
140
189
|
return {
|
|
141
190
|
status: 'recorded',
|
|
142
191
|
agent_acknowledgement: {
|
|
143
|
-
text: body.text,
|
|
192
|
+
text: body.text ?? '',
|
|
144
193
|
created_at: new Date(NOW).toISOString(),
|
|
145
194
|
},
|
|
146
195
|
}
|
|
@@ -152,6 +201,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
152
201
|
return {
|
|
153
202
|
request_id: requestId,
|
|
154
203
|
agent_acknowledgement_required: required,
|
|
204
|
+
agent_acknowledgement_text_required:
|
|
205
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
155
206
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
156
207
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
157
208
|
: null,
|
|
@@ -171,9 +222,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
171
222
|
recorder.receipts.push(requestId)
|
|
172
223
|
return {
|
|
173
224
|
request_id: requestId,
|
|
174
|
-
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
225
|
+
reply_expires_at: recorder.replyExpiresAt ?? new Date(NOW + 480_000).toISOString(),
|
|
175
226
|
agent_acknowledgement_required:
|
|
176
227
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
228
|
+
agent_acknowledgement_text_required:
|
|
229
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
177
230
|
replayed: false,
|
|
178
231
|
overall: 'provider_accepted_all',
|
|
179
232
|
deliveries: [],
|
|
@@ -187,6 +240,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
187
240
|
reply_expires_at: null,
|
|
188
241
|
agent_acknowledgement_required:
|
|
189
242
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
243
|
+
agent_acknowledgement_text_required:
|
|
244
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
190
245
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
191
246
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
192
247
|
: null,
|
|
@@ -200,6 +255,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
200
255
|
reply_expires_at: new Date(NOW).toISOString(),
|
|
201
256
|
agent_acknowledgement_required:
|
|
202
257
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
258
|
+
agent_acknowledgement_text_required:
|
|
259
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
203
260
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
204
261
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
205
262
|
: null,
|
|
@@ -415,6 +472,27 @@ function writeGlobalConfig(h: Harness, toml: string): void {
|
|
|
415
472
|
/** Long enough ago that no test is accidentally sensitive to the stamp. */
|
|
416
473
|
const AWAY = NOW - 600_000
|
|
417
474
|
|
|
475
|
+
describe('the waiter wall clock', () => {
|
|
476
|
+
it('spends a held turn sparingly and a detached one generously', () => {
|
|
477
|
+
// A held turn is somebody's terminal, so the short ceiling is the cost of
|
|
478
|
+
// waiting. Detached, nothing is held, and the same number only shortens how
|
|
479
|
+
// long a direct wake stays possible.
|
|
480
|
+
expect(waiterCeilingSeconds(false)).toBe(WAITER_CEILING_SECONDS)
|
|
481
|
+
expect(waiterCeilingSeconds(true)).toBe(DETACHED_WAITER_CEILING_SECONDS)
|
|
482
|
+
expect(DETACHED_WAITER_CEILING_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('stays under the timeout the async Stop handler declares', () => {
|
|
486
|
+
// That declaration's kill is silent: past it the waiter vanishes and the
|
|
487
|
+
// answer the user already gave is never delivered.
|
|
488
|
+
expect(DETACHED_WAITER_CEILING_SECONDS).toBeLessThan(CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('leaves the blocking host headroom above the waiter it declares for', () => {
|
|
492
|
+
expect(BLOCKING_STOP_TIMEOUT_SECONDS).toBeGreaterThan(WAITER_CEILING_SECONDS)
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
418
496
|
describe('pushing a registered question', () => {
|
|
419
497
|
// Found by a live Claude Code session: a spawned agent always has a
|
|
420
498
|
// just-set last_prompt_at, and the routing gate once read that as the user
|
|
@@ -432,16 +510,39 @@ describe('pushing a registered question', () => {
|
|
|
432
510
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
433
511
|
})
|
|
434
512
|
expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
|
|
435
|
-
title: '
|
|
513
|
+
title: 'Ship it?',
|
|
436
514
|
body: 'Ship it?',
|
|
437
515
|
})
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
macos: { sound: 'attention', interruption_level: 'active' },
|
|
441
|
-
})
|
|
516
|
+
// A pushed question is a question, and a question has an attention tone.
|
|
517
|
+
expect(h.recorder.submitted[0]?.draft.platform?.ios?.sound).toBe('attention')
|
|
442
518
|
})
|
|
443
519
|
|
|
444
|
-
it('
|
|
520
|
+
it('keeps sparse alt text paired with its original media item', async () => {
|
|
521
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
522
|
+
writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
|
|
523
|
+
registerQuestion(
|
|
524
|
+
'media-order',
|
|
525
|
+
h.env,
|
|
526
|
+
{
|
|
527
|
+
question: 'Which image should ship?',
|
|
528
|
+
body: 'Compare .',
|
|
529
|
+
media: [
|
|
530
|
+
{ media_id: 'med_first' },
|
|
531
|
+
{ media_id: 'med_second', alt: 'Second image' },
|
|
532
|
+
],
|
|
533
|
+
},
|
|
534
|
+
NOW,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'media-order' }))
|
|
538
|
+
|
|
539
|
+
expect(h.recorder.submitted[0]?.draft.presentation.media).toEqual([
|
|
540
|
+
{ media_id: 'med_first' },
|
|
541
|
+
{ media_id: 'med_second', alt: 'Second image' },
|
|
542
|
+
])
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('keeps Project identity out of the substantive question title', async () => {
|
|
445
546
|
const h = harness([reply({ text: 'Yes' })])
|
|
446
547
|
const projectDir = path.join(h.deps.cwd, '.notifai')
|
|
447
548
|
mkdirSync(projectDir, { recursive: true })
|
|
@@ -450,11 +551,17 @@ describe('pushing a registered question', () => {
|
|
|
450
551
|
'project = "notifai-cli"\nask_grace_seconds = 0\n',
|
|
451
552
|
)
|
|
452
553
|
writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
|
|
453
|
-
registerQuestion(
|
|
554
|
+
registerQuestion(
|
|
555
|
+
'project-title',
|
|
556
|
+
h.env,
|
|
557
|
+
{ question: 'Ship it?', project: 'notifai-cli' },
|
|
558
|
+
NOW,
|
|
559
|
+
)
|
|
454
560
|
|
|
455
561
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
|
|
456
562
|
|
|
457
|
-
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('
|
|
563
|
+
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Ship it?')
|
|
564
|
+
expect(h.recorder.submitted[0]?.draft.project).toBe('notifai-cli')
|
|
458
565
|
})
|
|
459
566
|
})
|
|
460
567
|
|
|
@@ -981,6 +1088,12 @@ describe('late answer collection', () => {
|
|
|
981
1088
|
|
|
982
1089
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
|
|
983
1090
|
expect(JSON.parse(h.io.outLines.at(-1) ?? '{}').reason).toContain('Hold')
|
|
1091
|
+
// The obligation survives the user typing — that is the whole point of
|
|
1092
|
+
// recording it — so the turn after the answer is held for it. Satisfy it
|
|
1093
|
+
// the way the agent would, then the retirement proceeds.
|
|
1094
|
+
expect(readSessionState('late-prompt', h.env).acknowledgement_due).toHaveLength(1)
|
|
1095
|
+
h.recorder.acknowledged = new Set(['req_live'])
|
|
1096
|
+
|
|
984
1097
|
await hookRunCommand(
|
|
985
1098
|
h.deps,
|
|
986
1099
|
'stop',
|
|
@@ -1152,10 +1265,66 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1152
1265
|
expect(h.recorder.submitted[0]).toEqual(firstAttempt)
|
|
1153
1266
|
})
|
|
1154
1267
|
|
|
1155
|
-
it('
|
|
1268
|
+
it('remints a frozen draft after a terminal 422 instead of replaying it', async () => {
|
|
1269
|
+
const h = harness([])
|
|
1270
|
+
const factory = h.deps.clientFactory
|
|
1271
|
+
h.deps.clientFactory = () => {
|
|
1272
|
+
const client = factory!()
|
|
1273
|
+
return {
|
|
1274
|
+
...client,
|
|
1275
|
+
submit: async () => {
|
|
1276
|
+
throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
|
|
1277
|
+
},
|
|
1278
|
+
} as ApiClient
|
|
1279
|
+
}
|
|
1280
|
+
writeSessionState('rejected-submit', h.env, { last_prompt_at: AWAY })
|
|
1281
|
+
registerQuestion('rejected-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1282
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1283
|
+
const frozen = readSessionState('rejected-submit', h.env).pending?.[0]?.submission
|
|
1284
|
+
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1285
|
+
|
|
1286
|
+
let submits = 0
|
|
1287
|
+
h.deps.clientFactory = () => {
|
|
1288
|
+
const client = factory!()
|
|
1289
|
+
return {
|
|
1290
|
+
...client,
|
|
1291
|
+
submit: async (body: SubmitNotificationRequestT, waitSeconds: number) => {
|
|
1292
|
+
submits += 1
|
|
1293
|
+
if (body.request_id === frozen.request_id) {
|
|
1294
|
+
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.', null, [
|
|
1295
|
+
{ path: 'presentation.detail', message: 'Unexpected property' },
|
|
1296
|
+
])
|
|
1297
|
+
}
|
|
1298
|
+
return client.submit(body, waitSeconds)
|
|
1299
|
+
},
|
|
1300
|
+
} as ApiClient
|
|
1301
|
+
}
|
|
1302
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-submit' }))
|
|
1303
|
+
|
|
1304
|
+
expect(submits).toBe(2)
|
|
1305
|
+
expect(h.recorder.submitted[0]?.request_id).toBeDefined()
|
|
1306
|
+
expect(h.recorder.submitted[0]?.request_id).not.toBe(frozen.request_id)
|
|
1307
|
+
expect(h.io.errLines.join('\n')).toContain('reminting the draft')
|
|
1308
|
+
expect(readSessionState('rejected-submit', h.env).pending).toBeUndefined()
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
it('retires a reminted draft that the server still rejects', async () => {
|
|
1156
1312
|
const h = harness([])
|
|
1157
1313
|
const factory = h.deps.clientFactory
|
|
1158
|
-
|
|
1314
|
+
h.deps.clientFactory = () => {
|
|
1315
|
+
const client = factory!()
|
|
1316
|
+
return {
|
|
1317
|
+
...client,
|
|
1318
|
+
submit: async () => {
|
|
1319
|
+
throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
|
|
1320
|
+
},
|
|
1321
|
+
} as ApiClient
|
|
1322
|
+
}
|
|
1323
|
+
writeSessionState('rejected-forever', h.env, { last_prompt_at: AWAY })
|
|
1324
|
+
registerQuestion('rejected-forever', h.env, { question: 'Deploy?' }, NOW)
|
|
1325
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
|
|
1326
|
+
expect(readSessionState('rejected-forever', h.env).pending?.[0]?.submission).toBeDefined()
|
|
1327
|
+
|
|
1159
1328
|
h.deps.clientFactory = () => {
|
|
1160
1329
|
const client = factory!()
|
|
1161
1330
|
return {
|
|
@@ -1163,29 +1332,72 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1163
1332
|
submit: async () => {
|
|
1164
1333
|
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1165
1334
|
},
|
|
1335
|
+
} as ApiClient
|
|
1336
|
+
}
|
|
1337
|
+
h.advanceClock(600_000)
|
|
1338
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
|
|
1339
|
+
expect(readSessionState('rejected-forever', h.env).pending).toBeUndefined()
|
|
1340
|
+
|
|
1341
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'rejected-forever' }))
|
|
1342
|
+
expect(h.recorder.submitted).toEqual([])
|
|
1343
|
+
expect(h.io.errLines.join('\n')).toContain('retiring the question so it is not retried forever')
|
|
1344
|
+
})
|
|
1345
|
+
|
|
1346
|
+
it('retires a freshly minted draft the current contract rejects', async () => {
|
|
1347
|
+
const h = harness([])
|
|
1348
|
+
const factory = h.deps.clientFactory
|
|
1349
|
+
h.deps.clientFactory = () => {
|
|
1350
|
+
const client = factory!()
|
|
1351
|
+
return {
|
|
1352
|
+
...client,
|
|
1353
|
+
submit: async () => {
|
|
1354
|
+
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1355
|
+
},
|
|
1356
|
+
} as ApiClient
|
|
1357
|
+
}
|
|
1358
|
+
writeSessionState('fresh-422', h.env, { last_prompt_at: AWAY })
|
|
1359
|
+
registerQuestion('fresh-422', h.env, { question: 'Deploy?' }, NOW)
|
|
1360
|
+
|
|
1361
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'fresh-422' }))
|
|
1362
|
+
|
|
1363
|
+
expect(readSessionState('fresh-422', h.env).pending).toBeUndefined()
|
|
1364
|
+
expect(h.io.errLines.join('\n')).toContain('retiring it because the current draft will never be accepted')
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
it('preserves a frozen intent after a retryable client rejection', async () => {
|
|
1368
|
+
const h = harness([])
|
|
1369
|
+
const factory = h.deps.clientFactory
|
|
1370
|
+
let polls = 0
|
|
1371
|
+
h.deps.clientFactory = () => {
|
|
1372
|
+
const client = factory!()
|
|
1373
|
+
return {
|
|
1374
|
+
...client,
|
|
1375
|
+
submit: async () => {
|
|
1376
|
+
throw new ApiCallError(401, 'unauthorized', 'Sign in again.')
|
|
1377
|
+
},
|
|
1166
1378
|
replies: async () => {
|
|
1167
1379
|
polls += 1
|
|
1168
1380
|
throw new Error('must not poll an unaccepted request')
|
|
1169
1381
|
},
|
|
1170
1382
|
} as ApiClient
|
|
1171
1383
|
}
|
|
1172
|
-
writeSessionState('
|
|
1173
|
-
registerQuestion('
|
|
1384
|
+
writeSessionState('auth-submit', h.env, { last_prompt_at: AWAY })
|
|
1385
|
+
registerQuestion('auth-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1174
1386
|
|
|
1175
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1387
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
|
|
1176
1388
|
|
|
1177
|
-
const pending = readSessionState('
|
|
1389
|
+
const pending = readSessionState('auth-submit', h.env).pending?.[0]
|
|
1178
1390
|
expect(pending?.request_id).toBeUndefined()
|
|
1179
1391
|
expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1180
1392
|
expect(polls).toBe(0)
|
|
1181
|
-
expect(h.io.errLines.join('\n')).toContain('
|
|
1393
|
+
expect(h.io.errLines.join('\n')).toContain('preserving it for recovery')
|
|
1182
1394
|
|
|
1183
1395
|
const frozen = pending?.submission
|
|
1184
1396
|
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1185
1397
|
writeGlobalConfig(h, 'project = "changed-after-crash"\n')
|
|
1186
1398
|
h.advanceClock(600_000)
|
|
1187
1399
|
h.deps.clientFactory = factory
|
|
1188
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1400
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
|
|
1189
1401
|
|
|
1190
1402
|
expect(h.recorder.submitted[0]).toEqual({
|
|
1191
1403
|
request_id: frozen.request_id,
|
|
@@ -1220,7 +1432,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1220
1432
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
1221
1433
|
})
|
|
1222
1434
|
|
|
1223
|
-
it('charges sequential submission latency to one deadline', async () => {
|
|
1435
|
+
it('charges sequential submission latency to one owner deadline, not to the answer window', async () => {
|
|
1224
1436
|
const h = harness([reply({ text: 'Done' })])
|
|
1225
1437
|
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
1226
1438
|
registerQuestion('latency', h.env, { question: 'First?' }, NOW)
|
|
@@ -1232,9 +1444,12 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1232
1444
|
const windows = h.recorder.submitted
|
|
1233
1445
|
.filter((entry) => entry.draft.event === 'agent_question')
|
|
1234
1446
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1447
|
+
// How long the user may answer does not shrink because this owner spent
|
|
1448
|
+
// part of its own budget getting the question out. Both questions stay
|
|
1449
|
+
// answerable for the configured window.
|
|
1450
|
+
expect(windows).toEqual([86_400, 86_400])
|
|
1451
|
+
// The owner's own budget is still one shared deadline, and it is still
|
|
1452
|
+
// charged for the latency: the whole pass fits inside the waiter ceiling.
|
|
1238
1453
|
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
1239
1454
|
})
|
|
1240
1455
|
|
|
@@ -1699,7 +1914,7 @@ describe('several questions in flight', () => {
|
|
|
1699
1914
|
|
|
1700
1915
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
|
|
1701
1916
|
expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
|
|
1702
|
-
{ request_id: 'req_ack_required', recorded_at: NOW },
|
|
1917
|
+
{ request_id: 'req_ack_required', recorded_at: NOW, text_required: true },
|
|
1703
1918
|
])
|
|
1704
1919
|
|
|
1705
1920
|
h.io.outLines = []
|
|
@@ -1719,6 +1934,66 @@ describe('several questions in flight', () => {
|
|
|
1719
1934
|
expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
|
|
1720
1935
|
})
|
|
1721
1936
|
|
|
1937
|
+
it('still blocks Stop, without asking for text, when the account turned text off', async () => {
|
|
1938
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1939
|
+
h.recorder.acknowledgementTextRequiredFor = () => false
|
|
1940
|
+
writeSessionState('ack-textless', h.env, {
|
|
1941
|
+
pending: [
|
|
1942
|
+
{
|
|
1943
|
+
question: 'Ship it?',
|
|
1944
|
+
request_id: 'req_ack_textless',
|
|
1945
|
+
collapse_key: 'collapse-ack-textless',
|
|
1946
|
+
device_ids: ['dev_iphone'],
|
|
1947
|
+
reply_deadline_at: NOW + 60_000,
|
|
1948
|
+
},
|
|
1949
|
+
],
|
|
1950
|
+
})
|
|
1951
|
+
|
|
1952
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-textless' }))
|
|
1953
|
+
// The obligation is unchanged by the preference: the acknowledgement still
|
|
1954
|
+
// has to happen, because the user's read-state depends on it.
|
|
1955
|
+
expect(readSessionState('ack-textless', h.env).acknowledgement_due).toEqual([
|
|
1956
|
+
{ request_id: 'req_ack_textless', recorded_at: NOW, text_required: false },
|
|
1957
|
+
])
|
|
1958
|
+
|
|
1959
|
+
h.io.outLines = []
|
|
1960
|
+
await hookRunCommand(
|
|
1961
|
+
h.deps,
|
|
1962
|
+
'stop',
|
|
1963
|
+
stdin({ session_id: 'ack-textless', stop_hook_active: true }),
|
|
1964
|
+
)
|
|
1965
|
+
|
|
1966
|
+
const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
1967
|
+
expect(blocked.decision).toBe('block')
|
|
1968
|
+
expect(blocked.reason).toContain('notifai acknowledge req_ack_textless`')
|
|
1969
|
+
expect(blocked.reason).not.toContain('--text')
|
|
1970
|
+
})
|
|
1971
|
+
|
|
1972
|
+
it('gives up holding the turn rather than wedging the session for ever', async () => {
|
|
1973
|
+
// The acknowledgement gate is the one place hooks break the fail-open rule.
|
|
1974
|
+
// Unbounded, an agent that never acknowledges — or a server that stays
|
|
1975
|
+
// unreachable, since an error counts as unresolved — held every turn of
|
|
1976
|
+
// this session for good.
|
|
1977
|
+
const h = harness([])
|
|
1978
|
+
writeSessionState('ack-wedge', h.env, {
|
|
1979
|
+
acknowledgement_due: [{ request_id: 'req_wedge', recorded_at: NOW }],
|
|
1980
|
+
})
|
|
1981
|
+
|
|
1982
|
+
const blocked: unknown[] = []
|
|
1983
|
+
for (let turn = 0; turn < 3; turn += 1) {
|
|
1984
|
+
h.io.outLines.length = 0
|
|
1985
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
|
|
1986
|
+
blocked.push(h.io.outLines.length > 0)
|
|
1987
|
+
}
|
|
1988
|
+
expect(blocked).toEqual([true, true, true])
|
|
1989
|
+
|
|
1990
|
+
// The fourth turn is let through, and the obligation stops being owed.
|
|
1991
|
+
h.io.outLines.length = 0
|
|
1992
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-wedge' }))
|
|
1993
|
+
expect(h.io.outLines).toEqual([])
|
|
1994
|
+
expect(readSessionState('ack-wedge', h.env).acknowledgement_due).toBeUndefined()
|
|
1995
|
+
})
|
|
1996
|
+
|
|
1722
1997
|
it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
|
|
1723
1998
|
const h = harness([])
|
|
1724
1999
|
writeSessionState('ack-heal', h.env, {
|
|
@@ -1889,7 +2164,7 @@ describe('a question that outlives its session', () => {
|
|
|
1889
2164
|
expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
|
|
1890
2165
|
})
|
|
1891
2166
|
|
|
1892
|
-
it('gives up on an orphan
|
|
2167
|
+
it('gives up on an orphan that outlived the longest answerable window', async () => {
|
|
1893
2168
|
const h = harness([])
|
|
1894
2169
|
orphanRetirements(
|
|
1895
2170
|
h.env,
|
|
@@ -1900,8 +2175,7 @@ describe('a question that outlives its session', () => {
|
|
|
1900
2175
|
question: 'Old?',
|
|
1901
2176
|
state: 'expired',
|
|
1902
2177
|
}],
|
|
1903
|
-
|
|
1904
|
-
NOW - 25 * 3600 * 1000,
|
|
2178
|
+
NOW - (REPLY_MAX_WINDOW_SECONDS + 2 * 3600) * 1000,
|
|
1905
2179
|
)
|
|
1906
2180
|
const drained = await drainOrphanRetirements(
|
|
1907
2181
|
{ client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
|
|
@@ -1962,7 +2236,7 @@ describe('ask registration', () => {
|
|
|
1962
2236
|
const h = harness()
|
|
1963
2237
|
// Inside a hook, a rejection is only a stderr note the agent never reads —
|
|
1964
2238
|
// so it would look registered and then silently never ask.
|
|
1965
|
-
expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'],
|
|
2239
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], sessionId: 'a1' })).toBe(EXIT.usage)
|
|
1966
2240
|
expect(readSessionState('a1', h.env).pending).toBeUndefined()
|
|
1967
2241
|
})
|
|
1968
2242
|
|
|
@@ -1993,7 +2267,7 @@ describe('ask registration', () => {
|
|
|
1993
2267
|
{ text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
|
|
1994
2268
|
{ text: 'Anything to watch?' },
|
|
1995
2269
|
],
|
|
1996
|
-
|
|
2270
|
+
body: '## Context\nThe long story.',
|
|
1997
2271
|
}) },
|
|
1998
2272
|
undefined,
|
|
1999
2273
|
)
|
|
@@ -2002,18 +2276,18 @@ describe('ask registration', () => {
|
|
|
2002
2276
|
registerQuestion('form1', h.env, {
|
|
2003
2277
|
question: built.questions[0]!.text,
|
|
2004
2278
|
questions: built.questions,
|
|
2005
|
-
|
|
2279
|
+
body: built.body,
|
|
2006
2280
|
})
|
|
2007
2281
|
const pending = readSessionState('form1', h.env).pending?.[0]
|
|
2008
2282
|
expect(pending?.questions).toHaveLength(2)
|
|
2009
|
-
expect(pending?.
|
|
2283
|
+
expect(pending?.body).toContain('long story')
|
|
2010
2284
|
|
|
2011
2285
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
|
|
2012
2286
|
const draft = h.recorder.submitted[0]?.draft
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2287
|
+
expect(draft?.presentation.title).toBe('Deploy where?')
|
|
2288
|
+
expect(draft?.presentation.body).toBe(
|
|
2289
|
+
'1. Deploy where?\n2. Anything to watch?\n\n## Context\nThe long story.',
|
|
2290
|
+
)
|
|
2017
2291
|
expect(draft?.reply?.questions).toEqual([
|
|
2018
2292
|
{
|
|
2019
2293
|
id: 'deploy-where',
|
|
@@ -2108,7 +2382,7 @@ describe('ask registration', () => {
|
|
|
2108
2382
|
|
|
2109
2383
|
it('does not let an explicit session bypass exact active-harness proof', () => {
|
|
2110
2384
|
const h = harness()
|
|
2111
|
-
expect(askCommand(h.deps, 'Ship it?', {
|
|
2385
|
+
expect(askCommand(h.deps, 'Ship it?', { sessionId: 'guessed' })).toBe(EXIT.usage)
|
|
2112
2386
|
expect(readSessionState('guessed', h.env).pending).toBeUndefined()
|
|
2113
2387
|
})
|
|
2114
2388
|
|
|
@@ -2529,21 +2803,46 @@ describe('malformed input', () => {
|
|
|
2529
2803
|
})
|
|
2530
2804
|
|
|
2531
2805
|
describe('telling concurrent agents apart', () => {
|
|
2532
|
-
it('stamps
|
|
2533
|
-
// The hook has always known session_id and never passed it on, so two
|
|
2534
|
-
// agents in separate worktrees produced identical notifications and the
|
|
2535
|
-
// user could answer the wrong one's question.
|
|
2806
|
+
it('stamps structured Source Context on the question it pushes', async () => {
|
|
2536
2807
|
const h = harness([])
|
|
2537
|
-
registerQuestion(
|
|
2808
|
+
registerQuestion(
|
|
2809
|
+
'sess-abc',
|
|
2810
|
+
h.env,
|
|
2811
|
+
{
|
|
2812
|
+
question: 'Ship it?',
|
|
2813
|
+
source: {
|
|
2814
|
+
session_id: 'sess-abc',
|
|
2815
|
+
session_label: sessionLabelFromId('sess-abc'),
|
|
2816
|
+
harness: 'claude-code',
|
|
2817
|
+
},
|
|
2818
|
+
},
|
|
2819
|
+
NOW,
|
|
2820
|
+
)
|
|
2538
2821
|
|
|
2539
2822
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2540
2823
|
|
|
2541
|
-
expect(h.recorder.submitted[0]?.draft.
|
|
2824
|
+
expect(h.recorder.submitted[0]?.draft.source).toEqual({
|
|
2825
|
+
session_id: 'sess-abc',
|
|
2826
|
+
session_label: sessionLabelFromId('sess-abc'),
|
|
2827
|
+
harness: 'claude-code',
|
|
2828
|
+
})
|
|
2542
2829
|
})
|
|
2543
2830
|
|
|
2544
2831
|
it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
|
|
2545
2832
|
const h = harness([reply({ text: 'Yes' })])
|
|
2546
|
-
registerQuestion(
|
|
2833
|
+
registerQuestion(
|
|
2834
|
+
'sess-abc',
|
|
2835
|
+
h.env,
|
|
2836
|
+
{
|
|
2837
|
+
question: 'Ship it?',
|
|
2838
|
+
source: {
|
|
2839
|
+
session_id: 'sess-abc',
|
|
2840
|
+
session_label: sessionLabelFromId('sess-abc'),
|
|
2841
|
+
harness: 'claude-code',
|
|
2842
|
+
},
|
|
2843
|
+
},
|
|
2844
|
+
NOW,
|
|
2845
|
+
)
|
|
2547
2846
|
|
|
2548
2847
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2549
2848
|
h.recorder.acknowledged ??= new Set()
|
|
@@ -2555,17 +2854,35 @@ describe('telling concurrent agents apart', () => {
|
|
|
2555
2854
|
)
|
|
2556
2855
|
|
|
2557
2856
|
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
2558
|
-
expect(retirement?.draft.
|
|
2857
|
+
expect(retirement?.draft.source).toEqual({
|
|
2858
|
+
session_id: 'sess-abc',
|
|
2859
|
+
session_label: sessionLabelFromId('sess-abc'),
|
|
2860
|
+
harness: 'claude-code',
|
|
2861
|
+
})
|
|
2559
2862
|
})
|
|
2560
2863
|
|
|
2561
|
-
it('
|
|
2864
|
+
it('preserves a human session label without displaying the opaque id', async () => {
|
|
2562
2865
|
const h = harness([])
|
|
2563
|
-
|
|
2564
|
-
|
|
2866
|
+
registerQuestion(
|
|
2867
|
+
'sess-abc',
|
|
2868
|
+
h.env,
|
|
2869
|
+
{
|
|
2870
|
+
question: 'Ship it?',
|
|
2871
|
+
source: {
|
|
2872
|
+
session_id: 'sess-abc',
|
|
2873
|
+
session_label: 'Migration Worktree',
|
|
2874
|
+
harness: 'claude-code',
|
|
2875
|
+
},
|
|
2876
|
+
},
|
|
2877
|
+
NOW,
|
|
2878
|
+
)
|
|
2565
2879
|
|
|
2566
2880
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2567
2881
|
|
|
2568
|
-
|
|
2882
|
+
const draft = h.recorder.submitted[0]?.draft
|
|
2883
|
+
expect(draft?.source?.session_label).toBe('Migration Worktree')
|
|
2884
|
+
expect(draft?.presentation.title).not.toContain('sess-abc')
|
|
2885
|
+
expect(draft?.presentation.body).not.toContain('sess-abc')
|
|
2569
2886
|
})
|
|
2570
2887
|
})
|
|
2571
2888
|
|
|
@@ -2713,7 +3030,6 @@ describe('durable state writes', () => {
|
|
|
2713
3030
|
state: 'expired',
|
|
2714
3031
|
},
|
|
2715
3032
|
],
|
|
2716
|
-
'linked-pointer',
|
|
2717
3033
|
NOW,
|
|
2718
3034
|
),
|
|
2719
3035
|
).toThrow(/symlink/)
|
|
@@ -3456,6 +3772,47 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3456
3772
|
expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
|
|
3457
3773
|
})
|
|
3458
3774
|
|
|
3775
|
+
it('does not spend the delivery cap on turns where nothing was handed over', async () => {
|
|
3776
|
+
// The bug: holds counted against MAX_CONTINUATION_COUNT, so three turns
|
|
3777
|
+
// where a liveness probe could not reach a busy session were enough to
|
|
3778
|
+
// settle an answer the user had already given, unread.
|
|
3779
|
+
const h = harness([])
|
|
3780
|
+
const sessionId = 'waiter-transient-holds'
|
|
3781
|
+
journaled(h, sessionId)
|
|
3782
|
+
const context = waiterContext(h)
|
|
3783
|
+
let handedOver = 0
|
|
3784
|
+
let pass = 0
|
|
3785
|
+
|
|
3786
|
+
for (; pass < MAX_CONTINUATION_COUNT + 2; pass += 1) {
|
|
3787
|
+
await runEscalationWaiter(context, {
|
|
3788
|
+
sessionId,
|
|
3789
|
+
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
3790
|
+
route: {
|
|
3791
|
+
kind: 'inbox-socket',
|
|
3792
|
+
deliver: async () => ({ notes: [], acknowledgement: 'held' as const }),
|
|
3793
|
+
},
|
|
3794
|
+
processDeadlineAt: NOW + 480_000,
|
|
3795
|
+
})
|
|
3796
|
+
}
|
|
3797
|
+
// Still journaled after more holds than the delivery cap allows.
|
|
3798
|
+
expect(readSessionState(sessionId, h.env).accepted).toBeDefined()
|
|
3799
|
+
|
|
3800
|
+
// And when a route can finally take it, it is delivered rather than lost.
|
|
3801
|
+
await runEscalationWaiter(context, {
|
|
3802
|
+
sessionId,
|
|
3803
|
+
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
3804
|
+
route: {
|
|
3805
|
+
kind: 'inbox-socket',
|
|
3806
|
+
deliver: async () => {
|
|
3807
|
+
handedOver += 1
|
|
3808
|
+
return { notes: [], acknowledgement: 'delivered' as const }
|
|
3809
|
+
},
|
|
3810
|
+
},
|
|
3811
|
+
processDeadlineAt: NOW + 480_000,
|
|
3812
|
+
})
|
|
3813
|
+
expect(handedOver).toBe(1)
|
|
3814
|
+
})
|
|
3815
|
+
|
|
3459
3816
|
it('bounds deliveries by the continuation cap on every route', async () => {
|
|
3460
3817
|
// A route that never acknowledges must still stop. The cap lives where all
|
|
3461
3818
|
// routes pass, so no future route can be the one it does not cover.
|
|
@@ -3467,7 +3824,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3467
3824
|
let deliveries = 0
|
|
3468
3825
|
const notes: string[] = []
|
|
3469
3826
|
|
|
3470
|
-
for (let pass = 0; pass <
|
|
3827
|
+
for (let pass = 0; pass < MAX_HELD_DELIVERIES + 3; pass += 1) {
|
|
3471
3828
|
const outcome = await runEscalationWaiter(context, {
|
|
3472
3829
|
sessionId,
|
|
3473
3830
|
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
@@ -3483,9 +3840,11 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3483
3840
|
notes.push(...outcome.notes)
|
|
3484
3841
|
}
|
|
3485
3842
|
|
|
3486
|
-
|
|
3843
|
+
// A hold is not a delivery — nothing was handed over — so it does not
|
|
3844
|
+
// spend the wake-loop cap. It still has to end, on its own looser bound.
|
|
3845
|
+
expect(deliveries).toBe(MAX_HELD_DELIVERIES)
|
|
3487
3846
|
expect(notes.join('\n')).toContain(
|
|
3488
|
-
|
|
3847
|
+
`could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns`,
|
|
3489
3848
|
)
|
|
3490
3849
|
expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
|
|
3491
3850
|
}
|
|
@@ -3519,3 +3878,86 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3519
3878
|
releaseQuestionPush('waiter-failed-route', h.env)
|
|
3520
3879
|
})
|
|
3521
3880
|
})
|
|
3881
|
+
|
|
3882
|
+
|
|
3883
|
+
describe('session state across a prompt', () => {
|
|
3884
|
+
it('carries every kind of session state across a typed prompt', async () => {
|
|
3885
|
+
// The prompt handler rebuilds state from named fields, so a field nobody
|
|
3886
|
+
// remembered to name is erased by the user typing. That is how an
|
|
3887
|
+
// acknowledgement obligation once vanished. This fails if a future field is
|
|
3888
|
+
// added to SessionState and not carried across.
|
|
3889
|
+
const h = harness([])
|
|
3890
|
+
const before = {
|
|
3891
|
+
last_prompt_at: AWAY,
|
|
3892
|
+
last_stop_at: NOW - 1_000,
|
|
3893
|
+
retiring: [
|
|
3894
|
+
{
|
|
3895
|
+
request_id: 'req_carry',
|
|
3896
|
+
collapse_key: 'collapse-carry',
|
|
3897
|
+
device_ids: ['dev_iphone'],
|
|
3898
|
+
question: 'Carried?',
|
|
3899
|
+
state: 'expired' as const,
|
|
3900
|
+
},
|
|
3901
|
+
],
|
|
3902
|
+
continuation: { answered_at: NOW - 2_000, count: 2 },
|
|
3903
|
+
acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
|
|
3904
|
+
acknowledgement_blocks: 2,
|
|
3905
|
+
}
|
|
3906
|
+
writeSessionState('carry-all', h.env, before)
|
|
3907
|
+
|
|
3908
|
+
await hookRunCommand(
|
|
3909
|
+
h.deps,
|
|
3910
|
+
'user-prompt-submit',
|
|
3911
|
+
stdin({ session_id: 'carry-all', cwd: h.deps.cwd }),
|
|
3912
|
+
)
|
|
3913
|
+
|
|
3914
|
+
const after = readSessionState('carry-all', h.env)
|
|
3915
|
+
for (const key of Object.keys(before) as (keyof typeof before)[]) {
|
|
3916
|
+
expect(after[key], `${key} did not survive the prompt`).toBeDefined()
|
|
3917
|
+
}
|
|
3918
|
+
// The one field a prompt resets rather than carries.
|
|
3919
|
+
expect(after.continuation?.count).toBe(0)
|
|
3920
|
+
expect(after.acknowledgement_blocks).toBe(2)
|
|
3921
|
+
})
|
|
3922
|
+
})
|
|
3923
|
+
|
|
3924
|
+
describe('a question outliving the waiter that pushed it', () => {
|
|
3925
|
+
it('records when the server stops accepting, not when this owner stops waiting', async () => {
|
|
3926
|
+
// These were the same number until the window became a setting, and taking
|
|
3927
|
+
// the minimum capped every question at the waiter's ceiling: a day-long
|
|
3928
|
+
// window was swept as stale about eight minutes later and retired, so the
|
|
3929
|
+
// answer given over lunch met a closed question.
|
|
3930
|
+
const h = harness([])
|
|
3931
|
+
const aDayOut = NOW + 86_400_000
|
|
3932
|
+
h.recorder.replyExpiresAt = new Date(aDayOut).toISOString()
|
|
3933
|
+
writeSessionState('outlive', h.env, { last_prompt_at: AWAY })
|
|
3934
|
+
registerQuestion('outlive', h.env, { question: 'Ship it?' }, NOW)
|
|
3935
|
+
|
|
3936
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'outlive' }))
|
|
3937
|
+
|
|
3938
|
+
const state = readSessionState('outlive', h.env)
|
|
3939
|
+
const live = state.pending?.[0]
|
|
3940
|
+
// Still live, still carrying the server's window — not the waiter's.
|
|
3941
|
+
expect(live?.request_id).toBeDefined()
|
|
3942
|
+
expect(live?.reply_deadline_at).toBe(aDayOut)
|
|
3943
|
+
// Not closed and not retired: those are what capped it at eight minutes.
|
|
3944
|
+
expect(state.retiring).toBeUndefined()
|
|
3945
|
+
expect(h.recorder.closed).toEqual([])
|
|
3946
|
+
// And the waiter still stopped at its own ceiling rather than waiting a day.
|
|
3947
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
3948
|
+
})
|
|
3949
|
+
|
|
3950
|
+
it('still closes a question the server has genuinely stopped accepting', async () => {
|
|
3951
|
+
const h = harness([])
|
|
3952
|
+
h.recorder.replyExpiresAt = new Date(NOW + 90_000).toISOString()
|
|
3953
|
+
writeSessionState('short-window', h.env, { last_prompt_at: AWAY })
|
|
3954
|
+
registerQuestion('short-window', h.env, { question: 'Ship it?' }, NOW)
|
|
3955
|
+
|
|
3956
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'short-window' }))
|
|
3957
|
+
|
|
3958
|
+
// Its window closed inside the waiter's ceiling, so it is retired here
|
|
3959
|
+
// rather than left for a turn that could never collect it.
|
|
3960
|
+
expect(readSessionState('short-window', h.env).pending).toBeUndefined()
|
|
3961
|
+
expect(h.recorder.closed.length).toBe(1)
|
|
3962
|
+
})
|
|
3963
|
+
})
|