switchroom 0.18.11 → 0.18.12
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 +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2453 -1293
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +504 -100
- package/dist/vault/approvals/kernel-server.js +53 -13
- package/dist/vault/broker/server.js +162 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
43
43
|
// window to ~5KB so we don't pick up the unrelated
|
|
44
44
|
// `removeTurnActiveMarker` call in the onTurnComplete handler
|
|
45
45
|
// way down at the bottom of the file.
|
|
46
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
46
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
|
|
47
47
|
const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
|
|
48
48
|
expect(clearMatches.length).toBe(1)
|
|
49
49
|
})
|
|
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
53
53
|
// must not prevent the gateway from coming up. Pin the try-wrapping
|
|
54
54
|
// to make sure a future refactor doesn't drop it.
|
|
55
55
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
56
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
56
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
|
|
57
57
|
expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
62
62
|
// Clearing the marker first means the watchdog sees a clean slate
|
|
63
63
|
// immediately, even if pin sweep takes 30s+ to finish.
|
|
64
64
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
65
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
65
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
|
|
66
66
|
const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
|
|
67
67
|
const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
|
|
68
68
|
expect(clearIdx).toBeGreaterThan(0)
|
|
@@ -36,6 +36,7 @@ function makeDeps(agentName: string | null) {
|
|
|
36
36
|
const finalizeB = vi.fn(async () => {})
|
|
37
37
|
const clearActiveReactions = vi.fn()
|
|
38
38
|
const disposeProgressDriver = vi.fn()
|
|
39
|
+
const stopTurnTypingLoops = vi.fn()
|
|
39
40
|
const log = vi.fn()
|
|
40
41
|
|
|
41
42
|
const activeStatusReactions = new Map<string, FakeCtrl>([
|
|
@@ -72,7 +73,7 @@ function makeDeps(agentName: string | null) {
|
|
|
72
73
|
// carry or clear it.
|
|
73
74
|
|
|
74
75
|
return {
|
|
75
|
-
spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, log },
|
|
76
|
+
spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, stopTurnTypingLoops, log },
|
|
76
77
|
deps: {
|
|
77
78
|
agentName,
|
|
78
79
|
activeStatusReactions,
|
|
@@ -83,6 +84,7 @@ function makeDeps(agentName: string | null) {
|
|
|
83
84
|
activeDraftStreams,
|
|
84
85
|
clearActiveReactions,
|
|
85
86
|
disposeProgressDriver,
|
|
87
|
+
stopTurnTypingLoops,
|
|
86
88
|
log,
|
|
87
89
|
},
|
|
88
90
|
}
|
|
@@ -121,6 +123,16 @@ describe('flushOnAgentDisconnect — anonymous clients (the regression scenario)
|
|
|
121
123
|
expect(spies.setDoneA).toHaveBeenCalledTimes(0)
|
|
122
124
|
expect(spies.setDoneB).toHaveBeenCalledTimes(0)
|
|
123
125
|
})
|
|
126
|
+
|
|
127
|
+
it('#2650: does NOT sweep the turn-typing loops for anonymous disconnects', () => {
|
|
128
|
+
// An anonymous one-shot IPC client (recall.py, etc.) never owned a
|
|
129
|
+
// turn-typing loop. The sweep MUST be gated by the agentName-null
|
|
130
|
+
// early-return so a constant stream of anonymous disconnects can't
|
|
131
|
+
// kill a live agent's typing indicator.
|
|
132
|
+
const { spies, deps } = makeDeps(null)
|
|
133
|
+
flushOnAgentDisconnect(deps)
|
|
134
|
+
expect(spies.stopTurnTypingLoops).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
124
136
|
})
|
|
125
137
|
|
|
126
138
|
describe('flushOnAgentDisconnect — registered agent disconnects (existing behavior preserved)', () => {
|
|
@@ -144,6 +156,15 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
|
|
|
144
156
|
expect(spies.clearActiveReactions).toHaveBeenCalledTimes(1)
|
|
145
157
|
})
|
|
146
158
|
|
|
159
|
+
it('#2650: sweeps the turn-typing loops on a real agent disconnect', () => {
|
|
160
|
+
// The bridge died mid-turn — the canonical turn-end that would stop the
|
|
161
|
+
// per-(chat,thread) `typing…` interval will never arrive, so the flush
|
|
162
|
+
// must sweep every live loop or a stale "typing…" shows until next turn.
|
|
163
|
+
const { spies, deps } = makeDeps('clerk')
|
|
164
|
+
flushOnAgentDisconnect(deps)
|
|
165
|
+
expect(spies.stopTurnTypingLoops).toHaveBeenCalledTimes(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
147
168
|
it('finalizes only non-final draft streams and clears the maps', () => {
|
|
148
169
|
const { spies, deps } = makeDeps('clerk')
|
|
149
170
|
flushOnAgentDisconnect(deps)
|
|
@@ -172,6 +193,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
172
193
|
const onDanglingTurnsSwept = vi.fn()
|
|
173
194
|
const clearActiveReactions = vi.fn()
|
|
174
195
|
const disposeProgressDriver = vi.fn()
|
|
196
|
+
const stopTurnTypingLoops = vi.fn()
|
|
175
197
|
const log = vi.fn()
|
|
176
198
|
const deps = {
|
|
177
199
|
agentName: 'clerk',
|
|
@@ -185,6 +207,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
185
207
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
186
208
|
clearActiveReactions,
|
|
187
209
|
disposeProgressDriver,
|
|
210
|
+
stopTurnTypingLoops,
|
|
188
211
|
onDanglingTurnsSwept,
|
|
189
212
|
log,
|
|
190
213
|
}
|
|
@@ -243,6 +266,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
243
266
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
244
267
|
clearActiveReactions: vi.fn(),
|
|
245
268
|
disposeProgressDriver: vi.fn(),
|
|
269
|
+
stopTurnTypingLoops: vi.fn(),
|
|
246
270
|
onDanglingTurnsSwept,
|
|
247
271
|
log: vi.fn(),
|
|
248
272
|
}
|
|
@@ -278,6 +302,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
278
302
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
279
303
|
clearActiveReactions: vi.fn(),
|
|
280
304
|
disposeProgressDriver: vi.fn(),
|
|
305
|
+
stopTurnTypingLoops: vi.fn(),
|
|
281
306
|
onDanglingTurnsSwept,
|
|
282
307
|
log,
|
|
283
308
|
}
|
|
@@ -310,6 +335,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
310
335
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
311
336
|
clearActiveReactions: vi.fn(),
|
|
312
337
|
disposeProgressDriver: vi.fn(),
|
|
338
|
+
stopTurnTypingLoops: vi.fn(),
|
|
313
339
|
}
|
|
314
340
|
// Singular form.
|
|
315
341
|
flushOnAgentDisconnect({
|
|
@@ -348,6 +374,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
348
374
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
349
375
|
clearActiveReactions: vi.fn(),
|
|
350
376
|
disposeProgressDriver: vi.fn(),
|
|
377
|
+
stopTurnTypingLoops: vi.fn(),
|
|
351
378
|
log,
|
|
352
379
|
})
|
|
353
380
|
expect(log.mock.calls.some((c: unknown[]) =>
|
|
@@ -368,6 +395,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
368
395
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
369
396
|
clearActiveReactions: vi.fn(),
|
|
370
397
|
disposeProgressDriver: vi.fn(),
|
|
398
|
+
stopTurnTypingLoops: vi.fn(),
|
|
371
399
|
// onDanglingTurnsSwept intentionally omitted.
|
|
372
400
|
log: vi.fn(),
|
|
373
401
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structural integration test for the loopback-paste fail-safe redaction in
|
|
6
|
+
* the gateway's inbound handler (security audit #3084, F2/F3).
|
|
7
|
+
*
|
|
8
|
+
* Why structural: gateway/gateway.ts does not export its inbound handler, so a
|
|
9
|
+
* pure-functional invocation would require end-to-end harnessing of
|
|
10
|
+
* Bot/Grammy/Context. The decision predicate (shouldConsumeLoopbackPaste, incl.
|
|
11
|
+
* the malformed-paste fail-safe) is exhaustively unit-tested in
|
|
12
|
+
* `auth-loopback-relay.test.ts`, and the redaction primitive
|
|
13
|
+
* (redactAuthCodeMessage) in `auth-code-redact.test.ts`. What's left to assert
|
|
14
|
+
* here is that the gateway actually WIRES a fail-safe so a code-bearing paste
|
|
15
|
+
* that is malformed, or arrives just after the intercept TTL, or has no active
|
|
16
|
+
* flow, is redacted + dropped rather than forwarded to the agent.
|
|
17
|
+
*
|
|
18
|
+
* The invariants:
|
|
19
|
+
* 1. The stale (TTL-expired) loopback branch does NOT let the paste fall
|
|
20
|
+
* through to the agent — it flows into the fail-safe below.
|
|
21
|
+
* 2. A fail-safe `if (shouldConsumeLoopbackPaste(text))` block exists that
|
|
22
|
+
* redacts (redactAuthCodeMessage) and returns, sitting AFTER the
|
|
23
|
+
* pending-flow branch but BEFORE the generic auth-code / agent-forward
|
|
24
|
+
* handlers.
|
|
25
|
+
*/
|
|
26
|
+
describe('gateway loopback-paste fail-safe redaction — structural wiring', () => {
|
|
27
|
+
const src = readFileSync(
|
|
28
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
29
|
+
'utf8',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
it('imports the consume predicate + redaction helper', () => {
|
|
33
|
+
expect(src).toMatch(/shouldConsumeLoopbackPaste/)
|
|
34
|
+
expect(src).toMatch(/redactAuthCodeMessage/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('has a fail-safe redact-and-return block guarded by shouldConsumeLoopbackPaste', () => {
|
|
38
|
+
// Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(text)) { ... }`
|
|
39
|
+
// that is NOT part of the `pendingLoop && ...` pending-flow branch.
|
|
40
|
+
const failSafe = src.match(
|
|
41
|
+
/if \(shouldConsumeLoopbackPaste\(text\)\) \{[\s\S]{0,600}?\n {2}\}/g,
|
|
42
|
+
)
|
|
43
|
+
expect(failSafe).not.toBeNull()
|
|
44
|
+
// The standalone fail-safe (the one NOT preceded by `pendingLoop &&`).
|
|
45
|
+
const standalone = (failSafe ?? []).find(
|
|
46
|
+
(b) => !b.startsWith('if (pendingLoop'),
|
|
47
|
+
)
|
|
48
|
+
expect(standalone).toBeDefined()
|
|
49
|
+
expect(standalone!).toMatch(/redactAuthCodeMessage/)
|
|
50
|
+
expect(standalone!).toMatch(/return/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('the stale loopback branch falls through to the fail-safe (does not forward)', () => {
|
|
54
|
+
// The stale-path comment must reference the fail-safe, and the fail-safe
|
|
55
|
+
// block must appear AFTER the pending-loop branch and BEFORE the generic
|
|
56
|
+
// auth-code intercept (`pendingReauthFlows`).
|
|
57
|
+
const failSafeIdx = src.indexOf(
|
|
58
|
+
'Fail-safe redaction (security audit #3084, F2/F3)',
|
|
59
|
+
)
|
|
60
|
+
const pendingLoopIdx = src.indexOf('const pendingLoop = pendingLoopbackFlows.get')
|
|
61
|
+
const reauthIdx = src.indexOf('const pendingReauth = pendingReauthFlows.get')
|
|
62
|
+
expect(failSafeIdx).toBeGreaterThan(-1)
|
|
63
|
+
expect(failSafeIdx).toBeGreaterThan(pendingLoopIdx)
|
|
64
|
+
expect(reauthIdx).toBeGreaterThan(failSafeIdx)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the gateway trace verbosity gate (#3025): zero-signal per-poll
|
|
3
|
+
* heartbeats are suppressed by default and the full firehose is restored
|
|
4
|
+
* only when SWITCHROOM_GW_TRACE is truthy. Error lines and real
|
|
5
|
+
* (non-poll / non-idle-tick) activity ALWAYS emit regardless of the flag.
|
|
6
|
+
*
|
|
7
|
+
* DUAL-RUNNER NOTE (vitest + bun): this file runs under BOTH runners, and
|
|
8
|
+
* bun's vitest shim does not honor `vi.resetModules()` — a re-import
|
|
9
|
+
* returns the originally-cached module, so flag variants CANNOT be tested
|
|
10
|
+
* via env + fresh import. Instead the gate exposes pure paths: the env
|
|
11
|
+
* parse is `computeGwTraceVerbose(flag)` and both should-emit functions
|
|
12
|
+
* take an explicit `verbose` argument (defaulting to the module-init
|
|
13
|
+
* read). Tests pass verbosity explicitly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
computeGwTraceVerbose,
|
|
19
|
+
shouldEmitTgPost,
|
|
20
|
+
shouldEmitShadowTrace,
|
|
21
|
+
} from '../shared/gw-trace-gate.js'
|
|
22
|
+
|
|
23
|
+
describe('computeGwTraceVerbose — env flag parsing', () => {
|
|
24
|
+
it('is OFF when unset', () => {
|
|
25
|
+
expect(computeGwTraceVerbose(undefined)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('opts IN on the truthy set, case-insensitively (1/true/yes/on)', () => {
|
|
29
|
+
for (const v of ['1', 'true', 'TRUE', 'True', 'yes', 'Yes', 'YES', 'on', 'ON', 'On', ' 1', 'true ']) {
|
|
30
|
+
expect(computeGwTraceVerbose(v)).toBe(true)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('stays OFF for falsey / unrecognized values', () => {
|
|
35
|
+
for (const v of ['', '0', 'false', 'FALSE', 'off', 'No', 'x', 'enabled']) {
|
|
36
|
+
expect(computeGwTraceVerbose(v)).toBe(false)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('shouldEmitTgPost — default (verbose=false): suppress poll heartbeats', () => {
|
|
42
|
+
it('suppresses status=ok for getUpdates and getMe', () => {
|
|
43
|
+
expect(shouldEmitTgPost('getUpdates', 'ok', false)).toBe(false)
|
|
44
|
+
expect(shouldEmitTgPost('getMe', 'ok', false)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('ALWAYS emits error lines, even for the poll methods', () => {
|
|
48
|
+
expect(shouldEmitTgPost('getUpdates', 'err', false)).toBe(true)
|
|
49
|
+
expect(shouldEmitTgPost('getMe', 'err', false)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps status=ok for real outbound methods', () => {
|
|
53
|
+
for (const m of ['sendMessage', 'editMessageText', 'sendChatAction', 'answerCallbackQuery']) {
|
|
54
|
+
expect(shouldEmitTgPost(m, 'ok', false)).toBe(true)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('shouldEmitShadowTrace — default (verbose=false): suppress idle no-op ticks', () => {
|
|
60
|
+
it('suppresses a tick with no effects in an idle global state', () => {
|
|
61
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', false)).toBe(false)
|
|
62
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_dead', false)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('emits a tick that produced effects (e.g. a TTL turn expiry)', () => {
|
|
66
|
+
expect(shouldEmitShadowTrace('tick', 1, 'bridge_alive_idle', false)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('emits a tick while a turn is in flight', () => {
|
|
70
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_in_turn', false)).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('ALWAYS emits non-tick events (real inbound/turn/bridge activity)', () => {
|
|
74
|
+
for (const ev of ['inbound', 'turnStart', 'turnEnd', 'bridgeUp', 'bridgeDown', 'permVerdict']) {
|
|
75
|
+
expect(shouldEmitShadowTrace(ev, 0, 'bridge_alive_idle', false)).toBe(true)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('verbose=true — full firehose restored', () => {
|
|
81
|
+
it('emits every line, including the two suppressed-by-default families', () => {
|
|
82
|
+
expect(shouldEmitTgPost('getUpdates', 'ok', true)).toBe(true)
|
|
83
|
+
expect(shouldEmitTgPost('getMe', 'ok', true)).toBe(true)
|
|
84
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', true)).toBe(true)
|
|
85
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_dead', true)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('default verbose argument wiring', () => {
|
|
90
|
+
it('the defaulted call form works and matches the module-init env read', () => {
|
|
91
|
+
// Env-agnostic assertion: the defaulted form must agree with the
|
|
92
|
+
// explicit form using the parsed CURRENT env — proving the default
|
|
93
|
+
// parameter is wired to the module-init flag, whatever its value in
|
|
94
|
+
// the test process.
|
|
95
|
+
const envVerbose = computeGwTraceVerbose(process.env.SWITCHROOM_GW_TRACE)
|
|
96
|
+
expect(shouldEmitTgPost('getUpdates', 'ok')).toBe(
|
|
97
|
+
shouldEmitTgPost('getUpdates', 'ok', envVerbose),
|
|
98
|
+
)
|
|
99
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle')).toBe(
|
|
100
|
+
shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', envVerbose),
|
|
101
|
+
)
|
|
102
|
+
// Regardless of the flag, an error line always emits via the default form.
|
|
103
|
+
expect(shouldEmitTgPost('getUpdates', 'err')).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -1,17 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle auto-clear — what "idle" MEANS.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-07-11 `overlord` worked for three hours straight (sub-agents, ~25 PRs
|
|
5
|
+
* merged) and was `/clear`ed 12 s after the window elapsed — three hours of
|
|
6
|
+
* session context destroyed. Root cause: the idle window was measured from the
|
|
7
|
+
* last turn START, and nothing in a turn (tool calls, sub-agent work, the turn
|
|
8
|
+
* ENDING) counted as activity. The invariant was inverted: the longer and more
|
|
9
|
+
* productive the work, the more certain the wipe.
|
|
10
|
+
*
|
|
11
|
+
* These tests assert the OUTCOME: an agent that is doing things is not idle,
|
|
12
|
+
* and an agent that is doing nothing still gets cleared.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic virtual clock only (`T + n * H`) — no fake timers, no
|
|
15
|
+
* `Date.now()`. Everything schedulable takes the clock as an argument, so the
|
|
16
|
+
* whole 3h window is exercised in microseconds and the suite stays portable if
|
|
17
|
+
* this file is ever moved to the bun runner. (It runs under vitest today: it is
|
|
18
|
+
* not in `package.json`'s `test:bun` list and not excluded in
|
|
19
|
+
* `vitest.config.ts`.)
|
|
20
|
+
*/
|
|
21
|
+
|
|
1
22
|
import { describe, it, expect } from 'vitest'
|
|
2
23
|
import {
|
|
3
24
|
decideIdleClear,
|
|
25
|
+
classifyIdleEvent,
|
|
4
26
|
idleDurationToMs,
|
|
5
27
|
DEFAULT_IDLE_CLEAR_MS,
|
|
6
28
|
type IdleClearState,
|
|
7
29
|
} from '../gateway/idle-clear.js'
|
|
8
30
|
|
|
9
31
|
const H = 3_600_000
|
|
32
|
+
const M = 60_000
|
|
33
|
+
/** Gateway's IDLE_CLEAR_CHECK_MS default. */
|
|
34
|
+
const TICK = 60_000
|
|
35
|
+
|
|
10
36
|
function state(p: Partial<IdleClearState>): IdleClearState {
|
|
11
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
lastActivityAt: 0,
|
|
39
|
+
lastTurnEndedAt: null,
|
|
40
|
+
idleClearMs: 3 * H,
|
|
41
|
+
alreadyCleared: false,
|
|
42
|
+
turnInFlight: false,
|
|
43
|
+
...p,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A minimal model of the gateway's idle bookkeeping, driven by a virtual clock.
|
|
49
|
+
* Mirrors gateway.ts: `markIdleActivity()` on inbound / cron / ANY session
|
|
50
|
+
* event, `markIdleTurnEnd()` on a turn ending, `decideIdleClear()` on each
|
|
51
|
+
* IDLE_CLEAR_CHECK_MS tick, `alreadyCleared` latched on fire.
|
|
52
|
+
*/
|
|
53
|
+
class IdleModel {
|
|
54
|
+
lastActivityAt: number
|
|
55
|
+
lastTurnEndedAt: number | null = null
|
|
56
|
+
alreadyCleared = false
|
|
57
|
+
turnInFlight = false
|
|
58
|
+
clears: number[] = []
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
startedAt: number,
|
|
62
|
+
readonly idleClearMs = 3 * H,
|
|
63
|
+
) {
|
|
64
|
+
this.lastActivityAt = startedAt
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A claude session-stream event (the gateway's handleSessionEvent stamp). */
|
|
68
|
+
sessionEvent(kind: string, now: number, durationMs?: number): void {
|
|
69
|
+
const signal = classifyIdleEvent(kind, durationMs)
|
|
70
|
+
if (signal.activity) {
|
|
71
|
+
this.lastActivityAt = now
|
|
72
|
+
this.alreadyCleared = false
|
|
73
|
+
}
|
|
74
|
+
if (signal.turnEnded) this.lastTurnEndedAt = now
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Inbound / cron fire. */
|
|
78
|
+
activity(now: number): void {
|
|
79
|
+
this.lastActivityAt = now
|
|
80
|
+
this.alreadyCleared = false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
tick(now: number): boolean {
|
|
84
|
+
const { clear } = decideIdleClear(
|
|
85
|
+
{
|
|
86
|
+
lastActivityAt: this.lastActivityAt,
|
|
87
|
+
lastTurnEndedAt: this.lastTurnEndedAt,
|
|
88
|
+
idleClearMs: this.idleClearMs,
|
|
89
|
+
alreadyCleared: this.alreadyCleared,
|
|
90
|
+
turnInFlight: this.turnInFlight,
|
|
91
|
+
},
|
|
92
|
+
now,
|
|
93
|
+
)
|
|
94
|
+
if (clear) {
|
|
95
|
+
this.alreadyCleared = true
|
|
96
|
+
this.clears.push(now)
|
|
97
|
+
}
|
|
98
|
+
return clear
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Run the interval from `from` to `to` inclusive, one tick per TICK. */
|
|
102
|
+
ticksThrough(from: number, to: number): void {
|
|
103
|
+
for (let t = from; t <= to; t += TICK) this.tick(t)
|
|
104
|
+
}
|
|
12
105
|
}
|
|
13
106
|
|
|
14
|
-
describe('
|
|
107
|
+
describe('idle-clear: a working agent is not idle', () => {
|
|
108
|
+
it('HEADLINE — a turn that runs LONGER than the idle window is not cleared when it ends', () => {
|
|
109
|
+
// The overlord incident, replayed. Window 3h. Turn starts at T, runs 3h08m
|
|
110
|
+
// of real work (sub-agents, tool calls), ends at T+3h08m.
|
|
111
|
+
const T = 100 * H
|
|
112
|
+
const m = new IdleModel(T)
|
|
113
|
+
|
|
114
|
+
m.turnInFlight = true
|
|
115
|
+
m.sessionEvent('enqueue', T) // turn start
|
|
116
|
+
|
|
117
|
+
// ... 3h08m of work. The gate suppresses the clear while in flight.
|
|
118
|
+
m.ticksThrough(T, T + 3 * H + 8 * M)
|
|
119
|
+
expect(m.clears).toEqual([])
|
|
120
|
+
|
|
121
|
+
// Turn ends. `turnInFlight` flips false — the moment of truth.
|
|
122
|
+
const end = T + 3 * H + 8 * M
|
|
123
|
+
m.sessionEvent('turn_end', end, 11_314_000)
|
|
124
|
+
m.turnInFlight = false
|
|
125
|
+
|
|
126
|
+
// The very next tick (12 s later in production) must NOT clear...
|
|
127
|
+
expect(m.tick(end + 12_000)).toBe(false)
|
|
128
|
+
// ...nor the next several ticks, nor anything short of a full fresh window.
|
|
129
|
+
m.ticksThrough(end, end + 3 * H - TICK)
|
|
130
|
+
expect(m.clears).toEqual([])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('in-turn work keeps the timer warm even when the gateway sees no turn boundary', () => {
|
|
134
|
+
// What ACTUALLY happened to overlord: the last turn ended at 07:05 and the
|
|
135
|
+
// agent then worked for ~3h under stop-hook continuations that opened no new
|
|
136
|
+
// turn. `turnInFlight` was false the whole time, so only the per-event stamp
|
|
137
|
+
// can save it.
|
|
138
|
+
const T = 100 * H
|
|
139
|
+
const m = new IdleModel(T)
|
|
140
|
+
m.sessionEvent('turn_end', T, 34_596) // last turn boundary
|
|
141
|
+
m.turnInFlight = false
|
|
142
|
+
|
|
143
|
+
// 3h30m of real work with no turn boundary: tool calls + sub-agent activity
|
|
144
|
+
// every ~10 min, and a tick between each.
|
|
145
|
+
for (let t = T; t <= T + 3 * H + 30 * M; t += 10 * M) {
|
|
146
|
+
m.sessionEvent('tool_use', t)
|
|
147
|
+
m.sessionEvent('sub_agent_tool_use', t + 60_000)
|
|
148
|
+
m.ticksThrough(t, t + 10 * M - TICK)
|
|
149
|
+
}
|
|
150
|
+
expect(m.clears).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('every genuine session event counts as activity (sub-agents included)', () => {
|
|
154
|
+
for (const kind of [
|
|
155
|
+
'enqueue',
|
|
156
|
+
'thinking',
|
|
157
|
+
'model',
|
|
158
|
+
'tool_use',
|
|
159
|
+
'tool_result',
|
|
160
|
+
'text',
|
|
161
|
+
'sub_agent_started',
|
|
162
|
+
'sub_agent_tool_use',
|
|
163
|
+
'sub_agent_text',
|
|
164
|
+
'sub_agent_turn_end',
|
|
165
|
+
]) {
|
|
166
|
+
expect(classifyIdleEvent(kind).activity).toBe(true)
|
|
167
|
+
expect(classifyIdleEvent(kind).turnEnded).toBe(false)
|
|
168
|
+
}
|
|
169
|
+
// A real turn_end is activity AND a turn boundary.
|
|
170
|
+
expect(classifyIdleEvent('turn_end', 5_000)).toEqual({ activity: true, turnEnded: true })
|
|
171
|
+
// The gateway's own synthetic turn_end (orphaned-reply backstop) is not the
|
|
172
|
+
// model doing something — but it still ends the turn.
|
|
173
|
+
expect(classifyIdleEvent('turn_end', -1)).toEqual({ activity: false, turnEnded: true })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('a background sub-agent still emitting events after the main turn ends holds off the clear', () => {
|
|
177
|
+
const T = 100 * H
|
|
178
|
+
const m = new IdleModel(T)
|
|
179
|
+
m.sessionEvent('turn_end', T, 30_000)
|
|
180
|
+
m.turnInFlight = false
|
|
181
|
+
// Worker grinds for 4h past the main turn end.
|
|
182
|
+
for (let t = T; t <= T + 4 * H; t += 5 * M) {
|
|
183
|
+
m.sessionEvent('sub_agent_tool_use', t)
|
|
184
|
+
m.ticksThrough(t, t + 5 * M - TICK)
|
|
185
|
+
}
|
|
186
|
+
expect(m.clears).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('idle-clear: a genuinely idle agent is still cleared', () => {
|
|
191
|
+
it('no turn, no inbound, no session event for a full window → cleared exactly once', () => {
|
|
192
|
+
const T = 100 * H
|
|
193
|
+
const m = new IdleModel(T)
|
|
194
|
+
m.ticksThrough(T, T + 6 * H)
|
|
195
|
+
expect(m.clears).toEqual([T + 3 * H])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('cleared once per idle period, and re-arms on the next inbound', () => {
|
|
199
|
+
const T = 100 * H
|
|
200
|
+
const m = new IdleModel(T)
|
|
201
|
+
m.ticksThrough(T, T + 5 * H)
|
|
202
|
+
expect(m.clears).toEqual([T + 3 * H])
|
|
203
|
+
|
|
204
|
+
const inbound = T + 5 * H
|
|
205
|
+
m.activity(inbound) // operator comes back
|
|
206
|
+
m.ticksThrough(inbound, inbound + 3 * H - TICK)
|
|
207
|
+
expect(m.clears).toEqual([T + 3 * H]) // not yet
|
|
208
|
+
m.ticksThrough(inbound, inbound + 3 * H)
|
|
209
|
+
expect(m.clears).toEqual([T + 3 * H, inbound + 3 * H])
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('a turn that ends is cleared one full window after it ended (not before)', () => {
|
|
213
|
+
const T = 100 * H
|
|
214
|
+
const m = new IdleModel(T)
|
|
215
|
+
m.turnInFlight = true
|
|
216
|
+
m.sessionEvent('enqueue', T)
|
|
217
|
+
const end = T + 3 * H + 8 * M
|
|
218
|
+
m.sessionEvent('turn_end', end, 11_314_000)
|
|
219
|
+
m.turnInFlight = false
|
|
220
|
+
|
|
221
|
+
m.ticksThrough(end, end + 3 * H - TICK)
|
|
222
|
+
expect(m.clears).toEqual([]) // still warm
|
|
223
|
+
m.ticksThrough(end, end + 3 * H + TICK)
|
|
224
|
+
expect(m.clears).toEqual([end + 3 * H]) // now genuinely idle → cleared
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('decideIdleClear (pure gate)', () => {
|
|
15
229
|
it('fires once the idle window has elapsed', () => {
|
|
16
230
|
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
|
|
17
231
|
expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H + 1).clear).toBe(true)
|
|
@@ -32,10 +246,26 @@ describe('decideIdleClear', () => {
|
|
|
32
246
|
it('is disabled when idleClearMs <= 0', () => {
|
|
33
247
|
expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: 0 }), 10 * H).clear).toBe(false)
|
|
34
248
|
expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: -1 }), 10 * H).clear).toBe(false)
|
|
249
|
+
// ...even with a fresh turn end in play.
|
|
250
|
+
expect(
|
|
251
|
+
decideIdleClear(state({ lastActivityAt: 0, lastTurnEndedAt: 9 * H, idleClearMs: 0 }), 10 * H).clear,
|
|
252
|
+
).toBe(false)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('measures the window from max(lastActivityAt, lastTurnEndedAt)', () => {
|
|
256
|
+
// Turn started at 0 (lastActivityAt), ended at 5h. At 5h the window has long
|
|
257
|
+
// "elapsed" against the START — it must not be treated as idle.
|
|
258
|
+
const justEnded = state({ lastActivityAt: 0, lastTurnEndedAt: 5 * H })
|
|
259
|
+
expect(decideIdleClear(justEnded, 5 * H).clear).toBe(false)
|
|
260
|
+
expect(decideIdleClear(justEnded, 8 * H - 1).clear).toBe(false)
|
|
261
|
+
expect(decideIdleClear(justEnded, 8 * H).clear).toBe(true) // a full window after the END
|
|
262
|
+
// Symmetric: activity newer than the turn end wins.
|
|
263
|
+
const activeSince = state({ lastActivityAt: 6 * H, lastTurnEndedAt: 5 * H })
|
|
264
|
+
expect(decideIdleClear(activeSince, 8 * H).clear).toBe(false)
|
|
265
|
+
expect(decideIdleClear(activeSince, 9 * H).clear).toBe(true)
|
|
35
266
|
})
|
|
36
267
|
|
|
37
268
|
it('re-arms after activity (fresh lastActivityAt + alreadyCleared=false → waits again)', () => {
|
|
38
|
-
// Simulated: after a clear, activity resets lastActivityAt to `now` and the flag.
|
|
39
269
|
const now = 100 * H
|
|
40
270
|
const reArmed = state({ lastActivityAt: now, alreadyCleared: false })
|
|
41
271
|
expect(decideIdleClear(reArmed, now + 3 * H - 1).clear).toBe(false) // not yet
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2094 finding 4 — bridge-side messageId dedup.
|
|
3
|
+
*
|
|
4
|
+
* Outcome contract: a genuine user message re-delivered by the stranded-
|
|
5
|
+
* message sweep within one turn is forwarded to claude exactly ONCE; after
|
|
6
|
+
* the turn-complete clear (a successful reply for that chat) forwarding
|
|
7
|
+
* resumes. Synthetic / button / structured-event inbounds are never deduped,
|
|
8
|
+
* and per-chat memory is capped.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
InboundDedup,
|
|
13
|
+
shouldDedupInbound,
|
|
14
|
+
dedupChatKey,
|
|
15
|
+
INBOUND_DEDUP_MAX_PER_CHAT,
|
|
16
|
+
type DedupInboundLike,
|
|
17
|
+
} from '../bridge/inbound-dedup.js'
|
|
18
|
+
|
|
19
|
+
function userMsg(messageId: number, extra: Record<string, string> = {}): DedupInboundLike {
|
|
20
|
+
return { messageId, meta: { chat_id: '12345', message_id: String(messageId), ...extra } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('shouldDedupInbound gate', () => {
|
|
24
|
+
it('dedups a plain user message', () => {
|
|
25
|
+
expect(shouldDedupInbound(userMsg(500))).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
it('never dedups synthetic (meta.source), button, structured, or reaction inbounds', () => {
|
|
28
|
+
expect(shouldDedupInbound(userMsg(500, { source: 'cron' }))).toBe(false)
|
|
29
|
+
expect(shouldDedupInbound(userMsg(500, { button_callback: 'true' }))).toBe(false)
|
|
30
|
+
expect(shouldDedupInbound(userMsg(500, { kind: 'checklist_task_changed' }))).toBe(false)
|
|
31
|
+
expect(shouldDedupInbound(userMsg(500, { event: 'reaction' }))).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
it('never dedups a non-positive messageId (no real Telegram id)', () => {
|
|
34
|
+
expect(shouldDedupInbound(userMsg(0))).toBe(false)
|
|
35
|
+
expect(shouldDedupInbound({ messageId: -1, meta: {} })).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('InboundDedup checkAndRecord + clearChat', () => {
|
|
40
|
+
it('processes a duplicate messageId within one turn exactly once', () => {
|
|
41
|
+
const d = new InboundDedup()
|
|
42
|
+
const key = dedupChatKey('12345')
|
|
43
|
+
expect(d.checkAndRecord(key, 500)).toBe('fresh')
|
|
44
|
+
// Sweep re-delivers the SAME messageId before the turn completes.
|
|
45
|
+
expect(d.checkAndRecord(key, 500)).toBe('duplicate')
|
|
46
|
+
expect(d.checkAndRecord(key, 500)).toBe('duplicate')
|
|
47
|
+
// A different message in the same turn is still forwarded.
|
|
48
|
+
expect(d.checkAndRecord(key, 501)).toBe('fresh')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('resumes processing the same messageId after the turn-complete clear', () => {
|
|
52
|
+
const d = new InboundDedup()
|
|
53
|
+
const key = dedupChatKey('12345')
|
|
54
|
+
expect(d.checkAndRecord(key, 500)).toBe('fresh')
|
|
55
|
+
expect(d.checkAndRecord(key, 500)).toBe('duplicate')
|
|
56
|
+
// reply fired for the chat → turn-complete reset.
|
|
57
|
+
d.clearChat('12345')
|
|
58
|
+
expect(d.size(key)).toBe(0)
|
|
59
|
+
// A genuinely new turn may re-see the id (fresh again).
|
|
60
|
+
expect(d.checkAndRecord(key, 500)).toBe('fresh')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('clearChat is thread-agnostic — clears every thread key for the chat', () => {
|
|
64
|
+
const d = new InboundDedup()
|
|
65
|
+
const dm = dedupChatKey('12345')
|
|
66
|
+
const topic = dedupChatKey('12345', 42)
|
|
67
|
+
d.checkAndRecord(dm, 500)
|
|
68
|
+
d.checkAndRecord(topic, 700)
|
|
69
|
+
d.checkAndRecord(dedupChatKey('999', 1), 800) // unrelated chat, untouched
|
|
70
|
+
d.clearChat('12345')
|
|
71
|
+
expect(d.size(dm)).toBe(0)
|
|
72
|
+
expect(d.size(topic)).toBe(0)
|
|
73
|
+
expect(d.size(dedupChatKey('999', 1))).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('caps per-chat memory (FIFO-evicts the oldest id)', () => {
|
|
77
|
+
const d = new InboundDedup(3)
|
|
78
|
+
const key = dedupChatKey('12345')
|
|
79
|
+
d.checkAndRecord(key, 1)
|
|
80
|
+
d.checkAndRecord(key, 2)
|
|
81
|
+
d.checkAndRecord(key, 3)
|
|
82
|
+
d.checkAndRecord(key, 4) // evicts id 1
|
|
83
|
+
expect(d.size(key)).toBe(3)
|
|
84
|
+
// id 1 was evicted → treated as fresh again (bounded memory, accepted).
|
|
85
|
+
expect(d.checkAndRecord(key, 1)).toBe('fresh')
|
|
86
|
+
// ids still tracked are duplicates.
|
|
87
|
+
expect(d.checkAndRecord(key, 4)).toBe('duplicate')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('default cap is a sane bound', () => {
|
|
91
|
+
expect(INBOUND_DEDUP_MAX_PER_CHAT).toBeGreaterThanOrEqual(128)
|
|
92
|
+
})
|
|
93
|
+
})
|