switchroom 0.17.6 → 0.18.3
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 +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- 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/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -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/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- 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/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -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/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -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/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- 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/directive_verify.py +445 -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/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -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/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -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
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the persistence of the 30-min scoped-approval windows across
|
|
3
|
+
* gateway restarts (#2863). The store (scopedGrants) is in-memory and used
|
|
4
|
+
* to evaporate on any gateway bounce, re-carding an action the operator
|
|
5
|
+
* allowed minutes ago. These pin:
|
|
6
|
+
*
|
|
7
|
+
* - round-trip: record → persist → reload → lookup still hits in-window;
|
|
8
|
+
* - entries already past their ABSOLUTE expiry are dropped at reload;
|
|
9
|
+
* - a restart NEVER extends a window (grant at T, reload at T+29min →
|
|
10
|
+
* ~1 min left, not a fresh 30);
|
|
11
|
+
* - fail-closed survives a reload (a destructive Bash command still
|
|
12
|
+
* re-cards even when a reloaded family grant matches; an expired grant
|
|
13
|
+
* never matches);
|
|
14
|
+
* - kill switch (SWITCHROOM_SCOPED_GRANT_PERSIST=0): no writes, boot
|
|
15
|
+
* ignores any pre-existing file.
|
|
16
|
+
*
|
|
17
|
+
* The persistence I/O (createScopedGrantStore) is thin; the invariants live
|
|
18
|
+
* in the pure serialize/deserialize + lookup, so we drive both together with
|
|
19
|
+
* an isolated tmpdir (never ~/.switchroom or a shared STATE_DIR).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
23
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { tmpdir } from 'node:os'
|
|
26
|
+
import {
|
|
27
|
+
SCOPED_APPROVAL_DEFAULT_TTL_MS,
|
|
28
|
+
recordScopedGrant,
|
|
29
|
+
lookupScopedGrant,
|
|
30
|
+
sweepScopedGrants,
|
|
31
|
+
serializeScopedGrants,
|
|
32
|
+
deserializeScopedGrants,
|
|
33
|
+
countScopedGrants,
|
|
34
|
+
type ScopedGrantStore,
|
|
35
|
+
} from '../scoped-approval.js'
|
|
36
|
+
import { createScopedGrantStore, scopedGrantPersistEnabled } from '../gateway/scoped-grant-store.js'
|
|
37
|
+
|
|
38
|
+
const T0 = 1_000_000
|
|
39
|
+
const TTL = SCOPED_APPROVAL_DEFAULT_TTL_MS
|
|
40
|
+
const AGENT = 'marko'
|
|
41
|
+
|
|
42
|
+
const editInput = (path: string) => JSON.stringify({ file_path: path })
|
|
43
|
+
const bashInput = (command: string) => JSON.stringify({ command })
|
|
44
|
+
|
|
45
|
+
function makeTmpDir() {
|
|
46
|
+
return mkdtempSync(join(tmpdir(), 'scoped-grant-persist-test-'))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function filePath(dir: string) {
|
|
50
|
+
return join(dir, 'scoped-grants.json')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('scoped-grant persistence', () => {
|
|
54
|
+
let dir: string
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
dir = makeTmpDir()
|
|
58
|
+
})
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('round-trips: record → persist → reload → lookup hits within the window', () => {
|
|
64
|
+
const store = createScopedGrantStore(dir, {})
|
|
65
|
+
expect(store.enabled).toBe(true)
|
|
66
|
+
|
|
67
|
+
// Operator taps Allow on a narrow file edit at T0.
|
|
68
|
+
const live: ScopedGrantStore = new Map()
|
|
69
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
|
|
70
|
+
store.save(live)
|
|
71
|
+
|
|
72
|
+
expect(existsSync(filePath(dir))).toBe(true)
|
|
73
|
+
|
|
74
|
+
// Gateway restarts 5 minutes later; reload the store.
|
|
75
|
+
const reloadAt = T0 + 5 * 60_000
|
|
76
|
+
const reloaded = store.load(reloadAt)
|
|
77
|
+
|
|
78
|
+
// The window is still live → an identical action auto-allows (no re-card).
|
|
79
|
+
const hit = lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), reloadAt)
|
|
80
|
+
expect(hit).toBe('Edit(/state/x.ts)')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('drops entries already past their absolute expiry at reload', () => {
|
|
84
|
+
const store = createScopedGrantStore(dir, {})
|
|
85
|
+
const live: ScopedGrantStore = new Map()
|
|
86
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
|
|
87
|
+
store.save(live)
|
|
88
|
+
|
|
89
|
+
// Reload well after the 30-min window has elapsed.
|
|
90
|
+
const reloadAt = T0 + TTL + 60_000
|
|
91
|
+
const reloaded = store.load(reloadAt)
|
|
92
|
+
|
|
93
|
+
expect(countScopedGrants(reloaded)).toBe(0)
|
|
94
|
+
const hit = lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), reloadAt)
|
|
95
|
+
expect(hit).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('a restart does NOT extend the window (absolute expiry preserved)', () => {
|
|
99
|
+
const store = createScopedGrantStore(dir, {})
|
|
100
|
+
const live: ScopedGrantStore = new Map()
|
|
101
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL) // expiresAt = T0 + 30min
|
|
102
|
+
store.save(live)
|
|
103
|
+
|
|
104
|
+
// Reload at T0 + 29 min: only ~1 min should remain, not a fresh 30.
|
|
105
|
+
const reloadAt = T0 + 29 * 60_000
|
|
106
|
+
const reloaded = store.load(reloadAt)
|
|
107
|
+
|
|
108
|
+
// Still live one minute before expiry...
|
|
109
|
+
expect(lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), reloadAt)).toBe(
|
|
110
|
+
'Edit(/state/x.ts)',
|
|
111
|
+
)
|
|
112
|
+
// ...but dead ~90s later — the original absolute expiry is honoured, the
|
|
113
|
+
// reload gave it no new lease.
|
|
114
|
+
const afterOriginalExpiry = T0 + TTL + 1_000
|
|
115
|
+
expect(
|
|
116
|
+
lookupScopedGrant(reloaded, AGENT, 'Edit', editInput('/state/x.ts'), afterOriginalExpiry),
|
|
117
|
+
).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('fail-closed survives reload: destructive Bash never auto-allows', () => {
|
|
121
|
+
const store = createScopedGrantStore(dir, {})
|
|
122
|
+
const live: ScopedGrantStore = new Map()
|
|
123
|
+
// A benign `git status` earned a Bash(git:*) family window.
|
|
124
|
+
recordScopedGrant(live, AGENT, 'Bash(git:*)', T0, TTL)
|
|
125
|
+
store.save(live)
|
|
126
|
+
|
|
127
|
+
const reloadAt = T0 + 60_000
|
|
128
|
+
const reloaded = store.load(reloadAt)
|
|
129
|
+
|
|
130
|
+
// Benign member still auto-allows.
|
|
131
|
+
expect(lookupScopedGrant(reloaded, AGENT, 'Bash', bashInput('git status'), reloadAt)).toBe(
|
|
132
|
+
'Bash(git:*)',
|
|
133
|
+
)
|
|
134
|
+
// Destructive member of the SAME family fails closed after reload.
|
|
135
|
+
expect(
|
|
136
|
+
lookupScopedGrant(reloaded, AGENT, 'Bash', bashInput('git push --force'), reloadAt),
|
|
137
|
+
).toBeNull()
|
|
138
|
+
expect(
|
|
139
|
+
lookupScopedGrant(reloaded, AGENT, 'Bash', bashInput('git reset --hard'), reloadAt),
|
|
140
|
+
).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('sweep-expiry removal is written through and a reload cannot resurrect it', () => {
|
|
144
|
+
const store = createScopedGrantStore(dir, {})
|
|
145
|
+
const live: ScopedGrantStore = new Map()
|
|
146
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
|
|
147
|
+
store.save(live)
|
|
148
|
+
|
|
149
|
+
// Periodic sweep runs past expiry and removes the entry; write it through.
|
|
150
|
+
const sweepAt = T0 + TTL + 1_000
|
|
151
|
+
sweepScopedGrants(live, sweepAt)
|
|
152
|
+
store.save(live)
|
|
153
|
+
|
|
154
|
+
// A restart immediately after must see an empty store.
|
|
155
|
+
const reloaded = store.load(sweepAt + 1)
|
|
156
|
+
expect(countScopedGrants(reloaded)).toBe(0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('kill switch: no writes, and boot ignores a pre-existing file', () => {
|
|
160
|
+
// Seed a live grant on disk as if a prior (persist-enabled) boot wrote it.
|
|
161
|
+
const seeded = createScopedGrantStore(dir, {})
|
|
162
|
+
const live: ScopedGrantStore = new Map()
|
|
163
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
|
|
164
|
+
seeded.save(live)
|
|
165
|
+
const onDiskBefore = readFileSync(filePath(dir), 'utf-8')
|
|
166
|
+
|
|
167
|
+
// Now boot with the kill switch on.
|
|
168
|
+
const killed = createScopedGrantStore(dir, { SWITCHROOM_SCOPED_GRANT_PERSIST: '0' })
|
|
169
|
+
expect(killed.enabled).toBe(false)
|
|
170
|
+
expect(scopedGrantPersistEnabled({ SWITCHROOM_SCOPED_GRANT_PERSIST: '0' })).toBe(false)
|
|
171
|
+
|
|
172
|
+
// Boot ignores the pre-existing file → empty store.
|
|
173
|
+
const reloaded = killed.load(T0 + 60_000)
|
|
174
|
+
expect(countScopedGrants(reloaded)).toBe(0)
|
|
175
|
+
|
|
176
|
+
// save() is a no-op — the file is not touched.
|
|
177
|
+
const grantsToPersist: ScopedGrantStore = new Map()
|
|
178
|
+
recordScopedGrant(grantsToPersist, AGENT, 'Edit(/state/y.ts)', T0, TTL)
|
|
179
|
+
killed.save(grantsToPersist)
|
|
180
|
+
expect(readFileSync(filePath(dir), 'utf-8')).toBe(onDiskBefore)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('pure serialize/deserialize drops malformed and expired rows (fail-closed)', () => {
|
|
184
|
+
const now = T0
|
|
185
|
+
const raw = [
|
|
186
|
+
{ agent: AGENT, rule: 'Edit(/state/live.ts)', expiresAt: now + TTL }, // keep
|
|
187
|
+
{ agent: AGENT, rule: 'Edit(/state/gone.ts)', expiresAt: now - 1 }, // expired → drop
|
|
188
|
+
{ agent: AGENT, rule: 'Edit(/state/x.ts)' }, // no expiresAt → drop
|
|
189
|
+
{ rule: 'Edit(/state/x.ts)', expiresAt: now + TTL }, // no agent → drop
|
|
190
|
+
{ agent: AGENT, rule: '', expiresAt: now + TTL }, // empty rule → drop
|
|
191
|
+
'garbage', // not an object → drop
|
|
192
|
+
null, // → drop
|
|
193
|
+
]
|
|
194
|
+
const store = deserializeScopedGrants(raw, now)
|
|
195
|
+
expect(countScopedGrants(store)).toBe(1)
|
|
196
|
+
expect(lookupScopedGrant(store, AGENT, 'Edit', editInput('/state/live.ts'), now)).toBe(
|
|
197
|
+
'Edit(/state/live.ts)',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('serialize captures absolute expiry, not a relative TTL', () => {
|
|
202
|
+
const live: ScopedGrantStore = new Map()
|
|
203
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
|
|
204
|
+
const flat = serializeScopedGrants(live)
|
|
205
|
+
expect(flat).toEqual([{ agent: AGENT, rule: 'Edit(/state/x.ts)', expiresAt: T0 + TTL }])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('corrupt on-disk JSON reloads as an empty store (fail-closed, no throw)', () => {
|
|
209
|
+
writeFileSync(filePath(dir), '{ this is not json', 'utf-8')
|
|
210
|
+
const store = createScopedGrantStore(dir, {})
|
|
211
|
+
const reloaded = store.load(T0)
|
|
212
|
+
expect(countScopedGrants(reloaded)).toBe(0)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('persisted file is written with mode 0o600', () => {
|
|
216
|
+
const store = createScopedGrantStore(dir, {})
|
|
217
|
+
const live: ScopedGrantStore = new Map()
|
|
218
|
+
recordScopedGrant(live, AGENT, 'Edit(/state/x.ts)', T0, TTL)
|
|
219
|
+
store.save(live)
|
|
220
|
+
const mode = statSync(filePath(dir)).mode & 0o777
|
|
221
|
+
expect(mode).toBe(0o600)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* silent-end-transport.test.ts — PR 2 of
|
|
3
|
+
* reference/rfcs/deterministic-turn-liveness.md (Phase 2: audit-and-harden
|
|
4
|
+
* the shipped dark-turn guard, per reference/jobs/know-what-my-agent-is-doing.md).
|
|
5
|
+
*
|
|
6
|
+
* Prior coverage (`silent-end.test.ts`, `silent-end-integration.test.ts`)
|
|
7
|
+
* proves the *decision* — state-file lifecycle, retryCount bookkeeping,
|
|
8
|
+
* the `{exhausted}` contract. It never asserts a byte reaches the
|
|
9
|
+
* transport, which is exactly the decision-vs-delivery gap the RFC calls
|
|
10
|
+
* out for the muted 45s floor (#2667) and asks Phase 2 to wall off for
|
|
11
|
+
* the dark-turn guard instead of re-creating it.
|
|
12
|
+
*
|
|
13
|
+
* This file reproduces the EXACT call sequence at the gateway's turn_end
|
|
14
|
+
* handler (gateway.ts ~14486-14535: `if (turn.finalAnswerDelivered ===
|
|
15
|
+
* false) { const silentEnd = recordUndeliveredTurnEnd(...); if
|
|
16
|
+
* (silentEnd.exhausted) { <send> } }`) against a spy transport standing in
|
|
17
|
+
* for `bot.api.sendMessage`, using the REAL exported
|
|
18
|
+
* `recordUndeliveredTurnEnd` / `silentEndFallbackText` /
|
|
19
|
+
* `isFinalAnswerReply` — same pattern `silent-end-integration.test.ts`
|
|
20
|
+
* already established for testing gateway call-sites that aren't
|
|
21
|
+
* themselves exported (gateway.ts is a multi-thousand-line module with
|
|
22
|
+
* process-level side effects on import; see that file's "KNOWN GAP" note).
|
|
23
|
+
* The spy asserts the SEND CALL is made (outcome, not a stubbed decision
|
|
24
|
+
* callback); it does not drive grammY itself — that last hop stays covered
|
|
25
|
+
* by the UAT harness.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
28
|
+
import { mkdtempSync, rmSync, writeFileSync as writeFileSyncState } from 'node:fs'
|
|
29
|
+
import { tmpdir } from 'node:os'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import {
|
|
32
|
+
writeSilentEndState,
|
|
33
|
+
readSilentEndState,
|
|
34
|
+
recordUndeliveredTurnEnd,
|
|
35
|
+
silentEndFallbackText,
|
|
36
|
+
SILENT_END_MAX_RETRIES,
|
|
37
|
+
type SilentEndDeps,
|
|
38
|
+
} from '../silent-end.js'
|
|
39
|
+
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
40
|
+
|
|
41
|
+
let stateDir: string
|
|
42
|
+
const ORIG_ENV = process.env.TELEGRAM_STATE_DIR
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
stateDir = mkdtempSync(join(tmpdir(), 'silent-end-transport-test-'))
|
|
46
|
+
process.env.TELEGRAM_STATE_DIR = stateDir
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
51
|
+
if (ORIG_ENV != null) process.env.TELEGRAM_STATE_DIR = ORIG_ENV
|
|
52
|
+
else delete process.env.TELEGRAM_STATE_DIR
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/** Spy transport — records every "send" with a wall-clock timestamp, the
|
|
56
|
+
* same shape a `bot.api.sendMessage` spy would capture at the real
|
|
57
|
+
* transport boundary. */
|
|
58
|
+
interface SentMessage {
|
|
59
|
+
chatId: string
|
|
60
|
+
text: string
|
|
61
|
+
ts: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeTransport() {
|
|
65
|
+
const sent: SentMessage[] = []
|
|
66
|
+
return {
|
|
67
|
+
sent,
|
|
68
|
+
send: (chatId: string, text: string) => {
|
|
69
|
+
sent.push({ chatId, text, ts: Date.now() })
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Faithful reproduction of the gateway's turn_end branch
|
|
76
|
+
* (gateway.ts ~14486-14535). Real functions in, spy transport out —
|
|
77
|
+
* including the REAL `silentEndFallbackText` (exported from
|
|
78
|
+
* silent-end.ts so the assertions below validate the shipped copy,
|
|
79
|
+
* not a hand-maintained duplicate).
|
|
80
|
+
*/
|
|
81
|
+
function simulateTurnEnd(
|
|
82
|
+
args: {
|
|
83
|
+
chatId: string
|
|
84
|
+
threadId: number | null
|
|
85
|
+
turnKey: string
|
|
86
|
+
finalAnswerDelivered: boolean
|
|
87
|
+
turnDurationMs: number
|
|
88
|
+
},
|
|
89
|
+
transport: ReturnType<typeof makeTransport>,
|
|
90
|
+
deps?: SilentEndDeps,
|
|
91
|
+
): { exhausted: boolean } | null {
|
|
92
|
+
if (args.finalAnswerDelivered === false) {
|
|
93
|
+
const silentEnd = recordUndeliveredTurnEnd(
|
|
94
|
+
{ chatId: args.chatId, threadId: args.threadId, turnKey: args.turnKey },
|
|
95
|
+
deps,
|
|
96
|
+
)
|
|
97
|
+
if (silentEnd.exhausted) {
|
|
98
|
+
transport.send(args.chatId, silentEndFallbackText(args.turnDurationMs))
|
|
99
|
+
}
|
|
100
|
+
return silentEnd
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Drives the Stop-hook re-prompt ladder up to (but not past) MAX_RETRIES,
|
|
106
|
+
* exactly as `silent-end-interrupt-stop.mjs` does on each block. */
|
|
107
|
+
function driveRetryLadderToMax(turnKey: string) {
|
|
108
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
109
|
+
const s = readSilentEndState()!
|
|
110
|
+
writeFileSyncState(path, JSON.stringify({ ...s, retryCount: SILENT_END_MAX_RETRIES, turnKey }), 'utf8')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('silent-end dark-turn guard — transport-boundary outcome tests (RFC Phase 2)', () => {
|
|
114
|
+
it('(a) full dark turn: exactly one fallback message lands on the real surface', () => {
|
|
115
|
+
const transport = makeTransport()
|
|
116
|
+
const turnKey = 'c:_'
|
|
117
|
+
|
|
118
|
+
// First dark turn-end — writes state, does NOT deliver a fallback
|
|
119
|
+
// (the Stop-hook re-prompt ladder hasn't been exhausted yet).
|
|
120
|
+
simulateTurnEnd(
|
|
121
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 12_000 },
|
|
122
|
+
transport,
|
|
123
|
+
)
|
|
124
|
+
expect(transport.sent).toHaveLength(0)
|
|
125
|
+
|
|
126
|
+
// Stop hook re-prompts twice (SILENT_END_MAX_RETRIES), agent stays
|
|
127
|
+
// silent both times — ladder exhausted.
|
|
128
|
+
driveRetryLadderToMax(turnKey)
|
|
129
|
+
|
|
130
|
+
// Re-prompted turn ends dark again — budget spent, deliver fallback.
|
|
131
|
+
simulateTurnEnd(
|
|
132
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 47_000 },
|
|
133
|
+
transport,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(transport.sent).toHaveLength(1)
|
|
137
|
+
expect(transport.sent[0]!.chatId).toBe('c')
|
|
138
|
+
expect(transport.sent[0]!.text).toContain('waited 47s')
|
|
139
|
+
expect(transport.sent[0]!.text).toContain('didn’t send a reply')
|
|
140
|
+
// Once delivered, the state must not linger to double-fire on a
|
|
141
|
+
// subsequent, unrelated dark turn on the same chat/thread.
|
|
142
|
+
expect(readSilentEndState()).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('(b) deliberate silent end (NO_REPLY / HEARTBEAT_OK) never fires the guard', () => {
|
|
146
|
+
// The gateway sets `turn.finalAnswerDelivered = true` for NO_REPLY /
|
|
147
|
+
// HEARTBEAT_OK silent markers BEFORE reaching this branch
|
|
148
|
+
// (gateway.ts ~14192, ~13880, ~10762 — the sentinel-suppression
|
|
149
|
+
// sites) — so the branch is never entered at all for a legitimately
|
|
150
|
+
// silent turn. Reproduce that gate here.
|
|
151
|
+
const transport = makeTransport()
|
|
152
|
+
simulateTurnEnd(
|
|
153
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_', finalAnswerDelivered: true, turnDurationMs: 5_000 },
|
|
154
|
+
transport,
|
|
155
|
+
)
|
|
156
|
+
expect(transport.sent).toHaveLength(0)
|
|
157
|
+
expect(readSilentEndState()).toBeNull()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('(c) normal replied turn: zero fallback messages, regardless of prior stale state', () => {
|
|
161
|
+
const transport = makeTransport()
|
|
162
|
+
// A prior turn on this chat/thread already reached exhaustion and
|
|
163
|
+
// was cleared (see test (a)) — simulate a completely normal turn
|
|
164
|
+
// afterwards; the branch must not even be entered.
|
|
165
|
+
simulateTurnEnd(
|
|
166
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_', finalAnswerDelivered: true, turnDurationMs: 3_000 },
|
|
167
|
+
transport,
|
|
168
|
+
)
|
|
169
|
+
expect(transport.sent).toHaveLength(0)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('(d) the guard never double-fires: two calls to the exhausted branch on the same spent record yield exactly one send', () => {
|
|
173
|
+
const transport = makeTransport()
|
|
174
|
+
const turnKey = 'c:dup'
|
|
175
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
|
|
176
|
+
driveRetryLadderToMax(turnKey)
|
|
177
|
+
|
|
178
|
+
simulateTurnEnd(
|
|
179
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 10_000 },
|
|
180
|
+
transport,
|
|
181
|
+
)
|
|
182
|
+
expect(transport.sent).toHaveLength(1)
|
|
183
|
+
// State is cleared by the first exhausted call — a defensive SECOND
|
|
184
|
+
// call for the exact same still-dark turn (e.g. a duplicate turn_end
|
|
185
|
+
// event) finds no prior state and starts a FRESH retry cycle rather
|
|
186
|
+
// than re-firing the fallback a second time.
|
|
187
|
+
const second = simulateTurnEnd(
|
|
188
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 10_050 },
|
|
189
|
+
transport,
|
|
190
|
+
)
|
|
191
|
+
expect(second?.exhausted).toBe(false)
|
|
192
|
+
expect(transport.sent).toHaveLength(1)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('(d2) staleness hardening: an exhausted-looking record left by an ALREADY-ANSWERED turn on the same chat/thread must not fire the fallback for a brand-new dark turn', () => {
|
|
196
|
+
// silent-end's turnKey is `statusKey(chatId, threadId)` — stable
|
|
197
|
+
// across every turn on a chat/thread, not a per-turn nonce. If a
|
|
198
|
+
// reply's `clearSilentEndState` call is ever missed (it is
|
|
199
|
+
// fail-silent by design), a spent retryCount from an OLD,
|
|
200
|
+
// genuinely-answered turn could otherwise be misread as THIS
|
|
201
|
+
// brand-new dark turn's already-exhausted budget — firing the
|
|
202
|
+
// fallback without ever running the Stop-hook re-prompt ladder.
|
|
203
|
+
//
|
|
204
|
+
// This is the same class of bug #2472 fixed in the represent guard
|
|
205
|
+
// (gateway/represent-guard.ts shouldSuppressRepresent): a
|
|
206
|
+
// satisfied-but-misdetected obligation must be verified against real
|
|
207
|
+
// delivery history, not trusted from key equality + a counter alone.
|
|
208
|
+
const transport = makeTransport()
|
|
209
|
+
const turnKey = 'c:_'
|
|
210
|
+
const staleTimestamp = 1_000_000
|
|
211
|
+
|
|
212
|
+
// Simulate the missed-clear: an old, already-answered turn left the
|
|
213
|
+
// ladder pinned at MAX_RETRIES and (because the clear was missed) the
|
|
214
|
+
// file was never removed.
|
|
215
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
|
|
216
|
+
driveRetryLadderToMax(turnKey)
|
|
217
|
+
// Force the stale timestamp to predate the "reply" the deps stub
|
|
218
|
+
// reports below.
|
|
219
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
220
|
+
const s = readSilentEndState()!
|
|
221
|
+
writeFileSyncState(path, JSON.stringify({ ...s, timestamp: staleTimestamp }), 'utf8')
|
|
222
|
+
|
|
223
|
+
// A reply WAS in fact delivered on this chat (the missed-clear case)
|
|
224
|
+
// at some point after the stale record's timestamp.
|
|
225
|
+
const deps: SilentEndDeps = {
|
|
226
|
+
hasOutboundDeliveredSince: (chatId, sinceMs) =>
|
|
227
|
+
chatId === 'c' && sinceMs <= staleTimestamp + 500,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A genuinely NEW, unrelated dark turn now ends on the same
|
|
231
|
+
// chat/thread (same turnKey by construction).
|
|
232
|
+
const result = simulateTurnEnd(
|
|
233
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 8_000 },
|
|
234
|
+
transport,
|
|
235
|
+
deps,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
expect(result?.exhausted).toBe(false)
|
|
239
|
+
expect(transport.sent).toHaveLength(0)
|
|
240
|
+
// CRITICAL (adversarial review of #2892): the "fresh retry cycle" must
|
|
241
|
+
// actually be fresh. writeSilentEndState re-inherits retryCount when
|
|
242
|
+
// the on-disk turnKey matches (which it ALWAYS does here — turnKey is
|
|
243
|
+
// statusKey). If the staleness branch writes without clearing first,
|
|
244
|
+
// the new record starts at retryCount=MAX: the Stop hook
|
|
245
|
+
// (silent-end-interrupt-stop.mjs) sees retryCount >= MAX_RETRIES and
|
|
246
|
+
// never re-prompts, while the gateway just got exhausted:false and
|
|
247
|
+
// sent no fallback either — pure silence, worse than no fix at all.
|
|
248
|
+
expect(readSilentEndState()!.retryCount).toBe(0)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('(d3) staleness hardening does NOT suppress a genuine, fresh exhaustion (no reply since the record)', () => {
|
|
252
|
+
const transport = makeTransport()
|
|
253
|
+
const turnKey = 'c:_'
|
|
254
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
|
|
255
|
+
driveRetryLadderToMax(turnKey)
|
|
256
|
+
|
|
257
|
+
const deps: SilentEndDeps = {
|
|
258
|
+
// No reply has landed since the record — the exhaustion is real.
|
|
259
|
+
hasOutboundDeliveredSince: () => false,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const result = simulateTurnEnd(
|
|
263
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: false, turnDurationMs: 9_000 },
|
|
264
|
+
transport,
|
|
265
|
+
deps,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
expect(result?.exhausted).toBe(true)
|
|
269
|
+
expect(transport.sent).toHaveLength(1)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('cross-check: isFinalAnswerReply gating means an ack-only turn still reaches the dark-turn branch', () => {
|
|
273
|
+
// Sanity cross-check against the real predicate used at the reply
|
|
274
|
+
// send-sites (executeReply/executeStreamReply) — an interim ack does
|
|
275
|
+
// NOT satisfy finalAnswerDelivered, so the branch is correctly
|
|
276
|
+
// entered (this is the #1664 case, not a legitimate silent end).
|
|
277
|
+
const ack = { text: 'On it', disableNotification: true }
|
|
278
|
+
expect(isFinalAnswerReply(ack)).toBe(false)
|
|
279
|
+
|
|
280
|
+
const transport = makeTransport()
|
|
281
|
+
const turnKey = 'c:ack-only'
|
|
282
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey })
|
|
283
|
+
driveRetryLadderToMax(turnKey)
|
|
284
|
+
simulateTurnEnd(
|
|
285
|
+
{ chatId: 'c', threadId: null, turnKey, finalAnswerDelivered: isFinalAnswerReply(ack), turnDurationMs: 15_000 },
|
|
286
|
+
transport,
|
|
287
|
+
)
|
|
288
|
+
expect(transport.sent).toHaveLength(1)
|
|
289
|
+
})
|
|
290
|
+
})
|