@raidiant/notifai 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +15 -10
- package/dist/commands.js +321 -88
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +2 -2
- package/dist/config-schema.js +9 -35
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +5 -38
- package/dist/config.js +11 -32
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +3 -13
- package/dist/harnesses.js +6 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +22 -7
- package/dist/install-hooks.js +43 -28
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +2 -4
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +6 -13
- package/dist/main.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +9 -9
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +393 -40
- package/src/commands.ts +433 -109
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +11 -40
- package/src/config.test.ts +21 -16
- package/src/config.ts +15 -69
- package/src/harnesses.test.ts +7 -21
- package/src/harnesses.ts +17 -19
- package/src/hooks.test.ts +780 -290
- package/src/hooks.ts +466 -339
- package/src/install-hooks.test.ts +44 -3
- package/src/install-hooks.ts +43 -28
- package/src/interactive.ts +2 -7
- package/src/logging.test.ts +3 -3
- package/src/main.ts +6 -12
- package/src/release.ts +51 -0
- package/src/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
package/src/hooks.test.ts
CHANGED
|
@@ -20,8 +20,10 @@ import type {
|
|
|
20
20
|
SubmissionReceipt,
|
|
21
21
|
SubmitNotificationRequestT,
|
|
22
22
|
} from '@raidiant/notifai-protocol'
|
|
23
|
-
import { describe, expect, it } from 'vitest'
|
|
23
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
24
24
|
import { ApiCallError, type ApiClient } from './client.js'
|
|
25
|
+
import { CLAUDE_POST_SEND_LIVENESS_MS, type ClaudeWakeAdapters } from './claude-wake.js'
|
|
26
|
+
import type { CodexWakeAdapters, CodexWakeObservation } from './codex-wake.js'
|
|
25
27
|
import { readStdinWithTimeout } from './hook-input.js'
|
|
26
28
|
import {
|
|
27
29
|
activeLogPath,
|
|
@@ -43,7 +45,8 @@ import {
|
|
|
43
45
|
clearSessionState,
|
|
44
46
|
drainRetirements,
|
|
45
47
|
drainOrphanRetirements,
|
|
46
|
-
|
|
48
|
+
runEscalationWaiter,
|
|
49
|
+
MAX_CONTINUATION_COUNT,
|
|
47
50
|
orphanRetirements,
|
|
48
51
|
pruneAbandonedSessions,
|
|
49
52
|
releaseQuestionPush,
|
|
@@ -193,12 +196,7 @@ interface Harness {
|
|
|
193
196
|
|
|
194
197
|
const NOW = 1_800_000_000_000
|
|
195
198
|
|
|
196
|
-
|
|
197
|
-
* `idleSeconds` defaults to null — "this machine has no idle source" — so these
|
|
198
|
-
* cases exercise the degraded path and stay independent of whether the person
|
|
199
|
-
* running the suite is touching their own keyboard.
|
|
200
|
-
*/
|
|
201
|
-
function harness(replies: ReplyView[] = [], idleSeconds: number | null = null): Harness {
|
|
199
|
+
function harness(replies: ReplyView[] = []): Harness {
|
|
202
200
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-'))
|
|
203
201
|
const env: NodeJS.ProcessEnv = {
|
|
204
202
|
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
@@ -233,7 +231,6 @@ function harness(replies: ReplyView[] = [], idleSeconds: number | null = null):
|
|
|
233
231
|
cwd: root,
|
|
234
232
|
clientFactory: () => fakeClient(recorder, replies),
|
|
235
233
|
now: () => clock,
|
|
236
|
-
idleSeconds: () => idleSeconds,
|
|
237
234
|
sleep: async (milliseconds: number) => {
|
|
238
235
|
clock += milliseconds
|
|
239
236
|
},
|
|
@@ -373,52 +370,16 @@ function writeGlobalConfig(h: Harness, toml: string): void {
|
|
|
373
370
|
writeFileSync(path.join(dir, 'config.toml'), toml)
|
|
374
371
|
}
|
|
375
372
|
|
|
376
|
-
|
|
377
|
-
const config = loadConfig({ cwd, env })
|
|
378
|
-
config.require_idle = { value: true, source: 'global:test' }
|
|
379
|
-
return config
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Long enough ago to be away under the 120s default. */
|
|
373
|
+
/** Long enough ago that no test is accidentally sensitive to the stamp. */
|
|
383
374
|
const AWAY = NOW - 600_000
|
|
384
|
-
const PRESENT = NOW - 5_000
|
|
385
|
-
|
|
386
|
-
describe('presence gate', () => {
|
|
387
|
-
it('treats a never-seen session as present, so a missing hook cannot hijack the terminal', () => {
|
|
388
|
-
const { env, deps } = harness()
|
|
389
|
-
const config = presenceGatedConfig(deps.cwd, env)
|
|
390
|
-
expect(isUserAway({}, config, NOW, null)).toBe(false)
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
it('is away only once the configured silence has elapsed', () => {
|
|
394
|
-
const { env, deps } = harness()
|
|
395
|
-
const config = presenceGatedConfig(deps.cwd, env)
|
|
396
|
-
expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, null)).toBe(false)
|
|
397
|
-
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
// The case that motivated this: "run the full test suite", then three
|
|
401
|
-
// minutes of watching. Elapsed time alone said away; the machine knows better.
|
|
402
|
-
it('keeps a user who is watching a long turn present, however long the turn ran', () => {
|
|
403
|
-
const { env, deps } = harness()
|
|
404
|
-
const config = presenceGatedConfig(deps.cwd, env)
|
|
405
|
-
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 3)).toBe(false)
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
// Found by a live Claude Code session: a spawned agent's session
|
|
409
|
-
// always has a just-set last_prompt_at, so requiring session silence too meant
|
|
410
|
-
// its FIRST question could never escalate — the "kick off agents and walk
|
|
411
|
-
// away" case the feature is for.
|
|
412
|
-
it('lets a freshly spawned session escalate when the machine says nobody is there', () => {
|
|
413
|
-
const { env, deps } = harness()
|
|
414
|
-
const config = presenceGatedConfig(deps.cwd, env)
|
|
415
|
-
expect(isUserAway({ last_prompt_at: PRESENT }, config, NOW, 900)).toBe(true)
|
|
416
|
-
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, 900)).toBe(true)
|
|
417
|
-
})
|
|
418
375
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
376
|
+
describe('pushing a registered question', () => {
|
|
377
|
+
// Found by a live Claude Code session: a spawned agent always has a
|
|
378
|
+
// just-set last_prompt_at, and the routing gate once read that as the user
|
|
379
|
+
// being present — so its FIRST question could never escalate, which is the
|
|
380
|
+
// "kick off agents and walk away" case the feature exists for.
|
|
381
|
+
it('pushes a freshly spawned session first question', async () => {
|
|
382
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
422
383
|
// Prompt 20s ago, exactly as a just-spawned agent has.
|
|
423
384
|
writeSessionState('spawn1', h.env, { last_prompt_at: NOW - 20_000 })
|
|
424
385
|
registerQuestion('spawn1', h.env, { question: 'Ship it?' }, NOW)
|
|
@@ -439,7 +400,7 @@ describe('presence gate', () => {
|
|
|
439
400
|
})
|
|
440
401
|
|
|
441
402
|
it('names the resolved project in the pushed question title', async () => {
|
|
442
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
403
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
443
404
|
const projectDir = path.join(h.deps.cwd, '.notifai')
|
|
444
405
|
mkdirSync(projectDir, { recursive: true })
|
|
445
406
|
writeFileSync(
|
|
@@ -453,23 +414,6 @@ describe('presence gate', () => {
|
|
|
453
414
|
|
|
454
415
|
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
|
|
455
416
|
})
|
|
456
|
-
|
|
457
|
-
it('falls back to elapsed time where no idle source exists', () => {
|
|
458
|
-
const { env, deps } = harness()
|
|
459
|
-
const config = presenceGatedConfig(deps.cwd, env)
|
|
460
|
-
expect(isUserAway({ last_prompt_at: AWAY }, config, NOW, null)).toBe(true)
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
it('does not push a question to the phone while the user is at the keyboard', async () => {
|
|
464
|
-
// Silent for ten minutes by the session clock, but active by the machine's.
|
|
465
|
-
const h = harness([reply({ text: 'Yes' })], 2)
|
|
466
|
-
writeGlobalConfig(h, 'require_idle = true\n')
|
|
467
|
-
writeSessionState('idle1', h.env, { last_prompt_at: AWAY })
|
|
468
|
-
registerQuestion('idle1', h.env, { question: 'Ship it?' })
|
|
469
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'idle1' }))
|
|
470
|
-
expect(h.recorder.submitted).toHaveLength(0)
|
|
471
|
-
expect(h.io.errLines.join('\n')).toContain('at the keyboard')
|
|
472
|
-
})
|
|
473
417
|
})
|
|
474
418
|
|
|
475
419
|
/**
|
|
@@ -488,22 +432,31 @@ describe('what the hook leaves behind', () => {
|
|
|
488
432
|
}
|
|
489
433
|
|
|
490
434
|
it('records why a question was held, in a name a filter can match', async () => {
|
|
491
|
-
const h = harness([]
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
435
|
+
const h = harness([])
|
|
436
|
+
writeSessionState('held', h.env, { accepted: undefined })
|
|
437
|
+
registerQuestion('held', h.env, { question: 'Ship it?' }, NOW)
|
|
438
|
+
// A continuation turn must not re-ask the question it is continuing from.
|
|
439
|
+
writeSessionState('held', h.env, {
|
|
440
|
+
...readSessionState('held', h.env),
|
|
441
|
+
continuation: { answered_at: NOW + 1000, count: 1 },
|
|
442
|
+
})
|
|
495
443
|
|
|
496
|
-
await hookRunCommand(
|
|
444
|
+
await hookRunCommand(
|
|
445
|
+
recording(h),
|
|
446
|
+
'stop',
|
|
447
|
+
stdin({ session_id: 'held', cwd: h.deps.cwd, stop_hook_active: true }),
|
|
448
|
+
)
|
|
497
449
|
|
|
498
|
-
const held = gates(h).find((record) => record.data?.['reason'] === '
|
|
450
|
+
const held = gates(h).find((record) => record.data?.['reason'] === 'continuation-repeat')
|
|
499
451
|
expect(held).toBeDefined()
|
|
500
|
-
expect(held!.data).toMatchObject({ verdict: 'held'
|
|
452
|
+
expect(held!.data).toMatchObject({ verdict: 'held' })
|
|
453
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
501
454
|
})
|
|
502
455
|
|
|
503
456
|
it('records the switch being off, which the user is deliberately never told', async () => {
|
|
504
457
|
// Silent to the user by design, so from outside "you turned it off" and "a
|
|
505
458
|
// bug ate my question" look identical. This is what tells them apart.
|
|
506
|
-
const h = harness([]
|
|
459
|
+
const h = harness([])
|
|
507
460
|
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
508
461
|
mkdirSync(dir, { recursive: true })
|
|
509
462
|
writeFileSync(path.join(dir, 'config.toml'), 'ask_notifications = false\n')
|
|
@@ -553,7 +506,7 @@ describe('what the hook leaves behind', () => {
|
|
|
553
506
|
})
|
|
554
507
|
|
|
555
508
|
it('persists an answer once while still reporting it to the harness', async () => {
|
|
556
|
-
const h = harness([reply({ text: 'Allow exactly once' })]
|
|
509
|
+
const h = harness([reply({ text: 'Allow exactly once' })])
|
|
557
510
|
writeSessionState('answer-log', h.env, { last_prompt_at: AWAY })
|
|
558
511
|
registerQuestion('answer-log', h.env, { question: 'Ship it?' })
|
|
559
512
|
|
|
@@ -565,12 +518,16 @@ describe('what the hook leaves behind', () => {
|
|
|
565
518
|
|
|
566
519
|
const raw = readFileSync(activeLogPath(h.env), 'utf8')
|
|
567
520
|
expect(raw.match(/Allow exactly once/g)).toHaveLength(1)
|
|
568
|
-
|
|
521
|
+
const answerRecords = readLogRecords(h.env, { event: ['hook.answer'] }).records
|
|
522
|
+
expect(answerRecords.map((record) => record.data?.['stage'])).toEqual([
|
|
523
|
+
'queued',
|
|
524
|
+
'delivered',
|
|
525
|
+
])
|
|
569
526
|
expect(h.io.errLines.join('\n')).toContain('Allow exactly once')
|
|
570
527
|
})
|
|
571
528
|
|
|
572
529
|
it('records the push and ties it to the request id the server knows', async () => {
|
|
573
|
-
const h = harness([]
|
|
530
|
+
const h = harness([])
|
|
574
531
|
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
575
532
|
mkdirSync(dir, { recursive: true })
|
|
576
533
|
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
@@ -589,7 +546,7 @@ describe('what the hook leaves behind', () => {
|
|
|
589
546
|
})
|
|
590
547
|
|
|
591
548
|
it('stores every request in a multi-question wait as a structured identity', async () => {
|
|
592
|
-
const h = harness([]
|
|
549
|
+
const h = harness([])
|
|
593
550
|
writeSessionState('request-log', h.env, { last_prompt_at: AWAY })
|
|
594
551
|
registerQuestion('request-log', h.env, { question: 'Ship it?' }, NOW)
|
|
595
552
|
registerQuestion('request-log', h.env, { question: 'Deploy it?' }, NOW + 1)
|
|
@@ -649,7 +606,7 @@ describe('what the hook leaves behind', () => {
|
|
|
649
606
|
})
|
|
650
607
|
|
|
651
608
|
it('never writes the machine credential, whatever the hook did', async () => {
|
|
652
|
-
const h = harness([]
|
|
609
|
+
const h = harness([])
|
|
653
610
|
const dir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
654
611
|
mkdirSync(dir, { recursive: true })
|
|
655
612
|
writeFileSync(path.join(dir, 'config.toml'), 'ask_grace_seconds = 0\n')
|
|
@@ -671,23 +628,21 @@ describe('what the hook leaves behind', () => {
|
|
|
671
628
|
* Presence is a precondition only while the user wants it to be, and switching
|
|
672
629
|
* it off must not disturb the grace timer.
|
|
673
630
|
*/
|
|
674
|
-
describe('
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const gated = loadConfig({ cwd: '/nowhere', env: {}, flags: {} })
|
|
681
|
-
gated.require_idle = { value: true, source: 'global:test' }
|
|
682
|
-
expect(isUserAway({ last_prompt_at: NOW }, gated, NOW, 1)).toBe(false)
|
|
683
|
-
})
|
|
631
|
+
describe('terminal-first grace window', () => {
|
|
632
|
+
/** Registers a question asked `agoMs` ago. */
|
|
633
|
+
function pending(h: Harness, session: string, agoMs: number): void {
|
|
634
|
+
writeSessionState(session, h.env, { last_prompt_at: AWAY })
|
|
635
|
+
registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
|
|
636
|
+
}
|
|
684
637
|
|
|
685
|
-
it('
|
|
686
|
-
const h = harness([]
|
|
638
|
+
it('pushes as soon as the turn ends when no window is configured', async () => {
|
|
639
|
+
const h = harness([])
|
|
687
640
|
let submittedAt: number | undefined
|
|
688
641
|
h.recorder.beforeQuestionSubmit = () => {
|
|
689
642
|
submittedAt = h.deps.now?.()
|
|
690
643
|
}
|
|
644
|
+
// The user typed this very instant. It changes nothing: the waiter does
|
|
645
|
+
// not hold their terminal, so their being here is not a reason to wait.
|
|
691
646
|
writeSessionState('present1', h.env, { last_prompt_at: NOW })
|
|
692
647
|
registerQuestion('present1', h.env, { question: 'Ship it?' })
|
|
693
648
|
|
|
@@ -695,135 +650,73 @@ describe('presence gating is optional (require_idle)', () => {
|
|
|
695
650
|
|
|
696
651
|
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
697
652
|
expect(submittedAt).toBe(NOW)
|
|
698
|
-
expect(h.io.errLines.join('\n')).not.toContain('at the keyboard')
|
|
699
|
-
})
|
|
700
|
-
|
|
701
|
-
it('still gives the terminal its grace window first', async () => {
|
|
702
|
-
// The two knobs are independent: not needing the user to leave does not
|
|
703
|
-
// mean skipping the wait that offers the question to the terminal.
|
|
704
|
-
const h = harness([], 1)
|
|
705
|
-
writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 300\n')
|
|
706
|
-
writeSessionState('present2', h.env, { last_prompt_at: NOW })
|
|
707
|
-
registerQuestion('present2', h.env, { question: 'Ship it?' }, NOW)
|
|
708
|
-
|
|
709
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present2' }))
|
|
710
|
-
|
|
711
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
712
|
-
// The clock only advances when the hook sleeps, so this is proof the wait
|
|
713
|
-
// actually happened rather than being skipped.
|
|
714
|
-
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(300_000)
|
|
715
|
-
})
|
|
716
|
-
|
|
717
|
-
it('honours the grace window on a machine with no idle source at all', async () => {
|
|
718
|
-
// With presence gating on, no idle source means refusing to wait
|
|
719
|
-
// ('no-signal'). With it off there is nothing to watch for, so the timer
|
|
720
|
-
// is just a timer and works everywhere.
|
|
721
|
-
const h = harness([], null)
|
|
722
|
-
writeGlobalConfig(h, 'require_idle = false\nask_grace_seconds = 120\n')
|
|
723
|
-
registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
|
|
724
|
-
|
|
725
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
726
|
-
|
|
727
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
728
|
-
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
729
|
-
expect(h.io.errLines.join('\n')).not.toContain('no idle signal')
|
|
730
653
|
})
|
|
731
654
|
|
|
732
|
-
it('is not a way to switch escalation on when the user has switched it off', async () => {
|
|
733
|
-
// ask_notifications is the "do not reach me" switch and outranks this one;
|
|
734
|
-
// wanting to be reachable while working is a different question entirely.
|
|
735
|
-
const h = harness([], 1)
|
|
736
|
-
writeGlobalConfig(h, 'require_idle = false\nask_notifications = false\n')
|
|
737
|
-
registerQuestion('present4', h.env, { question: 'Ship it?' })
|
|
738
|
-
|
|
739
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
|
|
740
|
-
|
|
741
|
-
expect(h.recorder.submitted).toHaveLength(0)
|
|
742
|
-
})
|
|
743
|
-
})
|
|
744
|
-
|
|
745
|
-
describe('terminal-first grace window', () => {
|
|
746
|
-
/** Registers a question asked `agoMs` ago, with the user long since silent. */
|
|
747
|
-
function pending(h: Harness, session: string, agoMs: number): void {
|
|
748
|
-
writeSessionState(session, h.env, { last_prompt_at: AWAY })
|
|
749
|
-
registerQuestion(session, h.env, { question: 'Ship it?' }, NOW - agoMs)
|
|
750
|
-
}
|
|
751
|
-
|
|
752
655
|
it('holds the question in the terminal until the window elapses', async () => {
|
|
753
|
-
|
|
754
|
-
// being abandoned. Sleeps advance the virtual clock.
|
|
755
|
-
const h = harness([reply({ text: 'Yes' })], 900)
|
|
656
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
756
657
|
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
757
658
|
pending(h, 'g1', 0)
|
|
758
659
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g1' }))
|
|
759
660
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
760
|
-
//
|
|
661
|
+
// Sleeps advance the virtual clock, so this is proof the wait happened.
|
|
761
662
|
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
|
|
762
663
|
})
|
|
763
664
|
|
|
764
665
|
it('counts the window from when the question was sent, not from the turn end', async () => {
|
|
765
666
|
// Asked 290s ago while the agent kept working: only 10s of wait remains.
|
|
766
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
667
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
668
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
767
669
|
pending(h, 'g2', 290_000)
|
|
768
670
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g2' }))
|
|
769
671
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
770
672
|
expect(h.deps.now?.()).toBeLessThan(NOW + 60_000)
|
|
771
673
|
})
|
|
772
674
|
|
|
773
|
-
it('
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
h.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g3' }))
|
|
785
|
-
expect(h.recorder.submitted).toHaveLength(0)
|
|
786
|
-
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
675
|
+
it('waits the window out on a machine with no idle signal at all', async () => {
|
|
676
|
+
// The timer once refused to run without an idle source to watch, because
|
|
677
|
+
// it was holding a terminal it could not monitor. It holds nothing now.
|
|
678
|
+
const h = harness([])
|
|
679
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
|
|
680
|
+
registerQuestion('present3', h.env, { question: 'Ship it?' }, NOW)
|
|
681
|
+
|
|
682
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present3' }))
|
|
683
|
+
|
|
684
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
685
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
787
686
|
})
|
|
788
687
|
|
|
789
|
-
it('
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: '
|
|
796
|
-
|
|
797
|
-
expect(h.
|
|
798
|
-
expect(h.io.errLines.join('\n')).toContain('no idle signal')
|
|
688
|
+
it('is not a way to switch escalation on when the user has switched it off', async () => {
|
|
689
|
+
// ask_notifications is the "do not reach me" switch and outranks the timer.
|
|
690
|
+
const h = harness([])
|
|
691
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\nask_notifications = false\n')
|
|
692
|
+
registerQuestion('present4', h.env, { question: 'Ship it?' })
|
|
693
|
+
|
|
694
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'present4' }))
|
|
695
|
+
|
|
696
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
799
697
|
})
|
|
800
698
|
|
|
801
|
-
it('never lets the window crowd out
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
699
|
+
it('never lets the longest window crowd out a reply window the server accepts', async () => {
|
|
700
|
+
// The maximum grace a user can configure still has to leave the protocol's
|
|
701
|
+
// 60s minimum inside the waiter ceiling. Real timers do not wake on the
|
|
702
|
+
// exact requested millisecond, so the sleep deliberately overshoots.
|
|
703
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
806
704
|
const virtualSleep = h.deps.sleep!
|
|
807
705
|
h.deps.sleep = async (milliseconds: number) => virtualSleep(milliseconds + 1)
|
|
808
|
-
|
|
809
|
-
mkdirSync(dir, { recursive: true })
|
|
810
|
-
writeFileSync(
|
|
811
|
-
path.join(dir, 'config.toml'),
|
|
812
|
-
'ask_grace_seconds = 334\nhook_reply_timeout_seconds = 540\n',
|
|
813
|
-
)
|
|
706
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 360\n')
|
|
814
707
|
pending(h, 'g5', 0)
|
|
815
708
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'g5' }))
|
|
816
709
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
817
|
-
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW +
|
|
818
|
-
expect(h.deps.now?.()).toBeLessThan(NOW +
|
|
710
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 360_000)
|
|
711
|
+
expect(h.deps.now?.()).toBeLessThan(NOW + 361_000)
|
|
819
712
|
expect(
|
|
820
713
|
h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')?.draft.reply
|
|
821
714
|
?.expires_in_seconds,
|
|
822
715
|
).toBeGreaterThanOrEqual(60)
|
|
823
716
|
})
|
|
824
717
|
|
|
825
|
-
it('
|
|
826
|
-
const h = harness([]
|
|
718
|
+
it('charges transport overrun and close time to the one waiter ceiling', async () => {
|
|
719
|
+
const h = harness([])
|
|
827
720
|
const factory = h.deps.clientFactory
|
|
828
721
|
h.deps.clientFactory = () => {
|
|
829
722
|
const client = factory!()
|
|
@@ -855,7 +748,7 @@ describe('terminal-first grace window', () => {
|
|
|
855
748
|
},
|
|
856
749
|
} as ApiClient
|
|
857
750
|
}
|
|
858
|
-
writeGlobalConfig(h, 'ask_grace_seconds = 250\
|
|
751
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 250\n')
|
|
859
752
|
pending(h, 'transport-budget', 0)
|
|
860
753
|
|
|
861
754
|
await hookRunCommand(h.deps, 'stop', async () => {
|
|
@@ -864,7 +757,10 @@ describe('terminal-first grace window', () => {
|
|
|
864
757
|
return JSON.stringify({ session_id: 'transport-budget' })
|
|
865
758
|
})
|
|
866
759
|
|
|
867
|
-
|
|
760
|
+
// The waiter's 480s ceiling is the last moment it will accept an answer;
|
|
761
|
+
// closing the window and retiring the card happens after it, which is what
|
|
762
|
+
// the blocking hosts' extra minute of declared headroom is there for.
|
|
763
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 540_000)
|
|
868
764
|
expect(readSessionState('transport-budget', h.env).pending).toBeUndefined()
|
|
869
765
|
})
|
|
870
766
|
})
|
|
@@ -916,7 +812,7 @@ describe('nagging guards', () => {
|
|
|
916
812
|
})
|
|
917
813
|
|
|
918
814
|
it('delivers a new question registered during an answer continuation', async () => {
|
|
919
|
-
const h = harness([reply({ text: 'First answer' })]
|
|
815
|
+
const h = harness([reply({ text: 'First answer' })])
|
|
920
816
|
writeSessionState('n3', h.env, { last_prompt_at: AWAY })
|
|
921
817
|
registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
|
|
922
818
|
|
|
@@ -936,7 +832,7 @@ describe('nagging guards', () => {
|
|
|
936
832
|
})
|
|
937
833
|
|
|
938
834
|
it('stops chained questions at the consecutive continuation cap', async () => {
|
|
939
|
-
const h = harness([]
|
|
835
|
+
const h = harness([])
|
|
940
836
|
writeSessionState('n4', h.env, {
|
|
941
837
|
last_prompt_at: AWAY,
|
|
942
838
|
continuation: { answered_at: NOW - 1, count: 3 },
|
|
@@ -949,11 +845,27 @@ describe('nagging guards', () => {
|
|
|
949
845
|
expect(readSessionState('n4', h.env).pending?.[0]?.question).toBe('One more?')
|
|
950
846
|
expect(h.io.errLines.join('\n')).toContain('continuation limit (3) reached')
|
|
951
847
|
})
|
|
848
|
+
|
|
849
|
+
it('starts the consecutive continuation count over when the user takes a turn', async () => {
|
|
850
|
+
// The cap bounds an agent running on its own. A human at the keyboard is
|
|
851
|
+
// the end of that chain, not another link in it.
|
|
852
|
+
const h = harness([])
|
|
853
|
+
writeSessionState('n5', h.env, {
|
|
854
|
+
continuation: { answered_at: NOW - 1, count: MAX_CONTINUATION_COUNT },
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
await hookRunCommand(h.deps, 'user-prompt-submit', stdin({ session_id: 'n5' }))
|
|
858
|
+
|
|
859
|
+
expect(readSessionState('n5', h.env).continuation).toEqual({
|
|
860
|
+
answered_at: NOW - 1,
|
|
861
|
+
count: 0,
|
|
862
|
+
})
|
|
863
|
+
})
|
|
952
864
|
})
|
|
953
865
|
|
|
954
866
|
describe('late answer collection', () => {
|
|
955
867
|
it('collects an answer that arrived during the resumed model turn', async () => {
|
|
956
|
-
const h = harness([reply({ text: 'Ship it' })]
|
|
868
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
957
869
|
writeSessionState('late-stop', h.env, {
|
|
958
870
|
last_prompt_at: AWAY,
|
|
959
871
|
pending: [{
|
|
@@ -979,7 +891,7 @@ describe('late answer collection', () => {
|
|
|
979
891
|
})
|
|
980
892
|
|
|
981
893
|
it('collects a late answer on UserPromptSubmit and retires it truthfully', async () => {
|
|
982
|
-
const h = harness([reply({ text: 'Hold' })]
|
|
894
|
+
const h = harness([reply({ text: 'Hold' })])
|
|
983
895
|
writeSessionState('late-prompt', h.env, {
|
|
984
896
|
last_prompt_at: AWAY,
|
|
985
897
|
pending: [{
|
|
@@ -1024,7 +936,7 @@ describe('late answer collection', () => {
|
|
|
1024
936
|
|
|
1025
937
|
describe('OpenCode answer continuation', () => {
|
|
1026
938
|
it('fails closed instead of accepting an answer the idle event cannot deliver', async () => {
|
|
1027
|
-
const h = harness([reply({ text: 'Approve' })]
|
|
939
|
+
const h = harness([reply({ text: 'Approve' })])
|
|
1028
940
|
writeSessionState('open1', h.env, { last_prompt_at: AWAY })
|
|
1029
941
|
registerQuestion('open1', h.env, { question: 'Deploy?' }, NOW)
|
|
1030
942
|
|
|
@@ -1041,9 +953,9 @@ describe('OpenCode answer continuation', () => {
|
|
|
1041
953
|
})
|
|
1042
954
|
})
|
|
1043
955
|
|
|
1044
|
-
describe('
|
|
956
|
+
describe('the waiter owning one question to the end', () => {
|
|
1045
957
|
it('delivers a reply that commits while the server close fence is finalizing silence', async () => {
|
|
1046
|
-
const h = harness([]
|
|
958
|
+
const h = harness([])
|
|
1047
959
|
const factory = h.deps.clientFactory
|
|
1048
960
|
h.deps.clientFactory = () => {
|
|
1049
961
|
const client = factory!()
|
|
@@ -1064,7 +976,6 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1064
976
|
},
|
|
1065
977
|
} as ApiClient
|
|
1066
978
|
}
|
|
1067
|
-
writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
|
|
1068
979
|
writeSessionState('fenced-reply', h.env, { last_prompt_at: AWAY })
|
|
1069
980
|
registerQuestion('fenced-reply', h.env, { question: 'Deploy?' }, NOW)
|
|
1070
981
|
|
|
@@ -1077,7 +988,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1077
988
|
})
|
|
1078
989
|
|
|
1079
990
|
it('preserves a silent question when the close fence cannot be proven', async () => {
|
|
1080
|
-
const h = harness([]
|
|
991
|
+
const h = harness([])
|
|
1081
992
|
const factory = h.deps.clientFactory
|
|
1082
993
|
h.deps.clientFactory = () => {
|
|
1083
994
|
const client = factory!()
|
|
@@ -1088,7 +999,6 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1088
999
|
},
|
|
1089
1000
|
} as ApiClient
|
|
1090
1001
|
}
|
|
1091
|
-
writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
|
|
1092
1002
|
writeSessionState('unproven-close', h.env, { last_prompt_at: AWAY })
|
|
1093
1003
|
registerQuestion('unproven-close', h.env, { question: 'Deploy?' }, NOW)
|
|
1094
1004
|
|
|
@@ -1101,7 +1011,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1101
1011
|
})
|
|
1102
1012
|
|
|
1103
1013
|
it('recovers an accepted question after its submit response is lost', async () => {
|
|
1104
|
-
const h = harness([]
|
|
1014
|
+
const h = harness([])
|
|
1105
1015
|
const factory = h.deps.clientFactory
|
|
1106
1016
|
let acceptedRequestId: string | undefined
|
|
1107
1017
|
h.deps.clientFactory = () => {
|
|
@@ -1140,7 +1050,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1140
1050
|
})
|
|
1141
1051
|
|
|
1142
1052
|
it('replays the frozen intent when an ambiguous submit never reached the server', async () => {
|
|
1143
|
-
const h = harness([]
|
|
1053
|
+
const h = harness([])
|
|
1144
1054
|
const factory = h.deps.clientFactory
|
|
1145
1055
|
let firstAttempt: SubmitNotificationRequestT | undefined
|
|
1146
1056
|
h.deps.clientFactory = () => {
|
|
@@ -1177,7 +1087,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1177
1087
|
})
|
|
1178
1088
|
|
|
1179
1089
|
it('preserves and exactly replays an intent after a definitive submit rejection', async () => {
|
|
1180
|
-
const h = harness([]
|
|
1090
|
+
const h = harness([])
|
|
1181
1091
|
const factory = h.deps.clientFactory
|
|
1182
1092
|
let polls = 0
|
|
1183
1093
|
h.deps.clientFactory = () => {
|
|
@@ -1218,29 +1128,8 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1218
1128
|
})
|
|
1219
1129
|
})
|
|
1220
1130
|
|
|
1221
|
-
it('returns the terminal when the user comes back after the push', async () => {
|
|
1222
|
-
const h = harness([], 900)
|
|
1223
|
-
writeGlobalConfig(h, 'require_idle = true\n')
|
|
1224
|
-
let checks = 0
|
|
1225
|
-
h.deps.idleSeconds = () => {
|
|
1226
|
-
checks += 1
|
|
1227
|
-
return checks >= 4 ? 1 : 900
|
|
1228
|
-
}
|
|
1229
|
-
writeSessionState('returned', h.env, { last_prompt_at: AWAY })
|
|
1230
|
-
registerQuestion('returned', h.env, { question: 'Deploy?' }, NOW - 300_000)
|
|
1231
|
-
|
|
1232
|
-
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'returned' }))
|
|
1233
|
-
|
|
1234
|
-
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toHaveLength(1)
|
|
1235
|
-
expect(h.recorder.closed).toContain(h.recorder.receipts[0])
|
|
1236
|
-
expect(readSessionState('returned', h.env).pending).toBeUndefined()
|
|
1237
|
-
expect((h.deps.now?.() ?? NOW) - NOW).toBeLessThan(30_000)
|
|
1238
|
-
expect(h.io.errLines.join('\n')).toContain('came back')
|
|
1239
|
-
expect(h.io.errLines.join('\n')).toContain('retired before returning the terminal')
|
|
1240
|
-
})
|
|
1241
|
-
|
|
1242
1131
|
it('closes and retires silence at the absolute owner deadline', async () => {
|
|
1243
|
-
const h = harness([]
|
|
1132
|
+
const h = harness([])
|
|
1244
1133
|
const factory = h.deps.clientFactory
|
|
1245
1134
|
h.deps.clientFactory = () => {
|
|
1246
1135
|
const client = factory!()
|
|
@@ -1254,7 +1143,6 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1254
1143
|
}),
|
|
1255
1144
|
} as ApiClient
|
|
1256
1145
|
}
|
|
1257
|
-
writeGlobalConfig(h, 'hook_reply_timeout_seconds = 60\n')
|
|
1258
1146
|
writeSessionState('deadline', h.env, { last_prompt_at: AWAY })
|
|
1259
1147
|
registerQuestion('deadline', h.env, { question: 'Deploy?' }, NOW)
|
|
1260
1148
|
|
|
@@ -1267,7 +1155,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1267
1155
|
})
|
|
1268
1156
|
|
|
1269
1157
|
it('charges sequential submission latency to one deadline', async () => {
|
|
1270
|
-
const h = harness([reply({ text: 'Done' })]
|
|
1158
|
+
const h = harness([reply({ text: 'Done' })])
|
|
1271
1159
|
writeSessionState('latency', h.env, { last_prompt_at: AWAY })
|
|
1272
1160
|
registerQuestion('latency', h.env, { question: 'First?' }, NOW)
|
|
1273
1161
|
registerQuestion('latency', h.env, { question: 'Second?' }, NOW)
|
|
@@ -1278,12 +1166,14 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1278
1166
|
const windows = h.recorder.submitted
|
|
1279
1167
|
.filter((entry) => entry.draft.event === 'agent_question')
|
|
1280
1168
|
.map((entry) => entry.draft.reply?.expires_in_seconds)
|
|
1281
|
-
|
|
1282
|
-
|
|
1169
|
+
// Both windows end at the same absolute moment: the second question's is
|
|
1170
|
+
// shorter by exactly the 40s the first submission spent.
|
|
1171
|
+
expect(windows).toEqual([480, 440])
|
|
1172
|
+
expect(h.deps.now?.()).toBeLessThanOrEqual(NOW + 480_000)
|
|
1283
1173
|
})
|
|
1284
1174
|
|
|
1285
1175
|
it('re-arms the next Stop for an independently pending answer', async () => {
|
|
1286
|
-
const h = harness([]
|
|
1176
|
+
const h = harness([])
|
|
1287
1177
|
const answers = new Map<string, ReplyView[]>([
|
|
1288
1178
|
['req_hook_1', [reply({ text: 'First answer' })]],
|
|
1289
1179
|
['req_hook_2', []],
|
|
@@ -1315,7 +1205,7 @@ describe('reply-wait presence monitoring', () => {
|
|
|
1315
1205
|
|
|
1316
1206
|
describe('Cursor stop output', () => {
|
|
1317
1207
|
it('fails closed when the invoking shell cannot name the exact conversation', async () => {
|
|
1318
|
-
const h = harness([reply({ text: 'Ship it' })]
|
|
1208
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1319
1209
|
writeSessionState('cursor-conversation', h.env, { last_prompt_at: AWAY })
|
|
1320
1210
|
registerQuestion('cursor-conversation', h.env, { question: 'Deploy now?' })
|
|
1321
1211
|
|
|
@@ -1491,7 +1381,7 @@ describe('several questions in flight', () => {
|
|
|
1491
1381
|
}
|
|
1492
1382
|
|
|
1493
1383
|
it('escalates every registered question in one pass, each as its own notification', async () => {
|
|
1494
|
-
const h = harness([]
|
|
1384
|
+
const h = harness([])
|
|
1495
1385
|
writeSessionState('multi1', h.env, { last_prompt_at: AWAY })
|
|
1496
1386
|
registerQuestion('multi1', h.env, { question: 'Ship it?' }, NOW)
|
|
1497
1387
|
registerQuestion('multi1', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
@@ -1513,7 +1403,7 @@ describe('several questions in flight', () => {
|
|
|
1513
1403
|
* handler; the harness flag only changes answer injection, not settlement.
|
|
1514
1404
|
*/
|
|
1515
1405
|
it('settles a registered form into a durable request_id on the Codex Stop path', async () => {
|
|
1516
|
-
const h = harness([]
|
|
1406
|
+
const h = harness([])
|
|
1517
1407
|
writeSessionState('codex-form', h.env, { last_prompt_at: AWAY })
|
|
1518
1408
|
const built = buildQuestions(
|
|
1519
1409
|
{ form: JSON.stringify({
|
|
@@ -1549,7 +1439,7 @@ describe('several questions in flight', () => {
|
|
|
1549
1439
|
})
|
|
1550
1440
|
|
|
1551
1441
|
it('keeps a settled ask durable and collects the answer after a transient internal_error', async () => {
|
|
1552
|
-
const h = harness([]
|
|
1442
|
+
const h = harness([])
|
|
1553
1443
|
let polls = 0
|
|
1554
1444
|
const factory = h.deps.clientFactory
|
|
1555
1445
|
h.deps.clientFactory = () => {
|
|
@@ -1588,7 +1478,7 @@ describe('several questions in flight', () => {
|
|
|
1588
1478
|
})
|
|
1589
1479
|
|
|
1590
1480
|
it('stops retrying and names a permanent rejection during the blocking multi-wait', async () => {
|
|
1591
|
-
const h = harness([]
|
|
1481
|
+
const h = harness([])
|
|
1592
1482
|
let polls = 0
|
|
1593
1483
|
const factory = h.deps.clientFactory
|
|
1594
1484
|
h.deps.clientFactory = () => {
|
|
@@ -1615,7 +1505,7 @@ describe('several questions in flight', () => {
|
|
|
1615
1505
|
})
|
|
1616
1506
|
|
|
1617
1507
|
it('distinguishes a permanent late-poll rejection from transient trouble', async () => {
|
|
1618
|
-
const h = harness([]
|
|
1508
|
+
const h = harness([])
|
|
1619
1509
|
let polls = 0
|
|
1620
1510
|
const factory = h.deps.clientFactory
|
|
1621
1511
|
h.deps.clientFactory = () => {
|
|
@@ -1655,7 +1545,7 @@ describe('several questions in flight', () => {
|
|
|
1655
1545
|
})
|
|
1656
1546
|
|
|
1657
1547
|
it('resumes with every answer that arrived, each tied to its question', async () => {
|
|
1658
|
-
const h = harness([]
|
|
1548
|
+
const h = harness([])
|
|
1659
1549
|
repliesByRequest(
|
|
1660
1550
|
h,
|
|
1661
1551
|
new Map([
|
|
@@ -1681,7 +1571,7 @@ describe('several questions in flight', () => {
|
|
|
1681
1571
|
})
|
|
1682
1572
|
|
|
1683
1573
|
it('journals an accepted answer until a successor Stop proves delivery', async () => {
|
|
1684
|
-
const h = harness([reply({ text: 'Ship it' })]
|
|
1574
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1685
1575
|
writeSessionState('answer-handoff', h.env, {
|
|
1686
1576
|
last_prompt_at: AWAY,
|
|
1687
1577
|
pending: [
|
|
@@ -1724,7 +1614,7 @@ describe('several questions in flight', () => {
|
|
|
1724
1614
|
})
|
|
1725
1615
|
|
|
1726
1616
|
it('resumes with a partial answer and keeps the rest registered', async () => {
|
|
1727
|
-
const h = harness([]
|
|
1617
|
+
const h = harness([])
|
|
1728
1618
|
const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
|
|
1729
1619
|
repliesByRequest(h, byRequest)
|
|
1730
1620
|
writeSessionState('multi3', h.env, { last_prompt_at: AWAY })
|
|
@@ -1885,14 +1775,15 @@ describe('hostile input', () => {
|
|
|
1885
1775
|
expect(seen).toBe('https://test.notifai.invalid')
|
|
1886
1776
|
})
|
|
1887
1777
|
|
|
1888
|
-
it('clamps an out-of-range
|
|
1778
|
+
it('clamps an out-of-range grace window instead of trusting it', () => {
|
|
1889
1779
|
const h = harness()
|
|
1890
1780
|
const project = mkdtempSync(path.join(os.tmpdir(), 'notifai-bounds-'))
|
|
1891
1781
|
mkdirSync(path.join(project, '.notifai'), { recursive: true })
|
|
1892
|
-
writeFileSync(path.join(project, '.notifai', 'config.toml'), '
|
|
1782
|
+
writeFileSync(path.join(project, '.notifai', 'config.toml'), 'ask_grace_seconds = 99999\n')
|
|
1893
1783
|
const config = loadConfig({ cwd: project, env: h.env })
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1784
|
+
// A committed repository file must not be able to consume the waiter's
|
|
1785
|
+
// whole ceiling and leave no window in which an answer is still accepted.
|
|
1786
|
+
expect(config.ask_grace_seconds.value).toBeLessThanOrEqual(360)
|
|
1896
1787
|
})
|
|
1897
1788
|
})
|
|
1898
1789
|
|
|
@@ -1924,7 +1815,7 @@ describe('ask registration', () => {
|
|
|
1924
1815
|
})
|
|
1925
1816
|
|
|
1926
1817
|
it('registers a multi-question form and pushes it as one set', async () => {
|
|
1927
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
1818
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
1928
1819
|
writeSessionState('form1', h.env, { last_prompt_at: AWAY })
|
|
1929
1820
|
const built = buildQuestions(
|
|
1930
1821
|
{ form: JSON.stringify({
|
|
@@ -1967,6 +1858,34 @@ describe('ask registration', () => {
|
|
|
1967
1858
|
])
|
|
1968
1859
|
})
|
|
1969
1860
|
|
|
1861
|
+
it('relays the real registered identity, question text, and answer without trust or approval claims', async () => {
|
|
1862
|
+
const h = harness(
|
|
1863
|
+
[
|
|
1864
|
+
reply({
|
|
1865
|
+
text: 'BETA',
|
|
1866
|
+
answers: [{ question_id: 'rollout-option', choice_ids: ['beta'], text: null }],
|
|
1867
|
+
}),
|
|
1868
|
+
],
|
|
1869
|
+
900,
|
|
1870
|
+
)
|
|
1871
|
+
writeSessionState('framing1', h.env, { last_prompt_at: AWAY })
|
|
1872
|
+
const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
|
|
1873
|
+
expect(built.ok).toBe(true)
|
|
1874
|
+
if (!built.ok) return
|
|
1875
|
+
registerQuestion('framing1', h.env, {
|
|
1876
|
+
question: 'Which rollout option?',
|
|
1877
|
+
questions: built.questions,
|
|
1878
|
+
})
|
|
1879
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'framing1' }))
|
|
1880
|
+
|
|
1881
|
+
const decision = JSON.parse(h.io.outLines.at(-1)!) as { decision: string; reason: string }
|
|
1882
|
+
expect(decision.decision).toBe('block')
|
|
1883
|
+
expect(decision.reason).toContain('question_id rollout-option')
|
|
1884
|
+
expect(decision.reason).toContain('"Which rollout option?"')
|
|
1885
|
+
expect(decision.reason).toContain('"BETA"')
|
|
1886
|
+
expect(decision.reason).not.toMatch(/trusted|urgent|permission|approval/i)
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1970
1889
|
it('acts on the latest reply when answers conflict, because it is a correction', async () => {
|
|
1971
1890
|
const h = harness(
|
|
1972
1891
|
[
|
|
@@ -2132,7 +2051,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
2132
2051
|
})
|
|
2133
2052
|
|
|
2134
2053
|
it('preserves a delivered question through a second ask and the prompt transition', async () => {
|
|
2135
|
-
const h = harness([]
|
|
2054
|
+
const h = harness([])
|
|
2136
2055
|
const live = {
|
|
2137
2056
|
question: 'Ship it?',
|
|
2138
2057
|
request_id: 'req_live',
|
|
@@ -2231,7 +2150,7 @@ describe('user-prompt-submit hook', () => {
|
|
|
2231
2150
|
})
|
|
2232
2151
|
|
|
2233
2152
|
it('retires as done/answered when the answer came from a device', async () => {
|
|
2234
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
2153
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
2235
2154
|
registerQuestion('s16', h.env, { question: 'Ship it?' })
|
|
2236
2155
|
|
|
2237
2156
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
|
|
@@ -2442,7 +2361,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
2442
2361
|
// The hook has always known session_id and never passed it on, so two
|
|
2443
2362
|
// agents in separate worktrees produced identical notifications and the
|
|
2444
2363
|
// user could answer the wrong one's question.
|
|
2445
|
-
const h = harness([]
|
|
2364
|
+
const h = harness([])
|
|
2446
2365
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
2447
2366
|
|
|
2448
2367
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
@@ -2451,7 +2370,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
2451
2370
|
})
|
|
2452
2371
|
|
|
2453
2372
|
it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
|
|
2454
|
-
const h = harness([reply({ text: 'Yes' })]
|
|
2373
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
2455
2374
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
2456
2375
|
|
|
2457
2376
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
@@ -2466,7 +2385,7 @@ describe('telling concurrent agents apart', () => {
|
|
|
2466
2385
|
})
|
|
2467
2386
|
|
|
2468
2387
|
it('prefers a name the user chose over the harness UUID', async () => {
|
|
2469
|
-
const h = harness([]
|
|
2388
|
+
const h = harness([])
|
|
2470
2389
|
h.env['NOTIFAI_SESSION'] = 'migration-worktree'
|
|
2471
2390
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
2472
2391
|
|
|
@@ -2477,44 +2396,19 @@ describe('telling concurrent agents apart', () => {
|
|
|
2477
2396
|
})
|
|
2478
2397
|
|
|
2479
2398
|
describe('clock jumps', () => {
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
//
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
const state = { last_prompt_at: NOW - 400 * 24 * 3600 * 1000 }
|
|
2487
|
-
expect(isUserAway(state, config, NOW, null)).toBe(false)
|
|
2488
|
-
})
|
|
2489
|
-
|
|
2490
|
-
it('does not read a backward jump as the user being present either', () => {
|
|
2491
|
-
// A negative delta is nonsense, not freshness. It resolves the same way:
|
|
2492
|
-
// no evidence, so leave the terminal alone.
|
|
2493
|
-
expect(isUserAway({ last_prompt_at: NOW + 60_000 }, config, NOW, null)).toBe(false)
|
|
2494
|
-
})
|
|
2495
|
-
|
|
2496
|
-
it('still escalates on an ordinary long silence', () => {
|
|
2497
|
-
expect(isUserAway({ last_prompt_at: NOW - 3600_000 }, config, NOW, null)).toBe(true)
|
|
2498
|
-
})
|
|
2499
|
-
|
|
2500
|
-
it('lets the OS idle signal decide regardless of the wall clock', () => {
|
|
2501
|
-
// The idle probe measures elapsed time directly, so it is unaffected — and
|
|
2502
|
-
// it outranks the proxy anyway.
|
|
2503
|
-
const nonsense = { last_prompt_at: NOW + 999_999_999 }
|
|
2504
|
-
expect(isUserAway(nonsense, config, NOW, 900)).toBe(true)
|
|
2505
|
-
expect(isUserAway(nonsense, config, NOW, 1)).toBe(false)
|
|
2506
|
-
})
|
|
2507
|
-
|
|
2508
|
-
it('does not let a stamp from the future hold the terminal past the budget', async () => {
|
|
2509
|
-
// asked_at is wall-clock too. A future stamp used to make the grace window
|
|
2510
|
-
// unreachable, blocking Stop until the harness killed it.
|
|
2511
|
-
const h = harness([], 900)
|
|
2399
|
+
it('does not let a stamp from the future consume the whole waiter ceiling', async () => {
|
|
2400
|
+
// `asked_at` is wall-clock, and an NTP correction or VM resume can move it.
|
|
2401
|
+
// A stamp thirty days out would otherwise make the grace window unreachable
|
|
2402
|
+
// and leave nothing of the ceiling for an answer to arrive in.
|
|
2403
|
+
const h = harness([])
|
|
2404
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 300\n')
|
|
2512
2405
|
registerQuestion('sess-jump', h.env, { question: 'Ship it?' }, NOW + 30 * 24 * 3600 * 1000)
|
|
2513
2406
|
|
|
2514
2407
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-jump' }))
|
|
2515
2408
|
|
|
2516
|
-
// It escalated rather than spinning: the window restarted from now.
|
|
2517
2409
|
expect(h.recorder.submitted.length).toBeGreaterThan(0)
|
|
2410
|
+
// The window ran from now, not from the impossible stamp.
|
|
2411
|
+
expect(h.deps.now?.()).toBeGreaterThanOrEqual(NOW + 300_000)
|
|
2518
2412
|
})
|
|
2519
2413
|
})
|
|
2520
2414
|
|
|
@@ -2755,7 +2649,7 @@ describe('two hooks racing one question', () => {
|
|
|
2755
2649
|
})
|
|
2756
2650
|
|
|
2757
2651
|
it('sends one notification when a second Stop arrives mid-flight', async () => {
|
|
2758
|
-
const h = harness([]
|
|
2652
|
+
const h = harness([])
|
|
2759
2653
|
writeSessionState('race5', h.env, { last_prompt_at: AWAY })
|
|
2760
2654
|
registerQuestion('race5', h.env, { question: 'Ship it?' })
|
|
2761
2655
|
// Standing in for the other process: the claim is already held.
|
|
@@ -2769,7 +2663,7 @@ describe('two hooks racing one question', () => {
|
|
|
2769
2663
|
|
|
2770
2664
|
it('resumes the agent once when two Stops collect the same late answer', async () => {
|
|
2771
2665
|
const answers: ReplyView[] = []
|
|
2772
|
-
const h = harness(answers
|
|
2666
|
+
const h = harness(answers)
|
|
2773
2667
|
writeSessionState('race-late-answer', h.env, {
|
|
2774
2668
|
last_prompt_at: AWAY,
|
|
2775
2669
|
pending: [
|
|
@@ -2816,7 +2710,7 @@ describe('two hooks racing one question', () => {
|
|
|
2816
2710
|
|
|
2817
2711
|
describe('question registration racing a Stop submission', () => {
|
|
2818
2712
|
it('keeps the new question when the older owner expires', async () => {
|
|
2819
|
-
const h = harness([]
|
|
2713
|
+
const h = harness([])
|
|
2820
2714
|
writeSessionState('submit-race', h.env, { last_prompt_at: AWAY })
|
|
2821
2715
|
registerQuestion('submit-race', h.env, { question: 'Old question?' }, NOW)
|
|
2822
2716
|
h.recorder.beforeQuestionSubmit = () => {
|
|
@@ -2839,7 +2733,7 @@ describe('question registration racing a Stop submission', () => {
|
|
|
2839
2733
|
})
|
|
2840
2734
|
|
|
2841
2735
|
it('keeps the newer question when the older in-flight question receives an answer', async () => {
|
|
2842
|
-
const h = harness([reply({ text: 'Old answer' })]
|
|
2736
|
+
const h = harness([reply({ text: 'Old answer' })])
|
|
2843
2737
|
writeSessionState('answer-race', h.env, { last_prompt_at: AWAY })
|
|
2844
2738
|
registerQuestion('answer-race', h.env, { question: 'Old question?' }, NOW)
|
|
2845
2739
|
h.recorder.beforeQuestionSubmit = () => {
|
|
@@ -2855,3 +2749,599 @@ describe('question registration racing a Stop submission', () => {
|
|
|
2855
2749
|
expect(state.accepted?.answers[0]?.reply.text).toBe('Old answer')
|
|
2856
2750
|
})
|
|
2857
2751
|
})
|
|
2752
|
+
|
|
2753
|
+
describe('Claude Code Stop wake route', () => {
|
|
2754
|
+
function claudeWake(status: 'idle' | 'busy' = 'idle'): ClaudeWakeAdapters & {
|
|
2755
|
+
sent: string[]
|
|
2756
|
+
resumed: string[]
|
|
2757
|
+
sleeps: number[]
|
|
2758
|
+
} {
|
|
2759
|
+
const sent: string[] = []
|
|
2760
|
+
const resumed: string[] = []
|
|
2761
|
+
const sleeps: number[] = []
|
|
2762
|
+
return {
|
|
2763
|
+
sent,
|
|
2764
|
+
resumed,
|
|
2765
|
+
sleeps,
|
|
2766
|
+
async listAgents() {
|
|
2767
|
+
return [
|
|
2768
|
+
{
|
|
2769
|
+
pid: 12345,
|
|
2770
|
+
sessionId: 'claude-route',
|
|
2771
|
+
startedAt: NOW,
|
|
2772
|
+
status,
|
|
2773
|
+
},
|
|
2774
|
+
]
|
|
2775
|
+
},
|
|
2776
|
+
readDescriptor() {
|
|
2777
|
+
return {
|
|
2778
|
+
pid: 12345,
|
|
2779
|
+
sessionId: 'claude-route',
|
|
2780
|
+
cwd: '/tmp/claude-route',
|
|
2781
|
+
startedAt: NOW,
|
|
2782
|
+
procStart: 'Wed Aug 12 08:17:53 2026',
|
|
2783
|
+
version: '2.1.228',
|
|
2784
|
+
peerProtocol: 1,
|
|
2785
|
+
messagingSocketPath: '/tmp/cc-socks/12345.sock',
|
|
2786
|
+
status,
|
|
2787
|
+
}
|
|
2788
|
+
},
|
|
2789
|
+
async sendSocket(_socketPath, line) {
|
|
2790
|
+
sent.push(line)
|
|
2791
|
+
},
|
|
2792
|
+
async resume(_sessionId, _cwd, context) {
|
|
2793
|
+
resumed.push(context)
|
|
2794
|
+
},
|
|
2795
|
+
async sleep(milliseconds) {
|
|
2796
|
+
sleeps.push(milliseconds)
|
|
2797
|
+
},
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
it('routes an accepted answer through the Claude inbox instead of hook stdout', async () => {
|
|
2802
|
+
const h = harness([reply({ text: 'BETA' })])
|
|
2803
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
2804
|
+
writeSessionState('claude-route', h.env, { last_prompt_at: AWAY })
|
|
2805
|
+
const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
|
|
2806
|
+
expect(built.ok).toBe(true)
|
|
2807
|
+
if (!built.ok) return
|
|
2808
|
+
registerQuestion('claude-route', h.env, {
|
|
2809
|
+
question: 'Which rollout option?',
|
|
2810
|
+
questions: built.questions,
|
|
2811
|
+
})
|
|
2812
|
+
const wake = claudeWake()
|
|
2813
|
+
|
|
2814
|
+
await hookRunCommand(
|
|
2815
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
2816
|
+
'stop',
|
|
2817
|
+
stdin({ session_id: 'claude-route', cwd: '/tmp/claude-route' }),
|
|
2818
|
+
'claude-code',
|
|
2819
|
+
)
|
|
2820
|
+
|
|
2821
|
+
expect(h.io.outLines).toEqual([])
|
|
2822
|
+
expect(wake.sent).toHaveLength(1)
|
|
2823
|
+
const message = JSON.parse(wake.sent[0]!) as {
|
|
2824
|
+
type: string
|
|
2825
|
+
message: { role: string; content: string }
|
|
2826
|
+
}
|
|
2827
|
+
expect(message).toMatchObject({ type: 'user', message: { role: 'user' } })
|
|
2828
|
+
expect(message.message.content).toContain('question_id')
|
|
2829
|
+
expect(message.message.content).toContain('"Which rollout option?"')
|
|
2830
|
+
expect(message.message.content).toContain('"BETA"')
|
|
2831
|
+
expect(message.message.content).not.toMatch(/trusted|urgent|permission|approval/i)
|
|
2832
|
+
expect(wake.sleeps).toEqual([CLAUDE_POST_SEND_LIVENESS_MS])
|
|
2833
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeDefined()
|
|
2834
|
+
})
|
|
2835
|
+
|
|
2836
|
+
/** One answer already accepted and journaled, ready for the next Stop. */
|
|
2837
|
+
function journaledAnswer(
|
|
2838
|
+
h: Harness,
|
|
2839
|
+
options: { recordedAt?: number; deliveredAt?: number } = {},
|
|
2840
|
+
): void {
|
|
2841
|
+
const recordedAt = options.recordedAt ?? NOW
|
|
2842
|
+
writeSessionState('claude-route', h.env, {
|
|
2843
|
+
...readSessionState('claude-route', h.env),
|
|
2844
|
+
accepted: {
|
|
2845
|
+
...(options.deliveredAt === undefined
|
|
2846
|
+
? {}
|
|
2847
|
+
: { delivered_at: options.deliveredAt, delivered_route: 'inbox-socket' }),
|
|
2848
|
+
answers: [
|
|
2849
|
+
{
|
|
2850
|
+
pending: {
|
|
2851
|
+
question: 'Which rollout option?',
|
|
2852
|
+
request_id: 'req_existing',
|
|
2853
|
+
collapse_key: 'question-existing',
|
|
2854
|
+
device_ids: ['dev_iphone'],
|
|
2855
|
+
},
|
|
2856
|
+
reply: reply({ text: 'BETA' }),
|
|
2857
|
+
replies: [reply({ text: 'BETA' })],
|
|
2858
|
+
},
|
|
2859
|
+
],
|
|
2860
|
+
remaining: 0,
|
|
2861
|
+
recorded_at: recordedAt,
|
|
2862
|
+
},
|
|
2863
|
+
})
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
it('settles a socket delivery so the woken turn cannot redeliver the answer', async () => {
|
|
2867
|
+
// The exact device-proof failure. A socket delivery starts a brand-new turn
|
|
2868
|
+
// rather than continuing this one, so the Stop that ends the woken turn
|
|
2869
|
+
// reports stop_hook_active=false — for ever. While that flag was the only
|
|
2870
|
+
// acknowledgement, the journal never settled and every turn-end delivered
|
|
2871
|
+
// the same answer again: 250 times, in the transcript that found this.
|
|
2872
|
+
const h = harness([])
|
|
2873
|
+
journaledAnswer(h)
|
|
2874
|
+
const wake = claudeWake()
|
|
2875
|
+
const deps = { ...h.deps, claudeWake: wake, claudeSourcePid: 12345 }
|
|
2876
|
+
const stop = (stopHookActive: boolean) =>
|
|
2877
|
+
hookRunCommand(
|
|
2878
|
+
deps,
|
|
2879
|
+
'stop',
|
|
2880
|
+
stdin({
|
|
2881
|
+
session_id: 'claude-route',
|
|
2882
|
+
cwd: '/tmp/claude-route',
|
|
2883
|
+
stop_hook_active: stopHookActive,
|
|
2884
|
+
}),
|
|
2885
|
+
'claude-code',
|
|
2886
|
+
)
|
|
2887
|
+
|
|
2888
|
+
await stop(false)
|
|
2889
|
+
|
|
2890
|
+
expect(wake.sent).toHaveLength(1)
|
|
2891
|
+
const delivered = readSessionState('claude-route', h.env).accepted
|
|
2892
|
+
expect(delivered?.delivered_route).toBe('inbox-socket')
|
|
2893
|
+
expect(typeof delivered?.delivered_at).toBe('number')
|
|
2894
|
+
|
|
2895
|
+
// Three more turn-ends of the woken session, none of them a continuation.
|
|
2896
|
+
await stop(false)
|
|
2897
|
+
await stop(false)
|
|
2898
|
+
await stop(false)
|
|
2899
|
+
|
|
2900
|
+
expect(wake.sent).toHaveLength(1)
|
|
2901
|
+
expect(wake.resumed).toEqual([])
|
|
2902
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
2903
|
+
// Settled, not merely dropped: the answered question is retired truthfully.
|
|
2904
|
+
expect(
|
|
2905
|
+
h.recorder.submitted.find(
|
|
2906
|
+
(entry) =>
|
|
2907
|
+
entry.draft.event === 'question_retired' &&
|
|
2908
|
+
entry.draft.lifecycle?.retires_request_id === 'req_existing',
|
|
2909
|
+
)?.draft.lifecycle?.state,
|
|
2910
|
+
).toBe('answered')
|
|
2911
|
+
})
|
|
2912
|
+
|
|
2913
|
+
it('applies the continuation cap to a woken turn that never sets stop_hook_active', async () => {
|
|
2914
|
+
// The chained loop the cap exists for, on a route that starts new turns.
|
|
2915
|
+
// Keyed to stop_hook_active the cap could never fire here at all.
|
|
2916
|
+
const h = harness([])
|
|
2917
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
2918
|
+
writeSessionState('claude-route', h.env, {
|
|
2919
|
+
last_prompt_at: AWAY,
|
|
2920
|
+
continuation: { answered_at: NOW - 2000, count: MAX_CONTINUATION_COUNT - 1 },
|
|
2921
|
+
})
|
|
2922
|
+
journaledAnswer(h, { recordedAt: NOW - 1000, deliveredAt: NOW - 900 })
|
|
2923
|
+
// The woken turn asked a genuinely new question before it ended.
|
|
2924
|
+
registerQuestion('claude-route', h.env, { question: 'And after that?' }, NOW)
|
|
2925
|
+
const wake = claudeWake()
|
|
2926
|
+
|
|
2927
|
+
await hookRunCommand(
|
|
2928
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
2929
|
+
'stop',
|
|
2930
|
+
stdin({
|
|
2931
|
+
session_id: 'claude-route',
|
|
2932
|
+
cwd: '/tmp/claude-route',
|
|
2933
|
+
stop_hook_active: false,
|
|
2934
|
+
}),
|
|
2935
|
+
'claude-code',
|
|
2936
|
+
)
|
|
2937
|
+
|
|
2938
|
+
expect(h.recorder.submitted.filter((entry) => entry.draft.event === 'agent_question')).toEqual(
|
|
2939
|
+
[],
|
|
2940
|
+
)
|
|
2941
|
+
expect(readSessionState('claude-route', h.env).pending?.[0]?.question).toBe('And after that?')
|
|
2942
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
2943
|
+
`continuation limit (${MAX_CONTINUATION_COUNT}) reached`,
|
|
2944
|
+
)
|
|
2945
|
+
})
|
|
2946
|
+
|
|
2947
|
+
it('uses the accepted journal as the loop guard after a socket wake fires Stop again', async () => {
|
|
2948
|
+
const h = harness([])
|
|
2949
|
+
writeSessionState('claude-route', h.env, {
|
|
2950
|
+
accepted: {
|
|
2951
|
+
answers: [
|
|
2952
|
+
{
|
|
2953
|
+
pending: {
|
|
2954
|
+
question: 'Which rollout option?',
|
|
2955
|
+
request_id: 'req_existing',
|
|
2956
|
+
collapse_key: 'question-existing',
|
|
2957
|
+
device_ids: ['dev_iphone'],
|
|
2958
|
+
},
|
|
2959
|
+
reply: reply({ text: 'BETA' }),
|
|
2960
|
+
replies: [reply({ text: 'BETA' })],
|
|
2961
|
+
},
|
|
2962
|
+
],
|
|
2963
|
+
remaining: 0,
|
|
2964
|
+
recorded_at: NOW,
|
|
2965
|
+
},
|
|
2966
|
+
})
|
|
2967
|
+
const wake = claudeWake()
|
|
2968
|
+
|
|
2969
|
+
await hookRunCommand(
|
|
2970
|
+
{ ...h.deps, claudeWake: wake, claudeSourcePid: 12345 },
|
|
2971
|
+
'stop',
|
|
2972
|
+
stdin({
|
|
2973
|
+
session_id: 'claude-route',
|
|
2974
|
+
cwd: '/tmp/claude-route',
|
|
2975
|
+
stop_hook_active: true,
|
|
2976
|
+
}),
|
|
2977
|
+
'claude-code',
|
|
2978
|
+
)
|
|
2979
|
+
|
|
2980
|
+
expect(wake.sent).toEqual([])
|
|
2981
|
+
expect(wake.resumed).toEqual([])
|
|
2982
|
+
expect(h.io.outLines).toEqual([])
|
|
2983
|
+
expect(readSessionState('claude-route', h.env).accepted).toBeUndefined()
|
|
2984
|
+
})
|
|
2985
|
+
})
|
|
2986
|
+
|
|
2987
|
+
describe('Codex Stop wake route', () => {
|
|
2988
|
+
const CODEX_THREAD = '019ff69d-a07f-7161-ab6e-bd06b3b93c8e'
|
|
2989
|
+
|
|
2990
|
+
function codexWake(
|
|
2991
|
+
options: { sourceAlive?: boolean; probe?: CodexWakeObservation } = {},
|
|
2992
|
+
): CodexWakeAdapters & { resumed: string[]; probed: string[] } {
|
|
2993
|
+
const resumed: string[] = []
|
|
2994
|
+
const probed: string[] = []
|
|
2995
|
+
return {
|
|
2996
|
+
resumed,
|
|
2997
|
+
probed,
|
|
2998
|
+
probeThreadWriter(lockPath) {
|
|
2999
|
+
probed.push(lockPath)
|
|
3000
|
+
return options.probe ?? { state: 'stopped' }
|
|
3001
|
+
},
|
|
3002
|
+
sourceAlive() {
|
|
3003
|
+
return options.sourceAlive ?? true
|
|
3004
|
+
},
|
|
3005
|
+
async resume(_threadId, _cwd, context) {
|
|
3006
|
+
resumed.push(context)
|
|
3007
|
+
},
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function journaledAnswer(h: Harness): void {
|
|
3012
|
+
writeSessionState(CODEX_THREAD, h.env, {
|
|
3013
|
+
accepted: {
|
|
3014
|
+
answers: [
|
|
3015
|
+
{
|
|
3016
|
+
pending: {
|
|
3017
|
+
question: 'Which rollout option?',
|
|
3018
|
+
request_id: 'req_existing',
|
|
3019
|
+
collapse_key: 'question-existing',
|
|
3020
|
+
device_ids: ['dev_iphone'],
|
|
3021
|
+
},
|
|
3022
|
+
reply: reply({ text: 'BETA' }),
|
|
3023
|
+
replies: [reply({ text: 'BETA' })],
|
|
3024
|
+
},
|
|
3025
|
+
],
|
|
3026
|
+
remaining: 0,
|
|
3027
|
+
recorded_at: NOW,
|
|
3028
|
+
},
|
|
3029
|
+
})
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
it('continues the held turn with decision:block when the answer arrives during the hold', async () => {
|
|
3033
|
+
const h = harness([reply({ text: 'BETA' })])
|
|
3034
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
3035
|
+
writeSessionState(CODEX_THREAD, h.env, { last_prompt_at: AWAY })
|
|
3036
|
+
const built = buildQuestions({ choice: ['ALPHA', 'BETA'] }, 'Which rollout option?')
|
|
3037
|
+
expect(built.ok).toBe(true)
|
|
3038
|
+
if (!built.ok) return
|
|
3039
|
+
registerQuestion(CODEX_THREAD, h.env, {
|
|
3040
|
+
question: 'Which rollout option?',
|
|
3041
|
+
questions: built.questions,
|
|
3042
|
+
})
|
|
3043
|
+
const wake = codexWake()
|
|
3044
|
+
|
|
3045
|
+
await hookRunCommand(
|
|
3046
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3047
|
+
'stop',
|
|
3048
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3049
|
+
'codex',
|
|
3050
|
+
)
|
|
3051
|
+
|
|
3052
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
3053
|
+
const decision = JSON.parse(h.io.outLines[0]!) as { decision: string; reason: string }
|
|
3054
|
+
expect(decision.decision).toBe('block')
|
|
3055
|
+
expect(decision.reason).toContain('"BETA"')
|
|
3056
|
+
// The default route owes the thread-writer lock nothing: a live Codex is
|
|
3057
|
+
// reading this stdout, and a probe could only take the answer away.
|
|
3058
|
+
expect(wake.probed).toEqual([])
|
|
3059
|
+
expect(wake.resumed).toEqual([])
|
|
3060
|
+
expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
|
|
3061
|
+
})
|
|
3062
|
+
|
|
3063
|
+
it('replays an answer journaled after the hold on the next Stop', async () => {
|
|
3064
|
+
const h = harness([])
|
|
3065
|
+
journaledAnswer(h)
|
|
3066
|
+
const wake = codexWake()
|
|
3067
|
+
|
|
3068
|
+
await hookRunCommand(
|
|
3069
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3070
|
+
'stop',
|
|
3071
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3072
|
+
'codex',
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
expect(h.io.outLines).toHaveLength(1)
|
|
3076
|
+
expect(JSON.parse(h.io.outLines[0]!)).toMatchObject({ decision: 'block' })
|
|
3077
|
+
// Still journaled: only a successor Stop proves the continued turn ran.
|
|
3078
|
+
expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
|
|
3079
|
+
})
|
|
3080
|
+
|
|
3081
|
+
it('cold-resumes the journaled answer when the Codex process is gone and no writer holds the thread', async () => {
|
|
3082
|
+
const h = harness([])
|
|
3083
|
+
journaledAnswer(h)
|
|
3084
|
+
const wake = codexWake({ sourceAlive: false, probe: { state: 'stopped' } })
|
|
3085
|
+
|
|
3086
|
+
await hookRunCommand(
|
|
3087
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3088
|
+
'stop',
|
|
3089
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3090
|
+
'codex',
|
|
3091
|
+
)
|
|
3092
|
+
|
|
3093
|
+
expect(h.io.outLines).toEqual([])
|
|
3094
|
+
expect(wake.resumed).toHaveLength(1)
|
|
3095
|
+
expect(wake.resumed[0]).toContain('"BETA"')
|
|
3096
|
+
expect(wake.probed).toHaveLength(2)
|
|
3097
|
+
})
|
|
3098
|
+
|
|
3099
|
+
it('holds the answer rather than resuming a thread a live writer owns', async () => {
|
|
3100
|
+
const h = harness([])
|
|
3101
|
+
journaledAnswer(h)
|
|
3102
|
+
const wake = codexWake({ sourceAlive: false, probe: { state: 'live' } })
|
|
3103
|
+
|
|
3104
|
+
await hookRunCommand(
|
|
3105
|
+
{ ...h.deps, codexWake: wake, codexSourcePid: 12345 },
|
|
3106
|
+
'stop',
|
|
3107
|
+
stdin({ session_id: CODEX_THREAD, cwd: '/tmp/codex-route' }),
|
|
3108
|
+
'codex',
|
|
3109
|
+
)
|
|
3110
|
+
|
|
3111
|
+
expect(h.io.outLines).toEqual([])
|
|
3112
|
+
expect(wake.resumed).toEqual([])
|
|
3113
|
+
expect(readSessionState(CODEX_THREAD, h.env).accepted).toBeDefined()
|
|
3114
|
+
expect(h.io.errLines.join('\n')).toContain('holding the accepted answer for the next turn')
|
|
3115
|
+
})
|
|
3116
|
+
})
|
|
3117
|
+
|
|
3118
|
+
describe('escalation waiter delivery seam', () => {
|
|
3119
|
+
function waiterContext(h: Harness) {
|
|
3120
|
+
const client = h.deps.clientFactory!('https://test.notifai.invalid', 'Bearer test')
|
|
3121
|
+
return {
|
|
3122
|
+
client,
|
|
3123
|
+
config: loadConfig({ cwd: h.deps.cwd, env: h.env }),
|
|
3124
|
+
env: h.env,
|
|
3125
|
+
now: h.deps.now!,
|
|
3126
|
+
sleep: h.deps.sleep!,
|
|
3127
|
+
waitForFirstReply: async (requestId: string, timeoutSeconds: number) => {
|
|
3128
|
+
const response = await client.replies(requestId, {
|
|
3129
|
+
waitSeconds: timeoutSeconds,
|
|
3130
|
+
afterSeq: 0,
|
|
3131
|
+
})
|
|
3132
|
+
return { replies: response.replies, timedOut: response.replies.length === 0 }
|
|
3133
|
+
},
|
|
3134
|
+
harness: 'codex' as const,
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
it('waits through grace, routes the fenced answer, and holds the claim until delivery settles', async () => {
|
|
3139
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
3140
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 120\n')
|
|
3141
|
+
writeSessionState('waiter-route', h.env, { last_prompt_at: AWAY })
|
|
3142
|
+
registerQuestion('waiter-route', h.env, { question: 'Ship it?' }, NOW)
|
|
3143
|
+
|
|
3144
|
+
const context = waiterContext(h)
|
|
3145
|
+
const deliveries: Array<{ route: string; context: string }> = []
|
|
3146
|
+
let claimHeldDuringDelivery = false
|
|
3147
|
+
const deliver = vi.fn(async (event: { context: string }) => {
|
|
3148
|
+
deliveries.push({ route: 'hook-continuation', context: event.context })
|
|
3149
|
+
claimHeldDuringDelivery = !claimQuestionPush('waiter-route', h.env)
|
|
3150
|
+
return { stdout: JSON.stringify({ decision: 'block', reason: event.context }) }
|
|
3151
|
+
})
|
|
3152
|
+
|
|
3153
|
+
const outcome = await runEscalationWaiter(context, {
|
|
3154
|
+
sessionId: 'waiter-route',
|
|
3155
|
+
envelope: { session_id: 'waiter-route' },
|
|
3156
|
+
route: { kind: 'hook-continuation', deliver },
|
|
3157
|
+
processDeadlineAt: NOW + 480_000,
|
|
3158
|
+
})
|
|
3159
|
+
|
|
3160
|
+
expect((h.deps.now?.() ?? NOW) - NOW).toBeGreaterThanOrEqual(120_000)
|
|
3161
|
+
expect(deliver).toHaveBeenCalledTimes(1)
|
|
3162
|
+
expect(deliver).toHaveBeenCalledWith(
|
|
3163
|
+
expect.objectContaining({ journal_recorded_at: expect.any(Number) }),
|
|
3164
|
+
)
|
|
3165
|
+
expect(deliveries).toEqual([
|
|
3166
|
+
{ route: 'hook-continuation', context: expect.stringContaining('Ship it') },
|
|
3167
|
+
])
|
|
3168
|
+
expect(claimHeldDuringDelivery).toBe(true)
|
|
3169
|
+
expect(JSON.parse(outcome.stdout ?? '{}')).toMatchObject({
|
|
3170
|
+
decision: 'block',
|
|
3171
|
+
reason: expect.stringContaining('Ship it'),
|
|
3172
|
+
})
|
|
3173
|
+
expect(claimQuestionPush('waiter-route', h.env)).toBe(true)
|
|
3174
|
+
releaseQuestionPush('waiter-route', h.env)
|
|
3175
|
+
})
|
|
3176
|
+
|
|
3177
|
+
it('races a second Stop while grace, polling, fencing, and route delivery are still owned', async () => {
|
|
3178
|
+
const h = harness([])
|
|
3179
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 10\n')
|
|
3180
|
+
writeSessionState('waiter-race-lifetime', h.env, { last_prompt_at: AWAY })
|
|
3181
|
+
registerQuestion('waiter-race-lifetime', h.env, { question: 'Ship it?' }, NOW)
|
|
3182
|
+
const context = waiterContext(h)
|
|
3183
|
+
const phases: string[] = []
|
|
3184
|
+
|
|
3185
|
+
let waitingForReply = false
|
|
3186
|
+
context.sleep = async (milliseconds: number) => {
|
|
3187
|
+
if (!waitingForReply) {
|
|
3188
|
+
phases.push('grace')
|
|
3189
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3190
|
+
}
|
|
3191
|
+
h.advanceClock(milliseconds)
|
|
3192
|
+
}
|
|
3193
|
+
context.waitForFirstReply = async () => {
|
|
3194
|
+
waitingForReply = true
|
|
3195
|
+
phases.push('reply-wait')
|
|
3196
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3197
|
+
return { replies: [reply({ text: 'Ship it' })], timedOut: false }
|
|
3198
|
+
}
|
|
3199
|
+
const closeReplies = context.client.closeReplies.bind(context.client)
|
|
3200
|
+
context.client.closeReplies = async (requestId) => {
|
|
3201
|
+
phases.push('close-fence')
|
|
3202
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3203
|
+
return closeReplies(requestId)
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
await runEscalationWaiter(context, {
|
|
3207
|
+
sessionId: 'waiter-race-lifetime',
|
|
3208
|
+
envelope: { session_id: 'waiter-race-lifetime' },
|
|
3209
|
+
route: {
|
|
3210
|
+
kind: 'hook-continuation',
|
|
3211
|
+
deliver: async (event) => {
|
|
3212
|
+
phases.push('delivery')
|
|
3213
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(false)
|
|
3214
|
+
return { stdout: JSON.stringify({ decision: 'block', reason: event.context }), notes: [] }
|
|
3215
|
+
},
|
|
3216
|
+
},
|
|
3217
|
+
processDeadlineAt: NOW + 480_000,
|
|
3218
|
+
})
|
|
3219
|
+
|
|
3220
|
+
expect(phases.filter((phase) => phase === 'grace').length).toBeGreaterThan(0)
|
|
3221
|
+
expect(phases.filter((phase) => phase !== 'grace')).toEqual([
|
|
3222
|
+
'reply-wait',
|
|
3223
|
+
'close-fence',
|
|
3224
|
+
'delivery',
|
|
3225
|
+
])
|
|
3226
|
+
expect(claimQuestionPush('waiter-race-lifetime', h.env)).toBe(true)
|
|
3227
|
+
releaseQuestionPush('waiter-race-lifetime', h.env)
|
|
3228
|
+
})
|
|
3229
|
+
|
|
3230
|
+
/** One accepted answer on the journal, whatever route is about to own it. */
|
|
3231
|
+
function journaled(h: Harness, sessionId: string): void {
|
|
3232
|
+
writeSessionState(sessionId, h.env, {
|
|
3233
|
+
accepted: {
|
|
3234
|
+
answers: [
|
|
3235
|
+
{
|
|
3236
|
+
pending: {
|
|
3237
|
+
question: 'Ship it?',
|
|
3238
|
+
request_id: 'req_existing',
|
|
3239
|
+
collapse_key: 'question-existing',
|
|
3240
|
+
device_ids: ['dev_iphone'],
|
|
3241
|
+
},
|
|
3242
|
+
reply: reply({ text: 'Ship it' }),
|
|
3243
|
+
replies: [reply({ text: 'Ship it' })],
|
|
3244
|
+
},
|
|
3245
|
+
],
|
|
3246
|
+
remaining: 0,
|
|
3247
|
+
recorded_at: NOW,
|
|
3248
|
+
},
|
|
3249
|
+
})
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
it('delivers an accepted answer once on a route that acknowledges its own write', async () => {
|
|
3253
|
+
const h = harness([])
|
|
3254
|
+
journaled(h, 'waiter-once')
|
|
3255
|
+
const context = waiterContext(h)
|
|
3256
|
+
const deliveries: string[] = []
|
|
3257
|
+
const route = {
|
|
3258
|
+
kind: 'inbox-socket' as const,
|
|
3259
|
+
deliver: async (event: { context: string }) => {
|
|
3260
|
+
deliveries.push(event.context)
|
|
3261
|
+
return {
|
|
3262
|
+
notes: ['posted the accepted answer'],
|
|
3263
|
+
log: { route: 'inbox-socket', stage: 'delivered' },
|
|
3264
|
+
acknowledgement: 'delivered' as const,
|
|
3265
|
+
}
|
|
3266
|
+
},
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
// Five turn-ends, none of them a harness continuation — the shape of every
|
|
3270
|
+
// route that wakes a new turn instead of continuing this one.
|
|
3271
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
3272
|
+
await runEscalationWaiter(context, {
|
|
3273
|
+
sessionId: 'waiter-once',
|
|
3274
|
+
envelope: { session_id: 'waiter-once', stop_hook_active: false },
|
|
3275
|
+
route,
|
|
3276
|
+
processDeadlineAt: NOW + 480_000,
|
|
3277
|
+
})
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
expect(deliveries).toHaveLength(1)
|
|
3281
|
+
expect(deliveries[0]).toContain('Ship it')
|
|
3282
|
+
expect(readSessionState('waiter-once', h.env).accepted).toBeUndefined()
|
|
3283
|
+
})
|
|
3284
|
+
|
|
3285
|
+
it('bounds deliveries by the continuation cap on every route', async () => {
|
|
3286
|
+
// A route that never acknowledges must still stop. The cap lives where all
|
|
3287
|
+
// routes pass, so no future route can be the one it does not cover.
|
|
3288
|
+
for (const kind of ['inbox-socket', 'hook-continuation'] as const) {
|
|
3289
|
+
const h = harness([])
|
|
3290
|
+
const sessionId = `waiter-cap-${kind}`
|
|
3291
|
+
journaled(h, sessionId)
|
|
3292
|
+
const context = waiterContext(h)
|
|
3293
|
+
let deliveries = 0
|
|
3294
|
+
const notes: string[] = []
|
|
3295
|
+
|
|
3296
|
+
for (let pass = 0; pass < MAX_CONTINUATION_COUNT + 3; pass += 1) {
|
|
3297
|
+
const outcome = await runEscalationWaiter(context, {
|
|
3298
|
+
sessionId,
|
|
3299
|
+
envelope: { session_id: sessionId, stop_hook_active: false },
|
|
3300
|
+
route: {
|
|
3301
|
+
kind,
|
|
3302
|
+
deliver: async () => {
|
|
3303
|
+
deliveries += 1
|
|
3304
|
+
return { notes: [], acknowledgement: 'held' as const }
|
|
3305
|
+
},
|
|
3306
|
+
},
|
|
3307
|
+
processDeadlineAt: NOW + 480_000,
|
|
3308
|
+
})
|
|
3309
|
+
notes.push(...outcome.notes)
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
expect(deliveries).toBe(MAX_CONTINUATION_COUNT)
|
|
3313
|
+
expect(notes.join('\n')).toContain(
|
|
3314
|
+
`${MAX_CONTINUATION_COUNT} times without being acknowledged`,
|
|
3315
|
+
)
|
|
3316
|
+
expect(readSessionState(sessionId, h.env).accepted).toBeUndefined()
|
|
3317
|
+
}
|
|
3318
|
+
})
|
|
3319
|
+
|
|
3320
|
+
it('keeps the accepted journal when an out-of-band route rejects delivery', async () => {
|
|
3321
|
+
const h = harness([reply({ text: 'Hold it' })])
|
|
3322
|
+
writeGlobalConfig(h, 'ask_grace_seconds = 0\n')
|
|
3323
|
+
writeSessionState('waiter-failed-route', h.env, { last_prompt_at: AWAY })
|
|
3324
|
+
registerQuestion('waiter-failed-route', h.env, { question: 'Ship it?' }, NOW)
|
|
3325
|
+
const context = waiterContext(h)
|
|
3326
|
+
|
|
3327
|
+
await expect(
|
|
3328
|
+
runEscalationWaiter(context, {
|
|
3329
|
+
sessionId: 'waiter-failed-route',
|
|
3330
|
+
envelope: { session_id: 'waiter-failed-route' },
|
|
3331
|
+
route: {
|
|
3332
|
+
kind: 'inbox-socket',
|
|
3333
|
+
deliver: async () => {
|
|
3334
|
+
throw new Error('socket unavailable')
|
|
3335
|
+
},
|
|
3336
|
+
},
|
|
3337
|
+
processDeadlineAt: NOW + 480_000,
|
|
3338
|
+
}),
|
|
3339
|
+
).rejects.toThrow('socket unavailable')
|
|
3340
|
+
|
|
3341
|
+
expect(readSessionState('waiter-failed-route', h.env).accepted?.answers[0]?.reply.text).toBe(
|
|
3342
|
+
'Hold it',
|
|
3343
|
+
)
|
|
3344
|
+
expect(claimQuestionPush('waiter-failed-route', h.env)).toBe(true)
|
|
3345
|
+
releaseQuestionPush('waiter-failed-route', h.env)
|
|
3346
|
+
})
|
|
3347
|
+
})
|