switchroom 0.15.45 → 0.16.5
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/dist/agent-scheduler/index.js +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression contract for #2533: the over-ping anti-spam downgrade MUST NOT
|
|
3
|
+
* pollute final-answer classification.
|
|
4
|
+
*
|
|
5
|
+
* The bug (surfaced by the `midturn-silent-dm` UAT against v0.15.57): in
|
|
6
|
+
* `executeReply`, the #1675 over-ping safety net reassigns the local
|
|
7
|
+
* `disableNotification` to `true` to silence a 2nd+ ping in a turn, and the
|
|
8
|
+
* final-answer classifier was then read with that *downgraded* value. So a
|
|
9
|
+
* final answer the model INTENDED to ping but the anti-spam net silenced was
|
|
10
|
+
* misclassified as not-final → `finalAnswerDelivered` stayed false → a
|
|
11
|
+
* spurious silent-end re-prompt (#1664) AND a false 'undelivered' 😐 (#2530).
|
|
12
|
+
*
|
|
13
|
+
* The contract this pins (what `executeStreamReply` already did, and what
|
|
14
|
+
* #2533 made `executeReply` do): final-answer classification keys on the
|
|
15
|
+
* MODEL'S ORIGINAL INTENT (`args.disable_notification`), not the
|
|
16
|
+
* over-ping-downgraded send value. The actual SEND still honours the
|
|
17
|
+
* downgrade — only the classification is decoupled.
|
|
18
|
+
*
|
|
19
|
+
* `executeReply` itself isn't unit-callable (it lives in the 22k-line
|
|
20
|
+
* gateway), so this ties the two real pure modules together to reproduce the
|
|
21
|
+
* exact failing sequence and assert the invariant.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest'
|
|
25
|
+
import { decideOverPing } from '../over-ping-safety-net.js'
|
|
26
|
+
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
27
|
+
|
|
28
|
+
describe('#2533 — over-ping downgrade must not pollute final-answer classification', () => {
|
|
29
|
+
// The midturn-silent-dm failing sequence: an interim ack pings, then a
|
|
30
|
+
// SHORT final answer the model also intended to ping.
|
|
31
|
+
const ACK = { text: 'On it.', modelWantsPing: true }
|
|
32
|
+
const FINAL = { text: 'Hostname is example-host.', modelWantsPing: true } // <200 chars
|
|
33
|
+
|
|
34
|
+
it('reproduces the sequence: ack claims the ping slot, the short final gets over-ping-suppressed', () => {
|
|
35
|
+
// Beat 1 — the ack pings; first ping of the turn claims the slot (not suppressed).
|
|
36
|
+
const ackDecision = decideOverPing({ modelRequestedPing: ACK.modelWantsPing, firstPingAt: null, nowMs: 1_000 })
|
|
37
|
+
expect(ackDecision.suppress).toBe(false)
|
|
38
|
+
expect(ackDecision.claimSlot).toBe(true)
|
|
39
|
+
const firstPingAt = 1_000
|
|
40
|
+
|
|
41
|
+
// Beat 2 — the real final answer also wants to ping, but the slot is taken → SUPPRESS.
|
|
42
|
+
const finalDecision = decideOverPing({ modelRequestedPing: FINAL.modelWantsPing, firstPingAt, nowMs: 2_000 })
|
|
43
|
+
expect(finalDecision.suppress).toBe(true) // the gateway would downgrade disable_notification:false → true
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('classifying on the MODEL INTENT marks the suppressed short final as final (correct)', () => {
|
|
47
|
+
// This is what the gateway MUST do (#2533): use args.disable_notification,
|
|
48
|
+
// NOT the over-ping-downgraded value.
|
|
49
|
+
const modelDisableNotification = !FINAL.modelWantsPing // model wanted to ping → false
|
|
50
|
+
expect(
|
|
51
|
+
isFinalAnswerReply({ text: FINAL.text, disableNotification: modelDisableNotification }),
|
|
52
|
+
).toBe(true) // delivered final → finalAnswerDelivered=true → no spurious re-prompt, no false 😐
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('classifying on the DOWNGRADED value misclassifies it as not-final (the bug #2533 fixed)', () => {
|
|
56
|
+
// The over-ping net forced disable_notification → true. If classification
|
|
57
|
+
// reads THAT (the pre-#2533 bug), the short non-done final is seen as an
|
|
58
|
+
// interim ack → finalAnswerDelivered stays false → spurious re-prompt + 😐.
|
|
59
|
+
const downgradedDisableNotification = true
|
|
60
|
+
expect(
|
|
61
|
+
isFinalAnswerReply({ text: FINAL.text, disableNotification: downgradedDisableNotification }),
|
|
62
|
+
).toBe(false) // <-- this WRONG classification is exactly what the gateway must NOT produce
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('a genuinely-silent interim ack (model set disable_notification:true) is still NOT final — fix does not over-correct', () => {
|
|
66
|
+
// The decoupling must not turn EVERY reply final: a short reply the MODEL
|
|
67
|
+
// marked silent (a real interim ack) still classifies non-final on model intent.
|
|
68
|
+
const modelSilentAck = true
|
|
69
|
+
expect(
|
|
70
|
+
isFinalAnswerReply({ text: 'looking into that…', disableNotification: modelSilentAck }),
|
|
71
|
+
).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('a long over-ping-suppressed answer was already final regardless (length backstop) — fix matters for SHORT finals', () => {
|
|
75
|
+
const long = 'x'.repeat(250)
|
|
76
|
+
// Even classifying on the downgraded value, length ≥200 makes it final — so
|
|
77
|
+
// the bug only ever bit SHORT over-ping-suppressed finals (the #2533 case).
|
|
78
|
+
expect(isFinalAnswerReply({ text: long, disableNotification: true })).toBe(true)
|
|
79
|
+
expect(isFinalAnswerReply({ text: long, disableNotification: false })).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Notification ownership (R8 / PR-2 — design `docs/message-emission-
|
|
85
|
+
* determinism.md` §over-ping). The substantive final answer must OWN the
|
|
86
|
+
* turn's single device ping. The residual the bare "first ping wins" rule
|
|
87
|
+
* left: an interim ack pings first and claims the slot, so the later
|
|
88
|
+
* substantive answer is downgraded to silent — "the reply is last but the
|
|
89
|
+
* phone never buzzed for the answer." `decideOverPing` is now aware of WHO
|
|
90
|
+
* holds the slot (`firstPingWasSubstantive`) and WHO is asking
|
|
91
|
+
* (`substantive`) and UPGRADES a substantive answer over an ack's slot,
|
|
92
|
+
* while still suppressing every double-ping the #1674 guard exists for.
|
|
93
|
+
*
|
|
94
|
+
* The 2×2 ownership matrix (model wants to ping, slot already held):
|
|
95
|
+
*
|
|
96
|
+
* incoming \ slot held by │ ACK (non-substantive) │ SUBSTANTIVE
|
|
97
|
+
* ────────────────────────┼───────────────────────┼─────────────
|
|
98
|
+
* SUBSTANTIVE answer │ UPGRADE (ping, claim) │ suppress (#1674)
|
|
99
|
+
* ACK │ suppress (orig) │ suppress
|
|
100
|
+
*/
|
|
101
|
+
describe('R8 / PR-2 — substantive final answer OWNS the turn ping (upgrade matrix)', () => {
|
|
102
|
+
const SUBSTANTIVE = 'x'.repeat(300) // ≥200 → isSubstantiveFinalReply true
|
|
103
|
+
const ACK = 'On it.' // <200, non-done → ack
|
|
104
|
+
|
|
105
|
+
it('row 1 — substantive answer pinging over an ACK-held slot ⇒ UPGRADE (not suppressed)', () => {
|
|
106
|
+
// The ack pinged first (claimed the slot, non-substantive).
|
|
107
|
+
const ack = decideOverPing({
|
|
108
|
+
modelRequestedPing: true,
|
|
109
|
+
firstPingAt: null,
|
|
110
|
+
substantive: isSubstantiveFinalReply({ text: ACK, disableNotification: false }),
|
|
111
|
+
nowMs: 1_000,
|
|
112
|
+
})
|
|
113
|
+
expect(ack.claimSlot).toBe(true)
|
|
114
|
+
expect(ack.upgrade).toBe(false)
|
|
115
|
+
// Now the substantive answer wants to ping; the slot is ack-held.
|
|
116
|
+
const answer = decideOverPing({
|
|
117
|
+
modelRequestedPing: true,
|
|
118
|
+
firstPingAt: 1_000,
|
|
119
|
+
substantive: isSubstantiveFinalReply({ text: SUBSTANTIVE, disableNotification: false }),
|
|
120
|
+
firstPingWasSubstantive: false, // the ack
|
|
121
|
+
nowMs: 2_000,
|
|
122
|
+
})
|
|
123
|
+
expect(answer.suppress).toBe(false) // the ANSWER pings — phone buzzes for the answer
|
|
124
|
+
expect(answer.claimSlot).toBe(true) // slot upgraded to substantive
|
|
125
|
+
expect(answer.upgrade).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('row 2 — ACK pinging over a SUBSTANTIVE-held slot ⇒ suppress (no double-ping after the answer)', () => {
|
|
129
|
+
const d = decideOverPing({
|
|
130
|
+
modelRequestedPing: true,
|
|
131
|
+
firstPingAt: 1_000,
|
|
132
|
+
substantive: isSubstantiveFinalReply({ text: ACK, disableNotification: false }),
|
|
133
|
+
firstPingWasSubstantive: true, // the real answer already owned the slot
|
|
134
|
+
nowMs: 2_000,
|
|
135
|
+
})
|
|
136
|
+
expect(d.suppress).toBe(true)
|
|
137
|
+
expect(d.claimSlot).toBe(false)
|
|
138
|
+
expect(d.upgrade).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('row 3 — SUBSTANTIVE over a SUBSTANTIVE-held slot ⇒ suppress (preserves the #1674 model-double-ping guard)', () => {
|
|
142
|
+
// The reproducer #1674 targeted: a substantive answer pinged, then a
|
|
143
|
+
// substantive wrap-up also wants to ping. One beep, not two.
|
|
144
|
+
const d = decideOverPing({
|
|
145
|
+
modelRequestedPing: true,
|
|
146
|
+
firstPingAt: 30_000,
|
|
147
|
+
substantive: isSubstantiveFinalReply({ text: SUBSTANTIVE, disableNotification: false }),
|
|
148
|
+
firstPingWasSubstantive: true,
|
|
149
|
+
nowMs: 36_000,
|
|
150
|
+
})
|
|
151
|
+
expect(d.suppress).toBe(true)
|
|
152
|
+
expect(d.upgrade).toBe(false)
|
|
153
|
+
expect(d.sinceFirstPingMs).toBe(6_000)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('row 4 — ACK over an ACK-held slot ⇒ suppress (original one-ping-per-turn behaviour, unchanged)', () => {
|
|
157
|
+
const d = decideOverPing({
|
|
158
|
+
modelRequestedPing: true,
|
|
159
|
+
firstPingAt: 1_000,
|
|
160
|
+
substantive: isSubstantiveFinalReply({ text: ACK, disableNotification: false }),
|
|
161
|
+
firstPingWasSubstantive: false,
|
|
162
|
+
nowMs: 2_000,
|
|
163
|
+
})
|
|
164
|
+
expect(d.suppress).toBe(true)
|
|
165
|
+
expect(d.claimSlot).toBe(false)
|
|
166
|
+
expect(d.upgrade).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('the upgrade fires AT MOST once: after an upgrade, a further ack does NOT re-upgrade', () => {
|
|
170
|
+
// ack pings (slot=ack) → answer upgrades (slot=substantive) → a trailing
|
|
171
|
+
// ack must now suppress, not ping a third time.
|
|
172
|
+
const trailingAck = decideOverPing({
|
|
173
|
+
modelRequestedPing: true,
|
|
174
|
+
firstPingAt: 2_000, // upgraded slot timestamp
|
|
175
|
+
substantive: false,
|
|
176
|
+
firstPingWasSubstantive: true, // slot now substantive after the upgrade
|
|
177
|
+
nowMs: 3_000,
|
|
178
|
+
})
|
|
179
|
+
expect(trailingAck.suppress).toBe(true)
|
|
180
|
+
expect(trailingAck.upgrade).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('a substantive FIRST ping still claims (no upgrade flag) — upgrade is strictly the second-ping case', () => {
|
|
184
|
+
const d = decideOverPing({
|
|
185
|
+
modelRequestedPing: true,
|
|
186
|
+
firstPingAt: null, // no prior ping this turn
|
|
187
|
+
substantive: true,
|
|
188
|
+
nowMs: 1_000,
|
|
189
|
+
})
|
|
190
|
+
expect(d.claimSlot).toBe(true)
|
|
191
|
+
expect(d.upgrade).toBe(false) // first ping is a claim, not an upgrade
|
|
192
|
+
expect(d.suppress).toBe(false)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -38,7 +38,7 @@ describe('decideOverPing — at-most-one-ping-per-turn safety net', () => {
|
|
|
38
38
|
firstPingAt: null,
|
|
39
39
|
nowMs: 1_000,
|
|
40
40
|
})
|
|
41
|
-
expect(d1).toEqual({ suppress: false, claimSlot: false, sinceFirstPingMs: null })
|
|
41
|
+
expect(d1).toEqual({ suppress: false, claimSlot: false, upgrade: false, sinceFirstPingMs: null })
|
|
42
42
|
|
|
43
43
|
// Prior ping already landed — silent reply still no-op, NOT claimed
|
|
44
44
|
const d2 = decideOverPing({
|
|
@@ -46,7 +46,7 @@ describe('decideOverPing — at-most-one-ping-per-turn safety net', () => {
|
|
|
46
46
|
firstPingAt: 1_000,
|
|
47
47
|
nowMs: 5_000,
|
|
48
48
|
})
|
|
49
|
-
expect(d2).toEqual({ suppress: false, claimSlot: false, sinceFirstPingMs: null })
|
|
49
|
+
expect(d2).toEqual({ suppress: false, claimSlot: false, upgrade: false, sinceFirstPingMs: null })
|
|
50
50
|
})
|
|
51
51
|
|
|
52
52
|
it('handles the edge case where firstPingAt equals nowMs (instant double-call)', () => {
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-4e — per-topic `currentTurn` map behind the emission-authority kill-switch.
|
|
3
|
+
*
|
|
4
|
+
* The single ambient/global `currentTurn` singleton (one foreground turn at a
|
|
5
|
+
* time) is replaced by a per-topic `currentTurnByKey` map keyed on chat+thread,
|
|
6
|
+
* behind `SWITCHROOM_EMISSION_AUTHORITY` (default OFF).
|
|
7
|
+
*
|
|
8
|
+
* KEY FRAMING: the `claude` CLI is genuinely SEQUENTIAL — never two turns
|
|
9
|
+
* executing CPU-simultaneously. 4e is NOT about concurrent execution. It is
|
|
10
|
+
* about the ISOLATION of per-topic emission STATE so a LATE async event for
|
|
11
|
+
* topic A (a deferred drain, the orphaned-reply backstop, a ping decision, the
|
|
12
|
+
* answer-stream suppressor) that fires AFTER the live turn flipped to topic B
|
|
13
|
+
* resolves A's authority instead of contaminating B's card / ping / single-flight
|
|
14
|
+
* state.
|
|
15
|
+
*
|
|
16
|
+
* DEFINING PROPERTY: flag-OFF ≡ flag-ON ≡ base — machine-proven by the parity
|
|
17
|
+
* test below.
|
|
18
|
+
*
|
|
19
|
+
* This file has two halves:
|
|
20
|
+
* (a) a SOURCE-READ structural oracle over gateway.ts (the IIFE can't be
|
|
21
|
+
* instantiated in-process — same pattern as silence-liveness-wiring.test),
|
|
22
|
+
* (b) a BEHAVIOURAL in-process harness driving current-turn-map.ts via the
|
|
23
|
+
* re-import-per-flag seam (mirrors emission-authority-card-drain-gate.test's
|
|
24
|
+
* `loadFacade`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
28
|
+
import { readFileSync } from 'node:fs'
|
|
29
|
+
import { resolve } from 'node:path'
|
|
30
|
+
|
|
31
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// (a) SOURCE-READ STRUCTURAL ORACLE
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('PR-4e source-read oracle — the wiring the per-topic map depends on', () => {
|
|
38
|
+
it('exactly ONE per-topic CurrentTurnMap is constructed in the gateway', () => {
|
|
39
|
+
const decls = gatewaySrc.match(/new CurrentTurnMap<CurrentTurn>\(\)/g) ?? []
|
|
40
|
+
expect(decls.length).toBe(1)
|
|
41
|
+
// And it is imported from the dedicated module (not inlined).
|
|
42
|
+
expect(gatewaySrc).toMatch(/import \{ CurrentTurnMap \} from '\.\/current-turn-map\.js'/)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('all 4 write sites route through the keyed accessors — zero RAW `currentTurn = ` outside the accessor bodies', () => {
|
|
46
|
+
// The ONLY permitted `currentTurn = ` assignments are: the initial `let`
|
|
47
|
+
// null, and the three lines inside setCurrentTurn / endCurrentTurnForKey /
|
|
48
|
+
// clearAllCurrentTurns that keep the module-scope mirror in lock-step with
|
|
49
|
+
// the map. Every behavioural write site (turn ctor, endCurrentTurnAtomic,
|
|
50
|
+
// silence-poke fallback, disconnect-flush) goes through an accessor.
|
|
51
|
+
const rawAssigns = gatewaySrc.match(/^[^/\n]*\bcurrentTurn = (?!currentTurnMap\.get\(\)|null$)/gm) ?? []
|
|
52
|
+
expect(rawAssigns, `unexpected raw currentTurn assignment(s): ${JSON.stringify(rawAssigns)}`).toEqual([])
|
|
53
|
+
|
|
54
|
+
// The turn ctor SET routes through setCurrentTurn with the statusKey.
|
|
55
|
+
expect(gatewaySrc).toMatch(/setCurrentTurn\(next, statusKey\(ev\.chatId, enqThreadIdNum\)\)/)
|
|
56
|
+
// The disconnect-flush clears the WHOLE map (every entry is a ghost).
|
|
57
|
+
expect(gatewaySrc).toMatch(/clearAllCurrentTurns\(\)/)
|
|
58
|
+
// The silence-poke fallback does a keyed delete for the wedged turn's key.
|
|
59
|
+
expect(gatewaySrc).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('endCurrentTurnAtomic closes the leak AT ORIGIN — keyed liveness guard + keyed delete', () => {
|
|
63
|
+
const body = gatewaySrc
|
|
64
|
+
.split('function endCurrentTurnAtomic(turn: CurrentTurn): void {')[1]
|
|
65
|
+
?.split('\n}')[0] ?? ''
|
|
66
|
+
expect(body.length).toBeGreaterThan(50)
|
|
67
|
+
// Guard is the keyed liveness check (NOT a bare singleton ===).
|
|
68
|
+
expect(body).toMatch(/turnLiveForItsTopic\(turn\)/)
|
|
69
|
+
// The clear is the keyed delete (leak-close-at-origin).
|
|
70
|
+
expect(body).toMatch(/endCurrentTurnForKey\(turn, key\)/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('the keyed accessor flag-branches in EXACTLY one place each (in current-turn-map.ts, not the gateway)', () => {
|
|
74
|
+
const mapSrc = readFileSync(
|
|
75
|
+
resolve(__dirname, '..', 'gateway', 'current-turn-map.ts'),
|
|
76
|
+
'utf-8',
|
|
77
|
+
)
|
|
78
|
+
// The map is the single home of the per-topic store + flag read-once.
|
|
79
|
+
expect(mapSrc).toMatch(/process\.env\.SWITCHROOM_EMISSION_AUTHORITY === '1'/)
|
|
80
|
+
expect(mapSrc).toMatch(/readonly byKey = new Map<string, T>\(\)/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('the 3 liveness-comparison reads are keyed under the flag (NOT a bare singleton === under flag-ON)', () => {
|
|
84
|
+
// Each of the three behaviourally load-bearing comparison sites must, under
|
|
85
|
+
// the flag, resolve THIS turn by ITS OWN topic key. We assert the keyed
|
|
86
|
+
// form appears the expected number of times AND the literal flag-OFF form is
|
|
87
|
+
// retained (the silence-liveness-wiring oracle requires `currentTurn === turn`).
|
|
88
|
+
const keyedLiveness = gatewaySrc.match(
|
|
89
|
+
/EMISSION_AUTHORITY_ENABLED \? turnLiveForItsTopic\(turn\) : currentTurn === turn/g,
|
|
90
|
+
) ?? []
|
|
91
|
+
expect(keyedLiveness.length).toBe(3)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('findTurnByOriginId resolves the LIVE turn by its OWN key under the flag (O(1) byKey.get)', () => {
|
|
95
|
+
const body = gatewaySrc
|
|
96
|
+
.split('function findTurnByOriginId(')[1]
|
|
97
|
+
?.split('\n}')[0] ?? ''
|
|
98
|
+
expect(body).toMatch(/EMISSION_AUTHORITY_ENABLED/)
|
|
99
|
+
expect(body).toMatch(/currentTurnMap\.get\(originTurnId\.slice\(0, hashIdx\)\)/)
|
|
100
|
+
// recentTurnsById registry fallback UNCHANGED.
|
|
101
|
+
expect(body).toMatch(/recentTurnsById\.get\(originTurnId\)/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('the self-heal backstop drops per-topic entries for the swept chat (purgeChatStale)', () => {
|
|
105
|
+
expect(gatewaySrc).toMatch(/currentTurnMap\.purgeChatStale\(fbChatId,/)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// (b) BEHAVIOURAL IN-PROCESS HARNESS (re-import-per-flag seam)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
let reimportSeq = 0
|
|
118
|
+
/**
|
|
119
|
+
* Re-import current-turn-map.ts with the flag set as requested. The flag is read
|
|
120
|
+
* ONCE at module top; bun re-evaluates a module imported under a fresh query
|
|
121
|
+
* string, so the read-once const is recomputed against the env we set here — a
|
|
122
|
+
* test-only seam that leaves the production read-once path untouched.
|
|
123
|
+
*/
|
|
124
|
+
async function loadMap(enabled: boolean): Promise<typeof import('../gateway/current-turn-map.js')> {
|
|
125
|
+
if (enabled) process.env.SWITCHROOM_EMISSION_AUTHORITY = '1'
|
|
126
|
+
else delete process.env.SWITCHROOM_EMISSION_AUTHORITY
|
|
127
|
+
return import(`../gateway/current-turn-map.js?ptctcase=${reimportSeq++}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A minimal stand-in for the gateway's CurrentTurn — identity is all we need. */
|
|
131
|
+
interface FakeTurn {
|
|
132
|
+
id: string
|
|
133
|
+
sessionChatId: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const KEY_A = 'chatA:_'
|
|
137
|
+
const KEY_B = 'chatA:42'
|
|
138
|
+
|
|
139
|
+
describe('per-topic current-turn — MULTI-TOPIC NON-CONTAMINATION (the most important property)', () => {
|
|
140
|
+
it('two topics under the same chat hold distinct live turns SIMULTANEOUSLY', async () => {
|
|
141
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
142
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
143
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'chatA' }
|
|
144
|
+
const B: FakeTurn = { id: 'B', sessionChatId: 'chatA' }
|
|
145
|
+
|
|
146
|
+
map.set(A, KEY_A)
|
|
147
|
+
map.set(B, KEY_B) // the live turn flips A → B
|
|
148
|
+
|
|
149
|
+
// BOTH are live for their own topic AT THE SAME TIME — the isolation 4e buys.
|
|
150
|
+
expect(map.get(KEY_A)).toBe(A)
|
|
151
|
+
expect(map.get(KEY_B)).toBe(B)
|
|
152
|
+
// The global "is anything live" mirror points at the most-recent (B).
|
|
153
|
+
expect(map.get()).toBe(B)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('a DEFERRED A-captured callback firing AFTER B flipped resolves A — not B', async () => {
|
|
157
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
158
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
159
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'chatA' }
|
|
160
|
+
const B: FakeTurn = { id: 'B', sessionChatId: 'chatA' }
|
|
161
|
+
|
|
162
|
+
map.set(A, KEY_A)
|
|
163
|
+
map.set(B, KEY_B) // live turn flipped to B
|
|
164
|
+
|
|
165
|
+
// Simulate the late A-captured callback (a deferred drain / ping / silence
|
|
166
|
+
// reset) firing now, AFTER the flip. It carries A and A's key.
|
|
167
|
+
const lateCallbackTurn = A
|
|
168
|
+
const lateCallbackKey = KEY_A
|
|
169
|
+
// The keyed liveness read resolves A's OWN topic — TRUE — even though the
|
|
170
|
+
// ambient mirror has moved on to B.
|
|
171
|
+
expect(map.isLiveForKey(lateCallbackTurn, lateCallbackKey)).toBe(true)
|
|
172
|
+
// A bare singleton check would have read B and FALSIFIED A's liveness; the
|
|
173
|
+
// keyed read does not contaminate.
|
|
174
|
+
expect(map.isLiveForKey(B, KEY_A)).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("ending A deletes ONLY A — B stays live through A's teardown", async () => {
|
|
178
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
179
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
180
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'chatA' }
|
|
181
|
+
const B: FakeTurn = { id: 'B', sessionChatId: 'chatA' }
|
|
182
|
+
|
|
183
|
+
map.set(A, KEY_A)
|
|
184
|
+
map.set(B, KEY_B)
|
|
185
|
+
|
|
186
|
+
// A B liveness check stays TRUE before, during, and after A's teardown.
|
|
187
|
+
expect(map.isLiveForKey(B, KEY_B)).toBe(true)
|
|
188
|
+
const ended = map.endTurnForKey(A, KEY_A)
|
|
189
|
+
expect(ended).toBe(true)
|
|
190
|
+
// A is gone; B is untouched.
|
|
191
|
+
expect(map.get(KEY_A)).toBeNull()
|
|
192
|
+
expect(map.get(KEY_B)).toBe(B)
|
|
193
|
+
expect(map.isLiveForKey(B, KEY_B)).toBe(true)
|
|
194
|
+
// The mirror was pointing at B (most-recent) the whole time — A's teardown
|
|
195
|
+
// did NOT clear it (A was not the mirror target).
|
|
196
|
+
expect(map.get()).toBe(B)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('endTurnForKey is a no-op when the key has already flipped to a successor (idempotent / no cross-topic clobber)', async () => {
|
|
200
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
201
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
202
|
+
const A1: FakeTurn = { id: 'A1', sessionChatId: 'chatA' }
|
|
203
|
+
const A2: FakeTurn = { id: 'A2', sessionChatId: 'chatA' }
|
|
204
|
+
map.set(A1, KEY_A)
|
|
205
|
+
map.set(A2, KEY_A) // same topic, new turn
|
|
206
|
+
|
|
207
|
+
// A late teardown for the OLD A1 must NOT delete the live A2.
|
|
208
|
+
expect(map.endTurnForKey(A1, KEY_A)).toBe(false)
|
|
209
|
+
expect(map.get(KEY_A)).toBe(A2)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('per-topic current-turn — PARITY (flag-OFF ≡ flag-ON ≡ base)', () => {
|
|
214
|
+
/**
|
|
215
|
+
* The single-topic sequence set A → set B → end B → end A produces an
|
|
216
|
+
* IDENTICAL observable liveness trace under both flag states (and that trace
|
|
217
|
+
* IS the base singleton behaviour). This is the machine proof of the defining
|
|
218
|
+
* property for the single-topic case the old singleton governed.
|
|
219
|
+
*/
|
|
220
|
+
async function liveTrace(enabled: boolean): Promise<string[]> {
|
|
221
|
+
const { CurrentTurnMap } = await loadMap(enabled)
|
|
222
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
223
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'c' }
|
|
224
|
+
const B: FakeTurn = { id: 'B', sessionChatId: 'c' }
|
|
225
|
+
const KA = 'c:_'
|
|
226
|
+
const KB = 'c:_' // SAME topic — the single-topic case the singleton modeled
|
|
227
|
+
const trace: string[] = []
|
|
228
|
+
const snap = (label: string) =>
|
|
229
|
+
trace.push(
|
|
230
|
+
`${label}: global=${map.get()?.id ?? 'null'} Alive=${map.isLiveForKey(A, KA)} Blive=${map.isLiveForKey(B, KB)}`,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
snap('init')
|
|
234
|
+
map.set(A, KA)
|
|
235
|
+
snap('setA')
|
|
236
|
+
map.set(B, KB) // same topic: B supersedes A
|
|
237
|
+
snap('setB')
|
|
238
|
+
map.endTurnForKey(B, KB)
|
|
239
|
+
snap('endB')
|
|
240
|
+
map.endTurnForKey(A, KA) // already superseded — no-op
|
|
241
|
+
snap('endA')
|
|
242
|
+
return trace
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
it('the single-topic liveness trace is identical under flag OFF and flag ON', async () => {
|
|
246
|
+
const off = await liveTrace(false)
|
|
247
|
+
const on = await liveTrace(true)
|
|
248
|
+
expect(on).toEqual(off)
|
|
249
|
+
// And it matches the base singleton semantics: B supersedes A in the same
|
|
250
|
+
// topic, so after setB only B is live; ending B clears it; ending the
|
|
251
|
+
// already-superseded A is a no-op.
|
|
252
|
+
expect(off).toEqual([
|
|
253
|
+
'init: global=null Alive=false Blive=false',
|
|
254
|
+
'setA: global=A Alive=true Blive=false',
|
|
255
|
+
'setB: global=B Alive=false Blive=true',
|
|
256
|
+
'endB: global=null Alive=false Blive=false',
|
|
257
|
+
'endA: global=null Alive=false Blive=false',
|
|
258
|
+
])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('flag OFF never writes the per-topic map (zero alloc, stays empty)', async () => {
|
|
262
|
+
const { CurrentTurnMap } = await loadMap(false)
|
|
263
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
264
|
+
map.set({ id: 'A', sessionChatId: 'c' }, 'c:_')
|
|
265
|
+
map.set({ id: 'B', sessionChatId: 'c' }, 'c:42')
|
|
266
|
+
// The byKey map is never touched under the flag OFF — the singleton is all.
|
|
267
|
+
expect(map.byKey.size).toBe(0)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('flag OFF keyed reads degenerate to the singleton (key is ignored)', async () => {
|
|
271
|
+
const { CurrentTurnMap } = await loadMap(false)
|
|
272
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
273
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'c' }
|
|
274
|
+
map.set(A, 'c:_')
|
|
275
|
+
// Under OFF, get(anyKey) === get() === the singleton.
|
|
276
|
+
expect(map.get('c:_')).toBe(A)
|
|
277
|
+
expect(map.get('c:999')).toBe(A) // key ignored — singleton semantics
|
|
278
|
+
expect(map.get()).toBe(A)
|
|
279
|
+
expect(map.isLiveForKey(A, 'whatever')).toBe(true)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('per-topic current-turn — LEAK FENCE', () => {
|
|
284
|
+
it('after BOTH topics end, the per-topic map is empty (size 0)', async () => {
|
|
285
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
286
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
287
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'chatA' }
|
|
288
|
+
const B: FakeTurn = { id: 'B', sessionChatId: 'chatA' }
|
|
289
|
+
map.set(A, KEY_A)
|
|
290
|
+
map.set(B, KEY_B)
|
|
291
|
+
map.endTurnForKey(A, KEY_A)
|
|
292
|
+
map.endTurnForKey(B, KEY_B)
|
|
293
|
+
expect(map.byKey.size).toBe(0)
|
|
294
|
+
expect(map.get()).toBeNull()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('bounded leak-by-supersession: a different-key set with NO A teardown leaves A until the self-heal sweep reclaims it', async () => {
|
|
298
|
+
// The REAL bounded-leak path the design relies on (distinct from the
|
|
299
|
+
// explicit-double-teardown case above): topic A is set, then topic B is set
|
|
300
|
+
// under a DIFFERENT key with NO intervening endTurnForKey(A). The mirror
|
|
301
|
+
// flips to B, but A's byKey entry is NOT torn down at that moment — it
|
|
302
|
+
// dangles until the periodic purgeChatStale self-heal sweeps it. This proves
|
|
303
|
+
// the leak is BOUNDED (the sweep reclaims it), not unbounded.
|
|
304
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
305
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
306
|
+
const A: FakeTurn = { id: 'A', sessionChatId: 'chatA' }
|
|
307
|
+
const B: FakeTurn = { id: 'B', sessionChatId: 'chatA' }
|
|
308
|
+
|
|
309
|
+
map.set(A, KEY_A) // chatA:_
|
|
310
|
+
map.set(B, KEY_B) // chatA:42 — different key, NO endTurnForKey(A)
|
|
311
|
+
|
|
312
|
+
// The leak: BOTH entries are present — A was never explicitly torn down, it
|
|
313
|
+
// is just no longer the mirror target.
|
|
314
|
+
expect(map.byKey.size).toBe(2)
|
|
315
|
+
expect(map.get(KEY_A)).toBe(A)
|
|
316
|
+
expect(map.get(KEY_B)).toBe(B)
|
|
317
|
+
expect(map.get()).toBe(B) // mirror moved on to the most-recent set
|
|
318
|
+
|
|
319
|
+
// The self-heal sweep for chatA, with B's topic (KEY_B) marked LIVE (not
|
|
320
|
+
// stale) and A's topic (KEY_A) stale, reclaims ONLY A's dangling entry.
|
|
321
|
+
const swept = map.purgeChatStale('chatA', (k) => k !== KEY_B)
|
|
322
|
+
expect(swept).toEqual([KEY_A])
|
|
323
|
+
|
|
324
|
+
// Leak reclaimed: A is gone, the map is bounded back to just B, and the
|
|
325
|
+
// mirror is untouched (it pointed at B, not a swept victim).
|
|
326
|
+
expect(map.get(KEY_A)).toBeNull()
|
|
327
|
+
expect(map.byKey.size).toBe(1)
|
|
328
|
+
expect(map.get(KEY_B)).toBe(B)
|
|
329
|
+
expect(map.get()).toBe(B)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('the disconnect-flush sim clears the WHOLE map + mirror (every entry is a ghost)', async () => {
|
|
333
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
334
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
335
|
+
map.set({ id: 'A', sessionChatId: 'chatA' }, KEY_A)
|
|
336
|
+
map.set({ id: 'B', sessionChatId: 'chatA' }, KEY_B)
|
|
337
|
+
map.set({ id: 'C', sessionChatId: 'chatC' }, 'chatC:_')
|
|
338
|
+
expect(map.byKey.size).toBe(3)
|
|
339
|
+
map.clearAll() // bridge died — every entry is a ghost
|
|
340
|
+
expect(map.byKey.size).toBe(0)
|
|
341
|
+
expect(map.get()).toBeNull()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('purgeChatStale self-heal drops only the firing chat\'s STALE entries — live siblings survive', async () => {
|
|
345
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
346
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
347
|
+
const a = { id: 'a', sessionChatId: 'chatA' }
|
|
348
|
+
const aSibling = { id: 'aSib', sessionChatId: 'chatA' }
|
|
349
|
+
const other = { id: 'o', sessionChatId: 'chatB' }
|
|
350
|
+
map.set(a, 'chatA:_')
|
|
351
|
+
map.set(aSibling, 'chatA:42')
|
|
352
|
+
map.set(other, 'chatB:_')
|
|
353
|
+
|
|
354
|
+
// Sweep chatA but mark the sibling topic chatA:42 as LIVE (not stale) — it
|
|
355
|
+
// must survive (the one-agent-owns-supergroup safety).
|
|
356
|
+
const swept = map.purgeChatStale('chatA', (k) => k !== 'chatA:42')
|
|
357
|
+
expect(swept).toEqual(['chatA:_'])
|
|
358
|
+
expect(map.get('chatA:_')).toBeNull()
|
|
359
|
+
expect(map.get('chatA:42')).toBe(aSibling) // live sibling spared
|
|
360
|
+
expect(map.get('chatB:_')).toBe(other) // other chat untouched
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('purgeChatStale defaults to always-stale (DM / single-topic) and clears the mirror iff it pointed at a victim', async () => {
|
|
364
|
+
const { CurrentTurnMap } = await loadMap(true)
|
|
365
|
+
const map = new CurrentTurnMap<FakeTurn>()
|
|
366
|
+
const a = { id: 'a', sessionChatId: 'chatA' }
|
|
367
|
+
map.set(a, 'chatA:_') // a is also the mirror (most-recent)
|
|
368
|
+
const swept = map.purgeChatStale('chatA')
|
|
369
|
+
expect(swept).toEqual(['chatA:_'])
|
|
370
|
+
expect(map.byKey.size).toBe(0)
|
|
371
|
+
expect(map.get()).toBeNull() // mirror cleared (it pointed at the victim)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M-1: SWITCHROOM_PERMISSION_CARD_ORIGIN_RECOVERY kill-switch — structural assertion.
|
|
3
|
+
*
|
|
4
|
+
* The permission-card origin-recovery feature (fixes the "marko Rentals-budget"
|
|
5
|
+
* incident: permission cards fanning out to operator DMs instead of the forum
|
|
6
|
+
* topic the operator is working in) must be gated by a kill switch so operators
|
|
7
|
+
* can opt out of the recovery behaviour if it causes issues.
|
|
8
|
+
*
|
|
9
|
+
* Load-bearing constraint: the kill switch must use `!== '0'` semantics (default
|
|
10
|
+
* ON — recovery is active unless explicitly disabled). This is a structural
|
|
11
|
+
* assertion — pattern matches silence-liveness-wiring.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const gatewaySrc = readFileSync(
|
|
18
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
19
|
+
'utf-8',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
describe('M-1: SWITCHROOM_PERMISSION_CARD_ORIGIN_RECOVERY kill-switch', () => {
|
|
23
|
+
it('kill-switch env var name is SWITCHROOM_PERMISSION_CARD_ORIGIN_RECOVERY', () => {
|
|
24
|
+
expect(gatewaySrc).toContain('SWITCHROOM_PERMISSION_CARD_ORIGIN_RECOVERY')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('kill-switch constant is defined with !== "0" semantic (default ON)', () => {
|
|
28
|
+
expect(gatewaySrc).toMatch(
|
|
29
|
+
/PERMISSION_CARD_ORIGIN_RECOVERY_ENABLED[\s\S]{0,10}=[\s\S]{0,10}process\.env\.SWITCHROOM_PERMISSION_CARD_ORIGIN_RECOVERY\s*!==\s*'0'/,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('kill-switch gates the origin-recovery call site in gateway.ts', () => {
|
|
34
|
+
// There must be at least two occurrences: the const definition and the gate site.
|
|
35
|
+
const matches = [...gatewaySrc.matchAll(/PERMISSION_CARD_ORIGIN_RECOVERY_ENABLED/g)]
|
|
36
|
+
expect(matches.length).toBeGreaterThanOrEqual(2)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('kill-switch is checked with if (PERMISSION_CARD_ORIGIN_RECOVERY_ENABLED)', () => {
|
|
40
|
+
expect(gatewaySrc).toMatch(/if\s*\(\s*PERMISSION_CARD_ORIGIN_RECOVERY_ENABLED\s*\)/)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -203,6 +203,23 @@ describe('matchesAllowRule — scoped file/Bash/Skill rules', () => {
|
|
|
203
203
|
it('does not match a different tool with the same arg', () => {
|
|
204
204
|
expect(matchesAllowRule('Skill(mail)', 'Bash', JSON.stringify({ skill: 'mail' }))).toBe(false)
|
|
205
205
|
})
|
|
206
|
+
|
|
207
|
+
it('matches a scoped Edit rule when the inputPreview is truncated (enforcement-half fix)', () => {
|
|
208
|
+
// Simulate Claude Code's 200-char truncation of inputPreview for Edit calls
|
|
209
|
+
// where old_string/new_string push the JSON past 200 chars.
|
|
210
|
+
const filePath = '/path/to/module.ts'
|
|
211
|
+
const truncatedPreview = JSON.stringify({
|
|
212
|
+
file_path: filePath,
|
|
213
|
+
old_string: 'function oldFn() {\n // many lines of old code that push JSON well past 200 chars\n const x = doSomething();\n return x;\n}',
|
|
214
|
+
new_string: 'function newFn() { return doSomethingElse(); }',
|
|
215
|
+
}).slice(0, 200)
|
|
216
|
+
|
|
217
|
+
// Precondition: the preview must be invalid JSON (proving truncation occurred).
|
|
218
|
+
expect(() => JSON.parse(truncatedPreview)).toThrow()
|
|
219
|
+
|
|
220
|
+
// The scoped rule must still match — file_path is the first key and intact.
|
|
221
|
+
expect(matchesAllowRule(`Edit(${filePath})`, 'Edit', truncatedPreview)).toBe(true)
|
|
222
|
+
})
|
|
206
223
|
})
|
|
207
224
|
|
|
208
225
|
describe('matchesAllowRule — MCP', () => {
|