switchroom 0.15.45 → 0.16.4
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 +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3158 -1178
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7479 -7439
- package/dist/vault/broker/server.js +11312 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -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 +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- 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 +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- 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-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,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', () => {
|