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.
- package/dist/agent-scheduler/index.js +10 -1
- package/dist/auth-broker/index.js +12 -3
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +524 -189
- package/dist/host-control/main.js +217 -5
- package/dist/vault/approvals/kernel-server.js +12 -3
- package/dist/vault/broker/server.js +12 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/dist/bridge/bridge.js +8 -0
- package/telegram-plugin/dist/gateway/gateway.js +314 -191
- package/telegram-plugin/dist/server.js +8 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +3 -3
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +39 -1
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- 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
|
+
})
|