switchroom 0.19.39 → 0.19.41

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 (37) 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 +562 -196
  5. package/dist/host-control/main.js +287 -20
  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/bridge/bridge.ts +2 -2
  11. package/telegram-plugin/dist/bridge/bridge.js +10 -2
  12. package/telegram-plugin/dist/gateway/gateway.js +549 -232
  13. package/telegram-plugin/dist/server.js +10 -2
  14. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  15. package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
  16. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  17. package/telegram-plugin/gateway/gateway.ts +72 -72
  18. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  19. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  20. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  21. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  22. package/telegram-plugin/gateway/stream-render.ts +39 -1
  23. package/telegram-plugin/gateway/turn-record-status.ts +80 -0
  24. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  25. package/telegram-plugin/hooks/hooks.json +11 -0
  26. package/telegram-plugin/session-tail.ts +20 -0
  27. package/telegram-plugin/silence-poke.ts +28 -0
  28. package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
  29. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  36. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  37. package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * outbox-flush-ack-claim-race.test.ts — outcome regression for the turn-flush
3
+ * vs outbox-sweep DUPLICATE-MESSAGE race.
4
+ *
5
+ * The race (root-caused via journal forensics):
6
+ * 1. A gateway-visible turn ends with unsent trailing prose → the turn-flush
7
+ * backstop delivers it through `runBackstopDelivery` (the flush core). The
8
+ * chunks land (Telegram acks) immediately.
9
+ * 2. The flush's DURABLE exactly-once claim (`journalExternalDelivery`,
10
+ * `deliverySource:'flush'`) used to run only in the caller's
11
+ * post-`await deliverAnswer` bookkeeping — i.e. AFTER `runBackstopDelivery`
12
+ * returned. But `runBackstopDelivery` does not return until its read-back
13
+ * probe resolves, and that probe is issued at COSMETIC priority: when the
14
+ * edit-flood-fuse is deferring cosmetic edits it blocks ~30s.
15
+ * 3. The out-of-band outbox sweep waits only `OUTBOX_QUIET_MS` (5s) before
16
+ * checking the delivered-keys journal. In the 5–30s gap it saw no journal
17
+ * entry (deferred behind the probe) and — because the Stop-hook-captured
18
+ * record's text differs in length/hash from the flush text, so the in-memory
19
+ * text dedup also misses — it sent a SECOND copy of the same answer.
20
+ *
21
+ * The fix claims the nonce at SEND-ACK time (`onAckClaim`), BEFORE the read-back
22
+ * probe. This test drives the REAL `runBackstopDelivery` with a read-back probe
23
+ * held open PAST the sweep's quiet window, plus a pending outbox record for the
24
+ * SAME nonce with DIFFERENT text, and asserts the REAL `sweepOutbox` delivers
25
+ * NOTHING. It is RED on pre-fix main (no ack-time claim → the journal is empty
26
+ * when the sweep ticks → the sweep sends the duplicate).
27
+ *
28
+ * The oracle is what the user observably received (sweep `send` invocations) and
29
+ * the durable journal — no assertion names an internal decision function.
30
+ */
31
+
32
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
33
+ import { mkdtempSync, rmSync } from 'node:fs'
34
+ import { tmpdir } from 'node:os'
35
+ import { join } from 'node:path'
36
+
37
+ import {
38
+ BackstopDeliveryLedger,
39
+ runBackstopDelivery,
40
+ type ReadBackResult,
41
+ } from '../gateway/backstop-delivery.js'
42
+ import { sweepOutbox, journalExternalDelivery } from '../gateway/outbox-sweep.js'
43
+ import {
44
+ OUTBOX_QUIET_MS,
45
+ readDeliveredNonces,
46
+ sha256Hex,
47
+ writeOutboxRecordAtomic,
48
+ type OutboxRecord,
49
+ } from '../outbox.js'
50
+
51
+ const CHAT = '111'
52
+ /** Gateway `deriveTurnId` shape — byte-identical to the Stop-hook record nonce. */
53
+ const NONCE = `${CHAT}:_#42`
54
+ /** The flush's answer (RICH). */
55
+ const FLUSH_TEXT = 'The deploy is green across all three checks. '.padEnd(320, 'a')
56
+ /** The Stop-hook-captured record for the SAME turn — a paraphrase, so its length
57
+ * and sha differ from the flush text and the in-memory text dedup CANNOT match. */
58
+ const RECORD_TEXT =
59
+ 'Summary of the above in different words so byte-exact dedup can never catch it. '.padEnd(
60
+ 420,
61
+ 'b',
62
+ )
63
+
64
+ /** A pending outbox record, aged PAST the sweep quiet window so it is eligible. */
65
+ function writeAgedRecord(dir: string, createdAt: number): void {
66
+ const record: OutboxRecord = {
67
+ turnNonce: NONCE,
68
+ chatId: CHAT,
69
+ threadId: null,
70
+ text: RECORD_TEXT,
71
+ textSha256: sha256Hex(RECORD_TEXT),
72
+ createdAt,
73
+ source: 'channel',
74
+ replyAlreadyDeliveredThisTurn: false,
75
+ }
76
+ const ok = writeOutboxRecordAtomic(record, dir)
77
+ expect(ok).toBe(true)
78
+ }
79
+
80
+ describe('turn-flush vs outbox-sweep — the flush claims its nonce at SEND-ACK, before a slow read-back', () => {
81
+ let dir: string
82
+ beforeEach(() => {
83
+ dir = mkdtempSync(join(tmpdir(), 'flush-ack-race-'))
84
+ })
85
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
86
+
87
+ it('a read-back slower than OUTBOX_QUIET_MS + a same-nonce/different-text record ⇒ the sweep sends NOTHING (exactly-once)', async () => {
88
+ const now = 10_000_000
89
+ // The captured record was written well before the quiet window elapsed.
90
+ writeAgedRecord(dir, now - (OUTBOX_QUIET_MS + 5_000))
91
+
92
+ // Read-back gate — held OPEN so runBackstopDelivery cannot return until we
93
+ // release it, simulating the cosmetic-edit flood-fuse deferring the probe
94
+ // ~30s (far longer than the sweep's 5s quiet window).
95
+ let releaseReadBack!: () => void
96
+ const readBackGate = new Promise<void>((resolve) => {
97
+ releaseReadBack = resolve
98
+ })
99
+
100
+ const ledger = new BackstopDeliveryLedger()
101
+ ledger.claim(NONCE)
102
+
103
+ const sentChunks: string[] = []
104
+ const flushPromise = runBackstopDelivery(
105
+ ledger,
106
+ NONCE,
107
+ [FLUSH_TEXT],
108
+ null,
109
+ {
110
+ sendChunk: async (_i, text) => {
111
+ sentChunks.push(text)
112
+ return [777] // Telegram acked — a fresh non-card chat id.
113
+ },
114
+ // Mirrors the real deliverAnswer read-back: paced cosmetic, deferrable.
115
+ readBack: async (): Promise<ReadBackResult> => {
116
+ await readBackGate
117
+ return 'exists'
118
+ },
119
+ // The fix: journal the nonce at ack, BEFORE the read-back above resolves.
120
+ onAckClaim: (ackIds) => {
121
+ journalExternalDelivery(
122
+ {
123
+ turnNonce: NONCE,
124
+ text: FLUSH_TEXT,
125
+ tgMessageId: ackIds[0],
126
+ deliverySource: 'flush',
127
+ },
128
+ dir,
129
+ )
130
+ },
131
+ },
132
+ 3,
133
+ )
134
+
135
+ // Let the send loop run its send + (with the fix) the ack claim, then park
136
+ // on the still-open read-back probe. Bounded macrotask flushes — no timers,
137
+ // no dependence on the fix, so a missing claim surfaces as a clean assertion
138
+ // failure below rather than a hang.
139
+ for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r))
140
+ expect(sentChunks).toEqual([FLUSH_TEXT])
141
+ // Durable claim landed at ack-time — WHILE the read-back is still open.
142
+ expect(readDeliveredNonces(dir).has(NONCE)).toBe(true)
143
+
144
+ // Now the sweep ticks, past the quiet window, with the text dedup MISSING
145
+ // (record text ≠ flush text). Its only exactly-once guard is the journal.
146
+ const swept: string[] = []
147
+ const summary = await sweepOutbox({
148
+ stateDir: dir,
149
+ now: () => now,
150
+ send: async (_chatId, _threadId, text) => {
151
+ swept.push(text)
152
+ return { messageId: 500, chunks: [{ messageId: 500, text }] }
153
+ },
154
+ // The exact-text in-memory dedup cannot help — the texts differ.
155
+ textAlreadyDelivered: () => false,
156
+ })
157
+
158
+ // THE user outcome: the sweep sent nothing — the flush already owns this turn.
159
+ expect(swept).toEqual([])
160
+ expect(summary.delivered).toBe(0)
161
+
162
+ // Release the (slow) read-back and let the flush finish cleanly.
163
+ releaseReadBack()
164
+ const result = await flushPromise
165
+ expect(result.delivered).toBe(true)
166
+ })
167
+
168
+ it('no-loss guard: a flush whose send never acks does NOT claim, so the sweep still delivers the captured answer once', async () => {
169
+ const now = 20_000_000
170
+ writeAgedRecord(dir, now - (OUTBOX_QUIET_MS + 5_000))
171
+
172
+ const ledger = new BackstopDeliveryLedger()
173
+ ledger.claim(NONCE)
174
+
175
+ let ackClaimCount = 0
176
+ const result = await runBackstopDelivery(
177
+ ledger,
178
+ NONCE,
179
+ [FLUSH_TEXT],
180
+ null,
181
+ {
182
+ // Every attempt throws — the answer never lands, so the claim predicate
183
+ // (all chunks landed + fresh receipt) is never met.
184
+ sendChunk: async () => {
185
+ throw new Error('telegram send rejected')
186
+ },
187
+ readBack: async (): Promise<ReadBackResult> => 'exists',
188
+ onAckClaim: () => {
189
+ ackClaimCount++
190
+ },
191
+ },
192
+ 3,
193
+ )
194
+ // The flush genuinely failed and never claimed the nonce.
195
+ expect(result.delivered).toBe(false)
196
+ expect(ackClaimCount).toBe(0)
197
+ expect(readDeliveredNonces(dir).has(NONCE)).toBe(false)
198
+
199
+ // The safety net now delivers the captured answer — exactly once, no loss.
200
+ const swept: string[] = []
201
+ const summary = await sweepOutbox({
202
+ stateDir: dir,
203
+ now: () => now,
204
+ send: async (_chatId, _threadId, text) => {
205
+ swept.push(text)
206
+ return { messageId: 501, chunks: [{ messageId: 501, text }] }
207
+ },
208
+ textAlreadyDelivered: () => false,
209
+ })
210
+ expect(swept).toEqual([RECORD_TEXT])
211
+ expect(summary.delivered).toBe(1)
212
+ })
213
+ })
@@ -268,7 +268,7 @@ async function runTurn(opts: {
268
268
  send: async (_chatId, _threadId, text) => {
269
269
  delivered.push({ via: 'sweep', text })
270
270
  sentLog.push({ text, at: now })
271
- return 500
271
+ return { messageId: 500, chunks: [{ messageId: 500, text }] }
272
272
  },
273
273
  // Mirrors the gateway's TTL-bounded exact-text `outboundDedup` cache:
274
274
  // anything sent (reply or sweep) within the TTL dedups; older evicts.
@@ -61,7 +61,7 @@ describe('outbox sweep vs an open flood window', () => {
61
61
 
62
62
  it('does not hit the wire while a flood window is open, and keeps the record', async () => {
63
63
  writeOutboxRecordAtomic(rec(), dir)
64
- const send = vi.fn(async () => 1)
64
+ const send = vi.fn(async () => ({ messageId: 1, chunks: [{ messageId: 1, text: rec().text }] }))
65
65
  const log = vi.fn()
66
66
 
67
67
  const summary = await sweepOutbox({
@@ -90,7 +90,7 @@ describe('outbox sweep vs an open flood window', () => {
90
90
 
91
91
  it('delivers the SAME record once the window closes', async () => {
92
92
  writeOutboxRecordAtomic(rec(), dir)
93
- const send = vi.fn(async () => 42)
93
+ const send = vi.fn(async () => ({ messageId: 42, chunks: [{ messageId: 42, text: rec().text }] }))
94
94
  let remaining = 12_247_000
95
95
 
96
96
  const deferred = await sweepOutbox({
@@ -120,7 +120,7 @@ describe('outbox sweep vs an open flood window', () => {
120
120
 
121
121
  it('sweeps normally when no window is open', async () => {
122
122
  writeOutboxRecordAtomic(rec(), dir)
123
- const send = vi.fn(async () => 7)
123
+ const send = vi.fn(async () => ({ messageId: 7, chunks: [{ messageId: 7, text: rec().text }] }))
124
124
  const summary = await sweepOutbox({
125
125
  stateDir: dir,
126
126
  send,
@@ -135,7 +135,7 @@ describe('outbox sweep vs an open flood window', () => {
135
135
 
136
136
  it('FAILS OPEN: a throwing probe must never strand the outbox', async () => {
137
137
  writeOutboxRecordAtomic(rec(), dir)
138
- const send = vi.fn(async () => 7)
138
+ const send = vi.fn(async () => ({ messageId: 7, chunks: [{ messageId: 7, text: rec().text }] }))
139
139
  const summary = await sweepOutbox({
140
140
  stateDir: dir,
141
141
  send,
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { describe, it, expect } from 'vitest'
23
+ import { GrammyError } from 'grammy'
23
24
  import { createOutboxSend } from '../gateway/outbox-sweep.js'
24
25
  import { makeOutboxListenMarkupResolver } from '../gateway/outbox-listen-markup.js'
25
26
  import {
@@ -38,14 +39,37 @@ const kokoroOnDemand: ListenButtonVoiceOutPlan = {
38
39
  // fallback needed for the unit under test).
39
40
  const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
40
41
 
41
- function fakeBot() {
42
+ // The sweep now renders via sendRichMessage (raw GFM markdown → entities), the
43
+ // SAME path the reply path uses; a markdown parse-reject falls back to plain
44
+ // sendMessage. `calls` records the CANONICAL rich sends (with the raw markdown
45
+ // pulled out of the `{ markdown }` body as `text`) so the existing assertions
46
+ // keep asserting the bytes handed to the wire; `plainCalls` records the
47
+ // parse-reject fallback sends.
48
+ function fakeBot(opts?: { failRich?: boolean }) {
42
49
  const calls: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
50
+ const plainCalls: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
43
51
  let n = 100
44
52
  return {
45
53
  calls,
54
+ plainCalls,
46
55
  api: {
47
- sendMessage: async (chatId: string, text: string, opts: object) => {
48
- calls.push({ chatId, text, opts: opts as Record<string, unknown> })
56
+ sendRichMessage: async (chatId: string, body: { markdown: string }, o: object) => {
57
+ if (opts?.failRich) {
58
+ // A real Telegram markdown parse-reject (isParseEntitiesError keys on
59
+ // `instanceof GrammyError` + the description), so the sweep falls back
60
+ // to a plain sendMessage of the same chunk.
61
+ throw new GrammyError(
62
+ 'Call to sendRichMessage failed!',
63
+ { ok: false, error_code: 400, description: "Bad Request: can't parse entities" },
64
+ 'sendRichMessage',
65
+ {} as never,
66
+ )
67
+ }
68
+ calls.push({ chatId, text: body.markdown, opts: o as Record<string, unknown> })
69
+ return { message_id: n++ }
70
+ },
71
+ sendMessage: async (chatId: string, text: string, o: object) => {
72
+ plainCalls.push({ chatId, text, opts: o as Record<string, unknown> })
49
73
  return { message_id: n++ }
50
74
  },
51
75
  },
@@ -99,6 +123,44 @@ describe('planListenButton — shared Listen-button decision', () => {
99
123
  })
100
124
  })
101
125
 
126
+ describe('createOutboxSend — RENDER PARITY (markdown is rendered, not raw)', () => {
127
+ it('sends through sendRichMessage with the markdown body, NOT a raw sendMessage', async () => {
128
+ // The bug: the sweep sent via a plain bot.api.sendMessage with no parse_mode
129
+ // and no rich render, so `**bold**` reached the operator verbatim. The fix
130
+ // routes each chunk through richMessage → sendRichMessage. This asserts the
131
+ // rendered payload (a `{ markdown }` body on the rich API), which the pre-fix
132
+ // raw-sendMessage path never produced.
133
+ const bot = fakeBot()
134
+ const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry })
135
+
136
+ const result = await send('123', null, 'Here is **bold** and `code`.')
137
+
138
+ // Rendered via the rich path (markdown preserved for Telegram to parse)…
139
+ expect(bot.calls).toHaveLength(1)
140
+ expect(bot.calls[0]!.text).toBe('Here is **bold** and `code`.')
141
+ // …and NOT via the raw plain-text path (the pre-fix bug).
142
+ expect(bot.plainCalls).toHaveLength(0)
143
+ // The landed chunk (id + text) is reported back for history persistence.
144
+ expect(result.chunks).toEqual([{ messageId: 100, text: 'Here is **bold** and `code`.' }])
145
+ expect(result.messageId).toBe(100)
146
+ })
147
+
148
+ it('falls back to a PLAIN sendMessage of the same chunk on a markdown parse-reject', async () => {
149
+ // Mirrors the reply path's plaintext fallback: if Telegram rejects the
150
+ // markdown entities, resend the raw source as plain text (readable prose)
151
+ // rather than dropping the answer.
152
+ const bot = fakeBot({ failRich: true })
153
+ const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry })
154
+
155
+ const result = await send('123', null, 'weird **[unbalanced markdown')
156
+
157
+ expect(bot.calls).toHaveLength(0) // rich rejected
158
+ expect(bot.plainCalls).toHaveLength(1) // plain fallback delivered
159
+ expect(bot.plainCalls[0]!.text).toBe('weird **[unbalanced markdown')
160
+ expect(result.chunks).toHaveLength(1) // still reported for history
161
+ })
162
+ })
163
+
102
164
  describe('createOutboxSend — net-delivered answer carries the Listen button (#3502)', () => {
103
165
  it('attaches the Listen keyboard as reply_markup when voice-out on-demand is enabled', async () => {
104
166
  const bot = fakeBot()
@@ -128,8 +190,8 @@ describe('createOutboxSend — net-delivered answer carries the Listen button (#
128
190
  resolveReplyMarkup: () => markup,
129
191
  })
130
192
 
131
- // > 4000 chars → two chunks.
132
- await send('123', null, 'x'.repeat(4500))
193
+ // > RICH_MESSAGE_MAX_CHARS (32768) of a single unbreakable token → two chunks.
194
+ await send('123', null, 'x'.repeat(40000))
133
195
 
134
196
  expect(bot.calls).toHaveLength(2)
135
197
  expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
@@ -162,10 +224,10 @@ describe('createOutboxSend — net-delivered answer carries the Listen button (#
162
224
  expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
163
225
  })
164
226
 
165
- it('empty text sends NOTHING and returns undefined (no empty-message retry wedge)', async () => {
227
+ it('empty text sends NOTHING and reports an empty result (no empty-message retry wedge)', async () => {
166
228
  // Telegram rejects an empty message body; a stray '' chunk would throw
167
229
  // every sweep tick and wedge the record in a permanent retry. The send
168
- // must short-circuit instead of calling sendMessage.
230
+ // must short-circuit instead of calling sendMessage/sendRichMessage.
169
231
  const bot = fakeBot()
170
232
  const resolverCalls: string[] = []
171
233
  const send = createOutboxSend({
@@ -179,8 +241,9 @@ describe('createOutboxSend — net-delivered answer carries the Listen button (#
179
241
 
180
242
  const result = await send('123', null, '')
181
243
 
182
- expect(result).toBeUndefined()
244
+ expect(result).toEqual({ messageId: undefined, chunks: [] })
183
245
  expect(bot.calls).toHaveLength(0)
246
+ expect(bot.plainCalls).toHaveLength(0)
184
247
  // No point resolving a Listen button for a body we never send.
185
248
  expect(resolverCalls).toEqual([])
186
249
  })
@@ -547,6 +547,53 @@ describe('deliverCapturedProse — silent-end recovery (routed into the send mod
547
547
  expect(h.calls).toHaveLength(0)
548
548
  })
549
549
 
550
+ it('PERSIST PARITY: the exhausted-boundary plain-text fallback records the recovered answer to history', async () => {
551
+ // #3228 exhausted-boundary: the rich send fails on the attempt where the
552
+ // re-prompt budget is already spent, so deliverCapturedProse delivers the
553
+ // real answer as PLAIN text. Pre-fix that plain-text delivery recorded only
554
+ // to the in-memory dedup and NEVER called recordOutbound — so the recovered
555
+ // answer reached the user but was silently absent from history.db. This
556
+ // asserts recordOutbound now fires once with the delivered message_id + the
557
+ // recovered text.
558
+ const dedup = new OutboundDedupCache()
559
+ // Rich send parse-rejects (one chunk → one queued failure); the plain
560
+ // sendMessage fallback succeeds.
561
+ const { api, calls } = makeFakeBot({ sendRichMessage: [grammy400("can't parse entities")] })
562
+ const recorded: Array<{ chat_id: string; thread_id: number | null; message_ids: number[]; texts: string[] }> = []
563
+ const text = 'The exhausted-boundary recovered answer.'
564
+ const deps: DeliverCapturedProseDeps = {
565
+ outboundDedup: dedup,
566
+ bot: { api } as unknown as DeliverCapturedProseDeps['bot'],
567
+ robustApiCall: (fn) => fn(),
568
+ redactOutboundText: (t) => redact(t),
569
+ recordOutbound: (rec) => recorded.push({ chat_id: rec.chat_id, thread_id: rec.thread_id, message_ids: rec.message_ids, texts: rec.texts }),
570
+ HISTORY_ENABLED: true,
571
+ OBLIGATION_LEDGER_ENABLED: true,
572
+ obligationLedger: { close: () => {} },
573
+ clearSilentEndState: () => {},
574
+ // Budget already spent → the fallback path fires.
575
+ recordUndeliveredTurnEnd: () => ({ exhausted: true }),
576
+ hasOutboundDeliveredSince: () => false,
577
+ }
578
+ await deliverCapturedProse(deps, {
579
+ chatId: CHAT,
580
+ threadId: undefined,
581
+ statusKeyStr: `${CHAT}:main`,
582
+ registryKey: null,
583
+ originTurnId: 'turn-45',
584
+ text,
585
+ })
586
+ // The plain-text fallback actually delivered.
587
+ const plainSends = calls.filter((c) => c.method === 'sendMessage')
588
+ expect(plainSends).toHaveLength(1)
589
+ expect(plainSends[0]!.text).toBe(text)
590
+ // …and it was persisted to history exactly once with the delivered id + text.
591
+ expect(recorded).toHaveLength(1)
592
+ expect(recorded[0]!.chat_id).toBe(CHAT)
593
+ expect(recorded[0]!.message_ids).toEqual([plainSends[0]!.message_id])
594
+ expect(recorded[0]!.texts).toEqual([text])
595
+ })
596
+
550
597
  it('already-deduped content settles bookkeeping WITHOUT a wire send', async () => {
551
598
  const dedup = new OutboundDedupCache()
552
599
  const text = 'Already went out earlier via the stream.'
@@ -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
+ })