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.
Files changed (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. 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 + 9000)
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 + 9000)
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 + 9000)
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 { lastActivityAt: 0, idleClearMs: 3 * H, alreadyCleared: false, turnInFlight: false, ...p }
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('decideIdleClear', () => {
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
+ })