switchroom 0.19.39 → 0.19.40

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 (31) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +12 -3
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +524 -189
  5. package/dist/host-control/main.js +217 -5
  6. package/dist/vault/approvals/kernel-server.js +12 -3
  7. package/dist/vault/broker/server.js +12 -3
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +314 -191
  12. package/telegram-plugin/dist/server.js +8 -0
  13. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  14. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  15. package/telegram-plugin/gateway/gateway.ts +3 -3
  16. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  17. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  18. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  19. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  20. package/telegram-plugin/gateway/stream-render.ts +39 -1
  21. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  22. package/telegram-plugin/hooks/hooks.json +11 -0
  23. package/telegram-plugin/session-tail.ts +20 -0
  24. package/telegram-plugin/silence-poke.ts +28 -0
  25. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  26. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  27. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  28. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  29. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  30. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  31. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * #4058 — mid-turn auto-compaction must not trip the 300s silence fallback.
3
+ *
4
+ * The bug: during compaction the model emits zero output and runs zero tools,
5
+ * so the gateway saw pure silence and fired the framework fallback ("⚠️ no
6
+ * output for 5 min — the framework ended that stalled turn") on a healthy
7
+ * turn that completed correctly right after compaction. Observed live:
8
+ * ~1m50s of tools + ~3m25s compacting = 304s silence → false fire.
9
+ *
10
+ * The fix: the PreCompact hook writes a compaction marker; silence-poke gets
11
+ * an `isCompactionInFlight` dep consulted in the SAME `underCeiling` defer
12
+ * branch as the #1292/#3519 in-flight-tool defers; the transcript's
13
+ * `compact_boundary` record (compaction END) clears the marker and counts as
14
+ * production. These tests drive the REAL tick loop and assert outcomes:
15
+ * - a compaction gap past 300s but under the hard ceiling → NO fire;
16
+ * - a genuinely-wedged turn (no compaction) → STILL fires at 300s;
17
+ * - a compaction stuck past the hard ceiling → STILL fires (bounded defer).
18
+ */
19
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
20
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, utimesSync } from 'node:fs'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+ import { execFileSync } from 'node:child_process'
24
+ import { projectTranscriptLine } from '../session-tail.js'
25
+ import { applySilencePokeSessionEvent } from '../gateway/silence-poke-session-event.js'
26
+ import {
27
+ COMPACTION_MARKER_FILE,
28
+ readCompactionMarkerAgeMs,
29
+ removeCompactionMarker,
30
+ } from '../gateway/compaction-marker.js'
31
+ import * as silencePoke from '../silence-poke.js'
32
+ import * as pendingProgress from '../pending-work-progress.js'
33
+ import {
34
+ startTurn,
35
+ __tickForTests,
36
+ __setDepsForTests,
37
+ __getStateForTests,
38
+ __resetAllForTests,
39
+ DEFAULT_THRESHOLDS,
40
+ type SilencePokeMetric,
41
+ type FrameworkFallbackContext,
42
+ } from '../silence-poke.js'
43
+
44
+ const HARD_CEILING = 900_000 // SILENCE_FALLBACK_HARD_MS default
45
+
46
+ interface TestFixtures {
47
+ emitted: SilencePokeMetric[]
48
+ fallbacks: FrameworkFallbackContext[]
49
+ }
50
+
51
+ function setupDeps(opts?: {
52
+ isCompactionInFlight?: (key: string) => boolean
53
+ isLegitimatelyWorking?: (key: string) => boolean
54
+ }): TestFixtures {
55
+ const fixtures: TestFixtures = { emitted: [], fallbacks: [] }
56
+ __setDepsForTests({
57
+ emitMetric: (e) => fixtures.emitted.push(e),
58
+ onFrameworkFallback: (ctx) => { fixtures.fallbacks.push(ctx) },
59
+ thresholdsMs: { ...DEFAULT_THRESHOLDS, fallbackHardCeiling: HARD_CEILING },
60
+ // Mirror production wiring: the callback path is active (liveness-wiring
61
+ // always wires isLegitimatelyWorking), returning false = "no tool work".
62
+ isLegitimatelyWorking: opts?.isLegitimatelyWorking ?? (() => false),
63
+ ...(opts?.isCompactionInFlight != null
64
+ ? { isCompactionInFlight: opts.isCompactionInFlight }
65
+ : {}),
66
+ })
67
+ return fixtures
68
+ }
69
+
70
+ beforeEach(() => {
71
+ __resetAllForTests()
72
+ delete process.env.SWITCHROOM_DISABLE_SILENCE_POKE
73
+ })
74
+
75
+ afterEach(() => {
76
+ __resetAllForTests()
77
+ })
78
+
79
+ describe('silence-poke — #4058 compaction defer (outcome)', () => {
80
+ it('a compaction gap longer than 300s but under the hard ceiling does NOT fire the fallback', () => {
81
+ // Real observed shape: tools until t=110s, compaction t=110s..415s (305s
82
+ // of pure silence — past the 300s window), turn completes after.
83
+ let compacting = false
84
+ const fx = setupDeps({ isCompactionInFlight: () => compacting })
85
+ startTurn('chat:1', 0)
86
+ // Tools produced feed renders until 110s → production reset at 110s.
87
+ silencePoke.noteProduction('chat:1', 110_000)
88
+ compacting = true // PreCompact hook fired
89
+ __tickForTests(300_000) // 190s silent — under threshold anyway
90
+ __tickForTests(415_000) // 305s silent — WOULD fire without the fix
91
+ __tickForTests(500_000) // 390s silent — still compacting
92
+ expect(fx.fallbacks).toHaveLength(0)
93
+ expect(fx.emitted).toHaveLength(0)
94
+ // Compaction ends: the compact_boundary handler clears the marker AND
95
+ // counts the boundary as production (fresh window for the resumed turn).
96
+ compacting = false
97
+ silencePoke.noteProduction('chat:1', 505_000)
98
+ __tickForTests(510_000)
99
+ expect(fx.fallbacks).toHaveLength(0) // healthy turn: never fired
100
+ })
101
+
102
+ it('a genuinely-wedged turn with no compaction STILL fires at 300s', () => {
103
+ const fx = setupDeps({ isCompactionInFlight: () => false })
104
+ startTurn('chat:2', 0)
105
+ __tickForTests(300_000)
106
+ expect(fx.fallbacks).toHaveLength(1)
107
+ expect(fx.emitted.at(-1)).toMatchObject({ kind: 'silence_fallback_sent' })
108
+ })
109
+
110
+ it('a turn that goes silent AFTER compaction ends still fires one window later', () => {
111
+ let compacting = true
112
+ const fx = setupDeps({ isCompactionInFlight: () => compacting })
113
+ startTurn('chat:3', 0)
114
+ __tickForTests(310_000)
115
+ expect(fx.fallbacks).toHaveLength(0) // deferred while compacting
116
+ compacting = false
117
+ silencePoke.noteProduction('chat:3', 320_000) // compact_boundary landed
118
+ __tickForTests(325_000)
119
+ expect(fx.fallbacks).toHaveLength(0)
120
+ // …but the model never resumes → real wedge → fires 300s after boundary.
121
+ __tickForTests(620_000)
122
+ expect(fx.fallbacks).toHaveLength(1)
123
+ })
124
+
125
+ it('a compaction stuck past the hard ceiling STILL fires (bounded defer)', () => {
126
+ const fx = setupDeps({ isCompactionInFlight: () => true })
127
+ startTurn('chat:4', 0)
128
+ __tickForTests(899_000)
129
+ expect(fx.fallbacks).toHaveLength(0) // deferred under the ceiling
130
+ __tickForTests(900_000) // silence ≥ fallbackHardCeiling → defer expires
131
+ expect(fx.fallbacks).toHaveLength(1)
132
+ })
133
+
134
+ it('absent dep (legacy fixtures) leaves behaviour unchanged — fires at 300s', () => {
135
+ const fx = setupDeps()
136
+ startTurn('chat:5', 0)
137
+ __tickForTests(300_000)
138
+ expect(fx.fallbacks).toHaveLength(1)
139
+ })
140
+ })
141
+
142
+ describe('session-tail — compact_boundary projection', () => {
143
+ it('projects a real transcript compact_boundary line', () => {
144
+ // Verbatim shape from a live agent transcript (trimmed to the fields the
145
+ // projection reads).
146
+ const line = JSON.stringify({
147
+ parentUuid: null,
148
+ isSidechain: false,
149
+ type: 'system',
150
+ subtype: 'compact_boundary',
151
+ content: 'Conversation compacted',
152
+ level: 'info',
153
+ compactMetadata: { trigger: 'auto', preTokens: 283797, postTokens: 224551, durationMs: 111959 },
154
+ })
155
+ expect(projectTranscriptLine(line)).toEqual([
156
+ { kind: 'compact_boundary', trigger: 'auto', compactDurationMs: 111959 },
157
+ ])
158
+ })
159
+
160
+ it('tolerates missing compactMetadata', () => {
161
+ const line = JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted' })
162
+ expect(projectTranscriptLine(line)).toEqual([
163
+ { kind: 'compact_boundary', trigger: null, compactDurationMs: null },
164
+ ])
165
+ })
166
+ })
167
+
168
+ describe('compaction-marker + session-event wiring', () => {
169
+ let dir: string
170
+ const envBefore = process.env.TELEGRAM_STATE_DIR
171
+
172
+ beforeEach(() => {
173
+ dir = mkdtempSync(join(tmpdir(), 'sp-compact-'))
174
+ process.env.TELEGRAM_STATE_DIR = dir
175
+ })
176
+
177
+ afterEach(() => {
178
+ if (envBefore != null) process.env.TELEGRAM_STATE_DIR = envBefore
179
+ else delete process.env.TELEGRAM_STATE_DIR
180
+ rmSync(dir, { recursive: true, force: true })
181
+ })
182
+
183
+ it('readCompactionMarkerAgeMs: absent → null; present → mtime age', () => {
184
+ expect(readCompactionMarkerAgeMs(dir, 1_000)).toBeNull()
185
+ const p = join(dir, COMPACTION_MARKER_FILE)
186
+ writeFileSync(p, '{"ts":1}\n')
187
+ const at = new Date(Date.now() - 10_000)
188
+ utimesSync(p, at, at)
189
+ const age = readCompactionMarkerAgeMs(dir)
190
+ expect(age).not.toBeNull()
191
+ expect(age!).toBeGreaterThanOrEqual(9_000)
192
+ expect(age!).toBeLessThan(60_000)
193
+ removeCompactionMarker(dir)
194
+ expect(readCompactionMarkerAgeMs(dir)).toBeNull()
195
+ removeCompactionMarker(dir) // idempotent
196
+ })
197
+
198
+ it('compact_boundary session event clears the marker and resets the silence clock', () => {
199
+ writeFileSync(join(dir, COMPACTION_MARKER_FILE), '{"ts":1}\n')
200
+ startTurn('c:9', 0)
201
+ setupDeps()
202
+ applySilencePokeSessionEvent(silencePoke, pendingProgress, 'c:9', {
203
+ kind: 'compact_boundary',
204
+ trigger: 'auto',
205
+ compactDurationMs: 111_959,
206
+ })
207
+ expect(existsSync(join(dir, COMPACTION_MARKER_FILE))).toBe(false)
208
+ // Clock reset: lastOutboundAt stamped by noteProduction.
209
+ expect(__getStateForTests('c:9')?.lastOutboundAt).not.toBeNull()
210
+ })
211
+
212
+ it('PreCompact hook writes the marker from its stdin payload', () => {
213
+ const hook = join(__dirname, '..', 'hooks', 'compaction-marker-precompact.mjs')
214
+ execFileSync('node', [hook], {
215
+ env: { ...process.env, TELEGRAM_STATE_DIR: dir },
216
+ input: JSON.stringify({ session_id: 's-1', trigger: 'auto', hook_event_name: 'PreCompact' }),
217
+ })
218
+ const age = readCompactionMarkerAgeMs(dir)
219
+ expect(age).not.toBeNull()
220
+ expect(age!).toBeLessThan(30_000)
221
+ })
222
+ })