@raidiant/notifai 4.0.0 → 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 +46 -0
- package/README.md +5 -0
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +174 -58
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +14 -5
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +11 -0
- 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 +72 -5
- package/dist/hooks.js +407 -79
- 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/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +44 -17
- 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 +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/commands.test.ts +185 -51
- package/src/commands.ts +198 -56
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +16 -5
- package/src/config.test.ts +41 -9
- package/src/config.ts +13 -0
- 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 +358 -21
- package/src/hooks.ts +457 -97
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- 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 +57 -23
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- 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
|
@@ -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
|
}
|
|
@@ -176,7 +189,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
176
189
|
return {
|
|
177
190
|
status: 'recorded',
|
|
178
191
|
agent_acknowledgement: {
|
|
179
|
-
text: body.text,
|
|
192
|
+
text: body.text ?? '',
|
|
180
193
|
created_at: new Date(NOW).toISOString(),
|
|
181
194
|
},
|
|
182
195
|
}
|
|
@@ -188,6 +201,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
188
201
|
return {
|
|
189
202
|
request_id: requestId,
|
|
190
203
|
agent_acknowledgement_required: required,
|
|
204
|
+
agent_acknowledgement_text_required:
|
|
205
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
191
206
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
192
207
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
193
208
|
: null,
|
|
@@ -207,9 +222,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
207
222
|
recorder.receipts.push(requestId)
|
|
208
223
|
return {
|
|
209
224
|
request_id: requestId,
|
|
210
|
-
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
225
|
+
reply_expires_at: recorder.replyExpiresAt ?? new Date(NOW + 480_000).toISOString(),
|
|
211
226
|
agent_acknowledgement_required:
|
|
212
227
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
228
|
+
agent_acknowledgement_text_required:
|
|
229
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
213
230
|
replayed: false,
|
|
214
231
|
overall: 'provider_accepted_all',
|
|
215
232
|
deliveries: [],
|
|
@@ -223,6 +240,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
223
240
|
reply_expires_at: null,
|
|
224
241
|
agent_acknowledgement_required:
|
|
225
242
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
243
|
+
agent_acknowledgement_text_required:
|
|
244
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
226
245
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
227
246
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
228
247
|
: null,
|
|
@@ -236,6 +255,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
236
255
|
reply_expires_at: new Date(NOW).toISOString(),
|
|
237
256
|
agent_acknowledgement_required:
|
|
238
257
|
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
258
|
+
agent_acknowledgement_text_required:
|
|
259
|
+
recorder.acknowledgementTextRequiredFor?.(requestId) ?? true,
|
|
239
260
|
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
240
261
|
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
241
262
|
: null,
|
|
@@ -451,6 +472,27 @@ function writeGlobalConfig(h: Harness, toml: string): void {
|
|
|
451
472
|
/** Long enough ago that no test is accidentally sensitive to the stamp. */
|
|
452
473
|
const AWAY = NOW - 600_000
|
|
453
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
|
+
|
|
454
496
|
describe('pushing a registered question', () => {
|
|
455
497
|
// Found by a live Claude Code session: a spawned agent always has a
|
|
456
498
|
// just-set last_prompt_at, and the routing gate once read that as the user
|
|
@@ -471,7 +513,8 @@ describe('pushing a registered question', () => {
|
|
|
471
513
|
title: 'Ship it?',
|
|
472
514
|
body: 'Ship it?',
|
|
473
515
|
})
|
|
474
|
-
|
|
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')
|
|
475
518
|
})
|
|
476
519
|
|
|
477
520
|
it('keeps sparse alt text paired with its original media item', async () => {
|
|
@@ -1045,6 +1088,12 @@ describe('late answer collection', () => {
|
|
|
1045
1088
|
|
|
1046
1089
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'late-prompt' }))
|
|
1047
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
|
+
|
|
1048
1097
|
await hookRunCommand(
|
|
1049
1098
|
h.deps,
|
|
1050
1099
|
'stop',
|
|
@@ -1216,10 +1265,66 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1216
1265
|
expect(h.recorder.submitted[0]).toEqual(firstAttempt)
|
|
1217
1266
|
})
|
|
1218
1267
|
|
|
1219
|
-
it('
|
|
1268
|
+
it('remints a frozen draft after a terminal 422 instead of replaying it', async () => {
|
|
1220
1269
|
const h = harness([])
|
|
1221
1270
|
const factory = h.deps.clientFactory
|
|
1222
|
-
|
|
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 () => {
|
|
1312
|
+
const h = harness([])
|
|
1313
|
+
const factory = h.deps.clientFactory
|
|
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
|
+
|
|
1223
1328
|
h.deps.clientFactory = () => {
|
|
1224
1329
|
const client = factory!()
|
|
1225
1330
|
return {
|
|
@@ -1227,29 +1332,72 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1227
1332
|
submit: async () => {
|
|
1228
1333
|
throw new ApiCallError(422, 'invalid_request', 'The draft was rejected.')
|
|
1229
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
|
+
},
|
|
1230
1378
|
replies: async () => {
|
|
1231
1379
|
polls += 1
|
|
1232
1380
|
throw new Error('must not poll an unaccepted request')
|
|
1233
1381
|
},
|
|
1234
1382
|
} as ApiClient
|
|
1235
1383
|
}
|
|
1236
|
-
writeSessionState('
|
|
1237
|
-
registerQuestion('
|
|
1384
|
+
writeSessionState('auth-submit', h.env, { last_prompt_at: AWAY })
|
|
1385
|
+
registerQuestion('auth-submit', h.env, { question: 'Deploy?' }, NOW)
|
|
1238
1386
|
|
|
1239
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1387
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
|
|
1240
1388
|
|
|
1241
|
-
const pending = readSessionState('
|
|
1389
|
+
const pending = readSessionState('auth-submit', h.env).pending?.[0]
|
|
1242
1390
|
expect(pending?.request_id).toBeUndefined()
|
|
1243
1391
|
expect(pending?.submission?.request_id).toMatch(/^req_[A-Za-z0-9_-]{22,24}$/)
|
|
1244
1392
|
expect(polls).toBe(0)
|
|
1245
|
-
expect(h.io.errLines.join('\n')).toContain('
|
|
1393
|
+
expect(h.io.errLines.join('\n')).toContain('preserving it for recovery')
|
|
1246
1394
|
|
|
1247
1395
|
const frozen = pending?.submission
|
|
1248
1396
|
if (frozen === undefined) throw new Error('expected a persisted submission intent')
|
|
1249
1397
|
writeGlobalConfig(h, 'project = "changed-after-crash"\n')
|
|
1250
1398
|
h.advanceClock(600_000)
|
|
1251
1399
|
h.deps.clientFactory = factory
|
|
1252
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
1400
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'auth-submit' }))
|
|
1253
1401
|
|
|
1254
1402
|
expect(h.recorder.submitted[0]).toEqual({
|
|
1255
1403
|
request_id: frozen.request_id,
|
|
@@ -1284,7 +1432,7 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1284
1432
|
expect(h.io.errLines.join('\n')).toContain('expired with its continuation owner')
|
|
1285
1433
|
})
|
|
1286
1434
|
|
|
1287
|
-
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 () => {
|
|
1288
1436
|
const h = harness([reply({ text: 'Done' })])
|
|
1289
1437
|
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
1290
1438
|
registerQuestion('latency', h.env, { question: 'First?' }, NOW)
|
|
@@ -1296,9 +1444,12 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1296
1444
|
const windows = h.recorder.submitted
|
|
1297
1445
|
.filter((entry) => entry.draft.event === 'agent_question')
|
|
1298
1446
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
|
|
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.
|
|
1302
1453
|
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
1303
1454
|
})
|
|
1304
1455
|
|
|
@@ -1763,7 +1914,7 @@ describe('several questions in flight', () => {
|
|
|
1763
1914
|
|
|
1764
1915
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
|
|
1765
1916
|
expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
|
|
1766
|
-
{ request_id: 'req_ack_required', recorded_at: NOW },
|
|
1917
|
+
{ request_id: 'req_ack_required', recorded_at: NOW, text_required: true },
|
|
1767
1918
|
])
|
|
1768
1919
|
|
|
1769
1920
|
h.io.outLines = []
|
|
@@ -1783,6 +1934,66 @@ describe('several questions in flight', () => {
|
|
|
1783
1934
|
expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
|
|
1784
1935
|
})
|
|
1785
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
|
+
|
|
1786
1997
|
it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
|
|
1787
1998
|
const h = harness([])
|
|
1788
1999
|
writeSessionState('ack-heal', h.env, {
|
|
@@ -1953,7 +2164,7 @@ describe('a question that outlives its session', () => {
|
|
|
1953
2164
|
expect(retirements(h).filter((r) => r.retires === first)).toHaveLength(count)
|
|
1954
2165
|
})
|
|
1955
2166
|
|
|
1956
|
-
it('gives up on an orphan
|
|
2167
|
+
it('gives up on an orphan that outlived the longest answerable window', async () => {
|
|
1957
2168
|
const h = harness([])
|
|
1958
2169
|
orphanRetirements(
|
|
1959
2170
|
h.env,
|
|
@@ -1964,7 +2175,7 @@ describe('a question that outlives its session', () => {
|
|
|
1964
2175
|
question: 'Old?',
|
|
1965
2176
|
state: 'expired',
|
|
1966
2177
|
}],
|
|
1967
|
-
NOW -
|
|
2178
|
+
NOW - (REPLY_MAX_WINDOW_SECONDS + 2 * 3600) * 1000,
|
|
1968
2179
|
)
|
|
1969
2180
|
const drained = await drainOrphanRetirements(
|
|
1970
2181
|
{ client: h.deps.clientFactory('https://test.notifai.invalid', 'Bearer x'), config: loadConfig({ cwd: h.deps.cwd, env: h.env }) },
|
|
@@ -3561,6 +3772,47 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3561
3772
|
expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
|
|
3562
3773
|
})
|
|
3563
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
|
+
|
|
3564
3816
|
it('bounds deliveries by the continuation cap on every route', async () => {
|
|
3565
3817
|
// A route that never acknowledges must still stop. The cap lives where all
|
|
3566
3818
|
// routes pass, so no future route can be the one it does not cover.
|
|
@@ -3572,7 +3824,7 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3572
3824
|
let deliveries = 0
|
|
3573
3825
|
const notes: string[] = []
|
|
3574
3826
|
|
|
3575
|
-
for (let pass = 0; pass <
|
|
3827
|
+
for (let pass = 0; pass < MAX_HELD_DELIVERIES + 3; pass += 1) {
|
|
3576
3828
|
const outcome = await runEscalationWaiter(context, {
|
|
3577
3829
|
sessionId,
|
|
3578
3830
|
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
@@ -3588,9 +3840,11 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3588
3840
|
notes.push(...outcome.notes)
|
|
3589
3841
|
}
|
|
3590
3842
|
|
|
3591
|
-
|
|
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)
|
|
3592
3846
|
expect(notes.join('\n')).toContain(
|
|
3593
|
-
|
|
3847
|
+
`could hand the user's answer over in ${MAX_HELD_DELIVERIES} turns`,
|
|
3594
3848
|
)
|
|
3595
3849
|
expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
|
|
3596
3850
|
}
|
|
@@ -3624,3 +3878,86 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3624
3878
|
releaseQuestionPush('waiter-failed-route', h.env)
|
|
3625
3879
|
})
|
|
3626
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
|
+
})
|