switchroom 0.17.10 → 0.18.6
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
writeActivityCardRecord,
|
|
36
36
|
clearActivityCardRecord,
|
|
37
37
|
runActivityCardBootReaper,
|
|
38
|
+
runActivityCardMidSessionReaper,
|
|
38
39
|
restartOrphanCardFinalizeText,
|
|
39
40
|
type ActivityCardRecord,
|
|
40
41
|
type ActivityCardStoreFsSeam,
|
|
@@ -434,3 +435,96 @@ describe('restartOrphanCardFinalizeText', () => {
|
|
|
434
435
|
expect(text).not.toContain('was running')
|
|
435
436
|
})
|
|
436
437
|
})
|
|
438
|
+
|
|
439
|
+
describe('runActivityCardMidSessionReaper (#2918 mid-session sweep)', () => {
|
|
440
|
+
const NOW = 2_000_000_000_000
|
|
441
|
+
const TTL = 15 * 60_000
|
|
442
|
+
|
|
443
|
+
it('finalizes + unpins an ownerless stale card, and deletes its record', async () => {
|
|
444
|
+
const dead = card({ turnKey: 'c:dead', activityMessageId: 715, startedAt: NOW - 30 * 60_000 })
|
|
445
|
+
const { fs, files } = memFs()
|
|
446
|
+
writeActivityCardRecord(PATH, fs, dead)
|
|
447
|
+
const finalized: number[] = []
|
|
448
|
+
const unpinned: number[] = []
|
|
449
|
+
const res = await runActivityCardMidSessionReaper({
|
|
450
|
+
path: PATH, fs,
|
|
451
|
+
isLive: () => false, // process gone, no live owner
|
|
452
|
+
ttlMs: TTL, now: NOW,
|
|
453
|
+
finalizeCard: async (r) => { finalized.push(r.activityMessageId); return { message_id: r.activityMessageId } },
|
|
454
|
+
unpinCard: async (r) => { unpinned.push(r.activityMessageId); return true },
|
|
455
|
+
})
|
|
456
|
+
expect(res).toEqual({ finalized: 1, vanished: 0, unpinned: 1, total: 1 })
|
|
457
|
+
expect(finalized).toEqual([715])
|
|
458
|
+
expect(unpinned).toEqual([715])
|
|
459
|
+
// Record deleted BEFORE the edit (at-most-once idempotency guard).
|
|
460
|
+
expect(loadActivityCards(PATH, fs)).toEqual([])
|
|
461
|
+
void files
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('NEVER touches a live card (isLive true) — spinner keeps spinning', async () => {
|
|
465
|
+
const live = card({ turnKey: 'c:live', activityMessageId: 900, startedAt: NOW - 6 * 60 * 60_000 })
|
|
466
|
+
const { fs } = memFs()
|
|
467
|
+
writeActivityCardRecord(PATH, fs, live)
|
|
468
|
+
let edits = 0
|
|
469
|
+
const res = await runActivityCardMidSessionReaper({
|
|
470
|
+
path: PATH, fs,
|
|
471
|
+
isLive: (r) => r.turnKey === 'c:live',
|
|
472
|
+
ttlMs: TTL, now: NOW,
|
|
473
|
+
finalizeCard: async () => { edits++; return {} },
|
|
474
|
+
unpinCard: async () => true,
|
|
475
|
+
})
|
|
476
|
+
expect(res.total).toBe(0)
|
|
477
|
+
expect(edits).toBe(0)
|
|
478
|
+
// Live record untouched on disk.
|
|
479
|
+
expect(loadActivityCards(PATH, fs)).toHaveLength(1)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('does not reap an ownerless card younger than the TTL (race guard)', async () => {
|
|
483
|
+
const fresh = card({ turnKey: 'c:fresh', startedAt: NOW - 60_000 })
|
|
484
|
+
const { fs } = memFs()
|
|
485
|
+
writeActivityCardRecord(PATH, fs, fresh)
|
|
486
|
+
let edits = 0
|
|
487
|
+
const res = await runActivityCardMidSessionReaper({
|
|
488
|
+
path: PATH, fs,
|
|
489
|
+
isLive: () => false,
|
|
490
|
+
ttlMs: TTL, now: NOW,
|
|
491
|
+
finalizeCard: async () => { edits++; return {} },
|
|
492
|
+
unpinCard: async () => true,
|
|
493
|
+
})
|
|
494
|
+
expect(res.total).toBe(0)
|
|
495
|
+
expect(edits).toBe(0)
|
|
496
|
+
expect(loadActivityCards(PATH, fs)).toHaveLength(1)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('reaps the dead card while sparing a concurrently-live one', async () => {
|
|
500
|
+
const { fs } = memFs()
|
|
501
|
+
writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:dead', activityMessageId: 1, startedAt: NOW - 30 * 60_000 }))
|
|
502
|
+
writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:live', activityMessageId: 2, startedAt: NOW - 30 * 60_000 }))
|
|
503
|
+
const finalized: number[] = []
|
|
504
|
+
const res = await runActivityCardMidSessionReaper({
|
|
505
|
+
path: PATH, fs,
|
|
506
|
+
isLive: (r) => r.turnKey === 'c:live',
|
|
507
|
+
ttlMs: TTL, now: NOW,
|
|
508
|
+
finalizeCard: async (r) => { finalized.push(r.activityMessageId); return {} },
|
|
509
|
+
unpinCard: async () => true,
|
|
510
|
+
})
|
|
511
|
+
expect(res.total).toBe(1)
|
|
512
|
+
expect(finalized).toEqual([1])
|
|
513
|
+
const remaining = loadActivityCards(PATH, fs)
|
|
514
|
+
expect(remaining).toHaveLength(1)
|
|
515
|
+
expect(remaining[0]!.turnKey).toBe('c:live')
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('counts a benign-400 (vanished) card as vanished, not finalized', async () => {
|
|
519
|
+
const { fs } = memFs()
|
|
520
|
+
writeActivityCardRecord(PATH, fs, card({ turnKey: 'c:gone', pinned: false, startedAt: NOW - 30 * 60_000 }))
|
|
521
|
+
const res = await runActivityCardMidSessionReaper({
|
|
522
|
+
path: PATH, fs,
|
|
523
|
+
isLive: () => false,
|
|
524
|
+
ttlMs: TTL, now: NOW,
|
|
525
|
+
finalizeCard: async () => undefined, // robustApiCall swallowed a benign 400
|
|
526
|
+
unpinCard: async () => true,
|
|
527
|
+
})
|
|
528
|
+
expect(res).toEqual({ finalized: 0, vanished: 1, unpinned: 0, total: 1 })
|
|
529
|
+
})
|
|
530
|
+
})
|
|
@@ -94,7 +94,7 @@ describe('renderShowText — Format 2 vs legacy', () => {
|
|
|
94
94
|
expect(out).toContain('🔋 **Auth — fleet status**');
|
|
95
95
|
expect(out).toContain('Recommendation:');
|
|
96
96
|
// GFM table card (#2700): State emoji per row, not group headers.
|
|
97
|
-
expect(out).toContain('| State | Account | 5h | 7d |
|
|
97
|
+
expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
|
|
98
98
|
expect(out).toContain('| 🔴 |'); // the blocked account's State cell
|
|
99
99
|
expect(out).toContain('| 🟢 |'); // a healthy account's State cell
|
|
100
100
|
expect(out).not.toContain('**BLOCKED**');
|
|
@@ -262,11 +262,13 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
262
262
|
return rows;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
it('renders a GFM table with the State/Account/5h/7d/
|
|
265
|
+
it('renders a GFM table with the State/Account/5h/5h resets/7d/7d resets header', () => {
|
|
266
266
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
267
267
|
expect(out).toContain('🔋 **Auth — fleet status**');
|
|
268
|
-
expect(out).toContain('| State | Account | 5h | 7d |
|
|
269
|
-
expect(out).toContain('| --- | --- | --- | --- | --- |');
|
|
268
|
+
expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
|
|
269
|
+
expect(out).toContain('| --- | --- | --- | --- | --- | --- |');
|
|
270
|
+
// The single collapsed Status column is gone.
|
|
271
|
+
expect(out).not.toContain('| 7d | Status |');
|
|
270
272
|
// No legacy group headers / health-section titles remain.
|
|
271
273
|
expect(out).not.toContain('**BLOCKED**');
|
|
272
274
|
expect(out).not.toContain('**HEALTHY**');
|
|
@@ -307,16 +309,22 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
307
309
|
expect(bob).toBeLessThan(alice);
|
|
308
310
|
});
|
|
309
311
|
|
|
310
|
-
it('
|
|
312
|
+
it('reset columns show a "<time> (in ...)" cell for a blocked account (7d binding window)', () => {
|
|
311
313
|
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
312
314
|
const bob = rows.find((r) => r[1].includes('bob@example.com'))!;
|
|
313
|
-
|
|
315
|
+
// bob is 7d-maxed; its 7d reset (2026-05-17T10:00Z) is ~2 days out and still
|
|
316
|
+
// renders in its own reset cell — no "back" prefix in the new per-window shape.
|
|
317
|
+
expect(bob[5]).toMatch(/^.* \(in .+\)$/);
|
|
318
|
+
expect(bob[5]).not.toContain('back');
|
|
314
319
|
});
|
|
315
320
|
|
|
316
|
-
it('
|
|
321
|
+
it('reset columns show a "<time> (in ...)" cell for each window of a healthy account', () => {
|
|
317
322
|
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
318
323
|
const you = rows.find((r) => r[1].includes('you@example.com'))!;
|
|
319
|
-
|
|
324
|
+
// 5h resets cell [3] and 7d resets cell [5] both carry a relative hint.
|
|
325
|
+
expect(you[3]).toMatch(/^.* \(in .+\)$/);
|
|
326
|
+
expect(you[5]).toMatch(/^.* \(in .+\)$/);
|
|
327
|
+
expect(you[3]).not.toContain('refills');
|
|
320
328
|
});
|
|
321
329
|
|
|
322
330
|
it('NEVER displays a percentage over 100% even on an over-cap account', () => {
|
|
@@ -330,13 +338,15 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
330
338
|
];
|
|
331
339
|
const rows = tableRows(renderAuthSnapshotFormat2(overSnaps, { now: NOW, tz: 'UTC' }));
|
|
332
340
|
expect(rows[0][2]).toBe('100%'); // 5h
|
|
333
|
-
expect(rows[0][
|
|
334
|
-
// And the blocked state still surfaces via the emoji (🔴)
|
|
341
|
+
expect(rows[0][4]).toBe('100%'); // 7d
|
|
342
|
+
// And the blocked state still surfaces via the emoji (🔴). No resets known
|
|
343
|
+
// (fixture has no reset timestamps) → both reset cells degrade to "—".
|
|
335
344
|
expect(rows[0][0]).toBe('🔴');
|
|
336
|
-
expect(rows[0][
|
|
345
|
+
expect(rows[0][3]).toBe('—'); // 5h resets
|
|
346
|
+
expect(rows[0][5]).toBe('—'); // 7d resets
|
|
337
347
|
});
|
|
338
348
|
|
|
339
|
-
it('renders dates in the
|
|
349
|
+
it('renders dates in the reset columns only when NOT today (user tz)', () => {
|
|
340
350
|
// now = Fri 3:00 PM Melbourne. A reset later the SAME Melbourne day shows
|
|
341
351
|
// time-only; a reset on the next day shows the weekday.
|
|
342
352
|
const MEL = 'Australia/Melbourne';
|
|
@@ -349,8 +359,9 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
349
359
|
}) }),
|
|
350
360
|
];
|
|
351
361
|
const todayRows = tableRows(renderAuthSnapshotFormat2(sameDay, { now: NOW_MEL, tz: MEL }));
|
|
352
|
-
|
|
353
|
-
expect(todayRows[0][
|
|
362
|
+
// 5h resets cell [3] — same Melbourne day → time only.
|
|
363
|
+
expect(todayRows[0][3]).toContain('9:00 PM');
|
|
364
|
+
expect(todayRows[0][3]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
|
|
354
365
|
|
|
355
366
|
const nextDay = [
|
|
356
367
|
snap({ label: 'tomorrow@example.com', isActive: true, quota: quota({
|
|
@@ -360,7 +371,8 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
360
371
|
}) }),
|
|
361
372
|
];
|
|
362
373
|
const tomorrowRows = tableRows(renderAuthSnapshotFormat2(nextDay, { now: NOW_MEL, tz: MEL }));
|
|
363
|
-
|
|
374
|
+
// 5h resets cell [3] — next Melbourne day → weekday prefix.
|
|
375
|
+
expect(tomorrowRows[0][3]).toContain('Sat 1:00 AM');
|
|
364
376
|
});
|
|
365
377
|
|
|
366
378
|
it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
|
|
@@ -419,6 +431,27 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
419
431
|
expect(out).not.toContain('Live · refreshed');
|
|
420
432
|
});
|
|
421
433
|
|
|
434
|
+
it('#2959 honesty fix — probeFailed renders an explicit marker instead of a false bare "Live" footer', () => {
|
|
435
|
+
// Total probe failure: no rows, no cache. The footer must not claim
|
|
436
|
+
// "Live" next to no-data rows. Pre-fix this rendered a bare `_Live_`.
|
|
437
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
|
|
438
|
+
now: NOW,
|
|
439
|
+
probeFailed: true,
|
|
440
|
+
});
|
|
441
|
+
expect(out).toContain('_⚠ probe failed — no live data_');
|
|
442
|
+
expect(out).not.toContain('_Live_');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('#2959 honesty fix — staleCachedAtMs (real cached data) takes precedence over probeFailed', () => {
|
|
446
|
+
const out = renderAuthSnapshotFormat2(fixtureSnaps.slice(0, 1), {
|
|
447
|
+
now: NOW,
|
|
448
|
+
staleCachedAtMs: NOW.getTime() - 2 * 60_000,
|
|
449
|
+
probeFailed: true,
|
|
450
|
+
});
|
|
451
|
+
expect(out).toContain('⚠ cached 2m ago');
|
|
452
|
+
expect(out).not.toContain('probe failed');
|
|
453
|
+
});
|
|
454
|
+
|
|
422
455
|
// ── demo mode (the `/usage demo` / `/auth demo` suffix) ──────────────
|
|
423
456
|
describe('demo mode masks email labels', () => {
|
|
424
457
|
it('WITHOUT demo, the real account emails still render', () => {
|
|
@@ -1120,14 +1153,16 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
|
|
|
1120
1153
|
expect(allText).toContain('Switch fleet → carol@example.com');
|
|
1121
1154
|
});
|
|
1122
1155
|
|
|
1123
|
-
it('quota-exhausted row shows a 🔴 +
|
|
1156
|
+
it('quota-exhausted row shows a 🔴 + its 5h reset time in the reset cell, not "billing disabled"', () => {
|
|
1124
1157
|
const futureReset = new Date(NOW.getTime() + 45 * 60_000);
|
|
1125
1158
|
const out = renderAuthSnapshotFormat2(
|
|
1126
1159
|
[snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
|
|
1127
1160
|
{ now: NOW },
|
|
1128
1161
|
);
|
|
1129
1162
|
expect(out).toContain('| 🔴 |');
|
|
1130
|
-
|
|
1163
|
+
// The 5h reset (45m out) renders in its own reset cell — no "back" prefix.
|
|
1164
|
+
expect(out).toMatch(/\(in 45m\)/);
|
|
1165
|
+
expect(out).not.toContain('back ');
|
|
1131
1166
|
expect(out).not.toContain('billing disabled');
|
|
1132
1167
|
expect(out).not.toContain('overage off');
|
|
1133
1168
|
});
|
|
@@ -137,6 +137,54 @@ describe('Claude Code event-stream contract (canary)', () => {
|
|
|
137
137
|
).toBe(false)
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
+
it('CANARY (Fix 3 precondition): Task tool_use still projects {id, name, input} — the shape the foreground-sub-agent-tracking / post-answer-liveness-freeze fix keys "still dispatched" off of', () => {
|
|
141
|
+
// The Fix 3 gap-closer (turn-liveness-floor.ts `stillDispatched`) is only
|
|
142
|
+
// as good as `turn.foregroundSubAgents` staying populated for the
|
|
143
|
+
// lifetime of a real Task dispatch. That population is driven by THIS
|
|
144
|
+
// exact tool_use projection (toolName === 'Task') plus the matching
|
|
145
|
+
// tool_result closing it out — if a future Claude Code release renames
|
|
146
|
+
// `name`/`input`/`id` or nests them differently, the fix silently goes
|
|
147
|
+
// inert (stillDispatched always false, freeze regresses) rather than
|
|
148
|
+
// failing loudly. Pin the shape here so drift fails CI instead.
|
|
149
|
+
const line = JSON.stringify({
|
|
150
|
+
type: 'assistant',
|
|
151
|
+
message: {
|
|
152
|
+
id: 'msg_task',
|
|
153
|
+
stop_reason: 'tool_use',
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: 'tool_use',
|
|
157
|
+
id: 'toolu_task_1',
|
|
158
|
+
name: 'Task',
|
|
159
|
+
input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
const evs = projectTranscriptLine(line)
|
|
165
|
+
expect(evs).toEqual([
|
|
166
|
+
{
|
|
167
|
+
kind: 'tool_use',
|
|
168
|
+
toolName: 'Task',
|
|
169
|
+
toolUseId: 'toolu_task_1',
|
|
170
|
+
input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
|
|
171
|
+
},
|
|
172
|
+
])
|
|
173
|
+
// The matching tool_result (Task returning) must still carry tool_use_id
|
|
174
|
+
// so the gateway can close out the foreground-sub-agent tracking entry.
|
|
175
|
+
const resultLine = JSON.stringify({
|
|
176
|
+
type: 'user',
|
|
177
|
+
message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_task_1', is_error: false, content: 'done' }] },
|
|
178
|
+
})
|
|
179
|
+
const resultEvs = projectTranscriptLine(resultLine)
|
|
180
|
+
expect(resultEvs).toHaveLength(1)
|
|
181
|
+
// `is_error: false` projects to `isError: undefined` (only `true` is
|
|
182
|
+
// ever stamped — see session-tail.ts's `c.is_error === true ? true :
|
|
183
|
+
// undefined`), so assert the falsy/absent form, not a literal `false`.
|
|
184
|
+
expect(resultEvs[0]).toMatchObject({ kind: 'tool_result', toolUseId: 'toolu_task_1' })
|
|
185
|
+
expect((resultEvs[0] as { isError?: boolean }).isError).toBeFalsy()
|
|
186
|
+
})
|
|
187
|
+
|
|
140
188
|
it('sub-agent kickoff: first user message string prompt fires sub_agent_started', () => {
|
|
141
189
|
const st = { hasEmittedStart: false }
|
|
142
190
|
const line = JSON.stringify({
|
|
@@ -153,6 +153,17 @@ describe('H-2: feedHeartbeatTick post-answer background-agent liveness (Fix 2 /
|
|
|
153
153
|
expect(postAnswerBlock).toMatch(/livenessVerdict\s*!==\s*'emit'/)
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
+
it('Fix 3: post-answer branch feeds stillDispatched from turn.foregroundSubAgents so a tracked foreground sub-agent bypasses the staleness cap', () => {
|
|
157
|
+
const body = feedHeartbeatTickSrc()
|
|
158
|
+
const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
|
|
159
|
+
const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
|
|
160
|
+
// A positive foreground-tracking signal must be computed and threaded
|
|
161
|
+
// into the pure decision — this is what stops the card freezing mid-
|
|
162
|
+
// delegation while a foreground Task/Agent is still outstanding.
|
|
163
|
+
expect(postAnswerBlock).toMatch(/stillDispatched\s*=\s*turn\.foregroundSubAgents\.size\s*>\s*0/)
|
|
164
|
+
expect(postAnswerBlock).toMatch(/stillDispatched,/)
|
|
165
|
+
})
|
|
166
|
+
|
|
156
167
|
it('staleness cap (concern 3) is parsed default-ON (30s) from SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS', () => {
|
|
157
168
|
// The cap const must default to 30_000 when the env is unset (the `|| 30_000`
|
|
158
169
|
// fallback over the positive-or-0 parse) so the post-answer card stops
|
|
@@ -47,6 +47,8 @@ import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
|
|
|
47
47
|
import {
|
|
48
48
|
ORPHANED_REPLY_TIMEOUT_MS,
|
|
49
49
|
ORPHANED_REPLY_MAX_REARMS,
|
|
50
|
+
ORPHANED_REPLY_STREAM_WINDOW_MS,
|
|
51
|
+
LivenessTracker,
|
|
50
52
|
} from '../context-exhaustion.js'
|
|
51
53
|
|
|
52
54
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -524,3 +526,40 @@ describe('silence-poke — hard ceiling bounds the defer', () => {
|
|
|
524
526
|
expect(__getStateForTests('chat:0')!.fallbackFired).toBe(true)
|
|
525
527
|
})
|
|
526
528
|
})
|
|
529
|
+
|
|
530
|
+
// ─── Thinking-pause liveness via the REAL LivenessTracker (orphaned-reply fix) ─
|
|
531
|
+
|
|
532
|
+
describe('silence-poke — thinking-pause liveness via the real LivenessTracker', () => {
|
|
533
|
+
// Drives the REAL LivenessTracker as the isLegitimatelyWorking source: a
|
|
534
|
+
// genuine stream event within ORPHANED_REPLY_STREAM_WINDOW_MS keeps the feed
|
|
535
|
+
// alive (a model reasoning pause is survivable); a gap longer than the window
|
|
536
|
+
// with no work tears the feed down.
|
|
537
|
+
it('a stream event within the window keeps the feed alive (thinking-pause survivable)', () => {
|
|
538
|
+
let clockNow = 0
|
|
539
|
+
const tracker = new LivenessTracker(0)
|
|
540
|
+
const f = setupSilenceDeps({
|
|
541
|
+
thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
|
|
542
|
+
isLegitimatelyWorking: () => tracker.recentlyStreaming(clockNow, ORPHANED_REPLY_STREAM_WINDOW_MS),
|
|
543
|
+
})
|
|
544
|
+
startTurn('chat:0', 0)
|
|
545
|
+
// A genuine stream event lands mid-turn (e.g. the model resumes after a
|
|
546
|
+
// reasoning pause) — only 50s before the 300s fallback tick.
|
|
547
|
+
tracker.onStreamEvent('text', undefined, 250_000)
|
|
548
|
+
clockNow = 300_000
|
|
549
|
+
__tickForTests(300_000) // 50s since last stream < 120s window → deferred
|
|
550
|
+
expect(f.fallbacks).toHaveLength(0)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('no stream event for longer than the window tears the feed down', () => {
|
|
554
|
+
let clockNow = 0
|
|
555
|
+
const tracker = new LivenessTracker(0) // seed at t=0, nothing after
|
|
556
|
+
const f = setupSilenceDeps({
|
|
557
|
+
thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
|
|
558
|
+
isLegitimatelyWorking: () => tracker.recentlyStreaming(clockNow, ORPHANED_REPLY_STREAM_WINDOW_MS),
|
|
559
|
+
})
|
|
560
|
+
startTurn('chat:0', 0)
|
|
561
|
+
clockNow = 300_000
|
|
562
|
+
__tickForTests(300_000) // 300s since last stream >> 120s window → fires
|
|
563
|
+
expect(f.fallbacks).toHaveLength(1)
|
|
564
|
+
})
|
|
565
|
+
})
|
|
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
43
43
|
// window to ~5KB so we don't pick up the unrelated
|
|
44
44
|
// `removeTurnActiveMarker` call in the onTurnComplete handler
|
|
45
45
|
// way down at the bottom of the file.
|
|
46
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
46
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
|
|
47
47
|
const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
|
|
48
48
|
expect(clearMatches.length).toBe(1)
|
|
49
49
|
})
|
|
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
53
53
|
// must not prevent the gateway from coming up. Pin the try-wrapping
|
|
54
54
|
// to make sure a future refactor doesn't drop it.
|
|
55
55
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
56
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
56
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
|
|
57
57
|
expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
62
62
|
// Clearing the marker first means the watchdog sees a clean slate
|
|
63
63
|
// immediately, even if pin sweep takes 30s+ to finish.
|
|
64
64
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
65
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
65
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
|
|
66
66
|
const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
|
|
67
67
|
const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
|
|
68
68
|
expect(clearIdx).toBeGreaterThan(0)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural pins for the session-only model relaunch wiring in gateway.ts.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour lives in un-exported inline closures (buildModelDeps's
|
|
5
|
+
* scheduleModelRelaunch, the model-menu callback sr-* branch, and the boot
|
|
6
|
+
* re-hydration / alert-sentinel block inside the startup IIFE), so — mirroring
|
|
7
|
+
* the other gateway-*.test.ts source-pins — we assert on the source structure.
|
|
8
|
+
* The end-to-end behaviour of the carrier itself is exercised in
|
|
9
|
+
* tests/scaffold.session-model-override.test.ts (rendered start.sh) and the
|
|
10
|
+
* handler contract in telegram-plugin/tests/model-command.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { dirname, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
20
|
+
|
|
21
|
+
describe('gateway: scheduleModelRelaunch dep', () => {
|
|
22
|
+
it('writes the carrier with exact "<model>\\n" bytes', () => {
|
|
23
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
24
|
+
expect(idx).toBeGreaterThan(0)
|
|
25
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
26
|
+
expect(win).toMatch(/writeFileSync\(\s*join\(agentDir, '\.session-model-override'\),\s*`\$\{model\}\\n`/)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('sets the in-memory activeSessionModelOverride before dispatching the restart', () => {
|
|
30
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
31
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
32
|
+
const setIdx = win.indexOf('activeSessionModelOverride = model')
|
|
33
|
+
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
34
|
+
expect(setIdx).toBeGreaterThan(0)
|
|
35
|
+
expect(restartIdx).toBeGreaterThan(setIdx)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
|
|
39
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
40
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
41
|
+
expect(win).toContain('await deps.scheduleRestart(reason)')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('gateway: model-menu sr-* target relaunches via the carrier', () => {
|
|
46
|
+
it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
|
|
47
|
+
const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
|
|
48
|
+
expect(idx).toBeGreaterThan(0)
|
|
49
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1400)
|
|
50
|
+
expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
|
|
51
|
+
// It must return before falling through to handleModelMenuCallback (which
|
|
52
|
+
// would inject an sr-* id claude's picker rejects).
|
|
53
|
+
expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () => {
|
|
58
|
+
it('re-hydrates activeSessionModelOverride from .active-session-model', () => {
|
|
59
|
+
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
|
|
60
|
+
expect(idx).toBeGreaterThan(0)
|
|
61
|
+
const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
|
|
62
|
+
// Only an override when the launched model differs from the configured one.
|
|
63
|
+
expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
|
|
64
|
+
// The configured value must be resolved through resolveMainModel (the SAME
|
|
65
|
+
// resolver start.sh's scaffold uses) so an unset/`default` model config does
|
|
66
|
+
// not get flagged as a phantom session override on an ordinary restart.
|
|
67
|
+
expect(win).toContain('resolveMainModel(raw ?? undefined)')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
|
|
71
|
+
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
|
|
72
|
+
expect(idx).toBeGreaterThan(0)
|
|
73
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1100)
|
|
74
|
+
expect(win).toContain('unlinkSync(alertPath)')
|
|
75
|
+
// Broadcasts to every operator in allowFrom, not just allowFrom[0].
|
|
76
|
+
expect(win).toContain('const operators = loadAccess().allowFrom')
|
|
77
|
+
expect(win).toContain('for (const operator of operators)')
|
|
78
|
+
expect(win).toContain('lockedBot.api')
|
|
79
|
+
expect(win).toContain('.sendMessage(operator')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural pin: the delivery state machine's `inbound` event MUST be
|
|
3
|
+
* emitted from `handleInbound` only AFTER the intercept/early-return
|
|
4
|
+
* gauntlet — never eagerly at handler entry.
|
|
5
|
+
*
|
|
6
|
+
* The wedge this guards (overlord `/usage` went dead, 2026-07-08). The
|
|
7
|
+
* inbound emit drives the now-AUTHORITATIVE turn-in-flight gate
|
|
8
|
+
* (turnInFlightForGate → isMachineInTurn). When it fired at handler entry
|
|
9
|
+
* — before the permission-reply / `/auth` paste-back / interrupt-empty /
|
|
10
|
+
* secret-detect-drop intercepts — an intercepted message (e.g. an approval
|
|
11
|
+
* reply) drove the machine idle→`bridge_alive_in_turn`, then early-returned
|
|
12
|
+
* as an intercept: no delivery, no claudeBusyKeys mark, and critically no
|
|
13
|
+
* `turnEnd`. The machine held the gate closed until the 5-min TTL tick
|
|
14
|
+
* force-cleared it, buffering every inbound (including `/usage`) meanwhile —
|
|
15
|
+
* the dangerous `machine_over_holds` divergence gate-parity-probe.ts flags.
|
|
16
|
+
*
|
|
17
|
+
* The behaviour lives inside the un-exported `handleInbound` closure, so —
|
|
18
|
+
* mirroring the other gateway-*.test.ts source-pins — we assert on source
|
|
19
|
+
* structure: the single inbound `shadowEmit` must sit below the intercepts
|
|
20
|
+
* and carry the real `isSteering` classification. Machine self-heal + gate
|
|
21
|
+
* accessors are behaviour-tested in inbound-delivery-cutover-gate.test.ts.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest'
|
|
25
|
+
import { readFileSync } from 'node:fs'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { dirname, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
30
|
+
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
31
|
+
|
|
32
|
+
/** Byte offset of the sole `shadowEmit({ kind: 'inbound' ... })` call. */
|
|
33
|
+
function inboundEmitOffset(): number {
|
|
34
|
+
const m = GATEWAY_SRC.match(/shadowEmit\(\{\s*\n\s*kind:\s*'inbound'/)
|
|
35
|
+
expect(m, 'expected exactly one multi-line inbound shadowEmit').not.toBeNull()
|
|
36
|
+
const idx = GATEWAY_SRC.indexOf(m![0])
|
|
37
|
+
// There must be only ONE such emit — a second eager site would reintroduce
|
|
38
|
+
// the wedge.
|
|
39
|
+
const second = GATEWAY_SRC.indexOf(m![0], idx + 1)
|
|
40
|
+
expect(second, 'a second inbound shadowEmit would reintroduce the eager-emit wedge').toBe(-1)
|
|
41
|
+
return idx
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('gateway: inbound machine-emit is deferred past the intercepts', () => {
|
|
45
|
+
it('carries the DEFERRED_INBOUND_EMIT marker so the intent is greppable', () => {
|
|
46
|
+
expect(GATEWAY_SRC).toContain('DEFERRED_INBOUND_EMIT')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('emits the machine `inbound` event AFTER the permission-reply intercept', () => {
|
|
50
|
+
const emitIdx = inboundEmitOffset()
|
|
51
|
+
// The permission-reply intercept is one of the early-return paths that
|
|
52
|
+
// must NOT drive the machine into a turn.
|
|
53
|
+
const permIdx = GATEWAY_SRC.indexOf('const permMatch = PERMISSION_REPLY_RE.exec(text)')
|
|
54
|
+
expect(permIdx).toBeGreaterThan(0)
|
|
55
|
+
expect(emitIdx).toBeGreaterThan(permIdx)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('emits AFTER the `/auth add` paste-back intercept', () => {
|
|
59
|
+
const emitIdx = inboundEmitOffset()
|
|
60
|
+
const authIdx = GATEWAY_SRC.indexOf('pendingAuthAddFlows.get(interceptKey)')
|
|
61
|
+
expect(authIdx).toBeGreaterThan(0)
|
|
62
|
+
expect(emitIdx).toBeGreaterThan(authIdx)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('emits AFTER isSteering is classified, and passes the real value (not a hardcoded false)', () => {
|
|
66
|
+
const emitIdx = inboundEmitOffset()
|
|
67
|
+
const steerIdx = GATEWAY_SRC.indexOf('isSteering = priorTurnInFlight && isSteerPrefix')
|
|
68
|
+
expect(steerIdx).toBeGreaterThan(0)
|
|
69
|
+
expect(emitIdx).toBeGreaterThan(steerIdx)
|
|
70
|
+
// The emit's msg object must forward the live `isSteering` binding, so a
|
|
71
|
+
// mid-turn steer is not mis-modelled as a fresh turn.
|
|
72
|
+
const win = GATEWAY_SRC.slice(emitIdx, emitIdx + 260)
|
|
73
|
+
expect(win).toMatch(/msg:\s*\{[\s\S]*?\bisSteering,[\s\S]*?\}/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('emits BEFORE the deliver-or-buffer decision (reserveInboundDelivery)', () => {
|
|
77
|
+
const emitIdx = inboundEmitOffset()
|
|
78
|
+
const gateIdx = GATEWAY_SRC.indexOf('const deliveryGate = reserveInboundDelivery({')
|
|
79
|
+
expect(gateIdx).toBeGreaterThan(0)
|
|
80
|
+
expect(emitIdx).toBeLessThan(gateIdx)
|
|
81
|
+
})
|
|
82
|
+
})
|