switchroom 0.19.38 → 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 (45) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +15 -6
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +1141 -566
  5. package/dist/host-control/main.js +220 -8
  6. package/dist/vault/approvals/kernel-server.js +15 -6
  7. package/dist/vault/broker/server.js +15 -6
  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 +450 -206
  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/handback-preturn-signal.ts +16 -0
  17. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  18. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  19. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  20. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  21. package/telegram-plugin/gateway/stream-render.ts +277 -18
  22. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  23. package/telegram-plugin/hooks/hooks.json +11 -0
  24. package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
  25. package/telegram-plugin/session-tail.ts +20 -0
  26. package/telegram-plugin/silence-poke.ts +28 -0
  27. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
  28. package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
  29. package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
  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/queued-card-surface.test.ts +262 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  37. package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
  38. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  39. package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
  40. package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +19 -15
  42. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
  43. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
  45. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
@@ -158,6 +158,12 @@ export type SessionEvent =
158
158
  * ~300s wedge recovery once the launching bash is no longer running.
159
159
  */
160
160
  | { kind: 'task_notification'; taskId: string; status: string }
161
+ // #4058 — system compact_boundary: the CLI finished compacting the session
162
+ // mid-turn (the record is written at compaction END — it embeds the
163
+ // compaction's own durationMs). Consumed by silence-poke wiring to clear
164
+ // the PreCompact "compaction in flight" marker and count the boundary as
165
+ // production so the resumed turn gets a fresh silence window.
166
+ | { kind: 'compact_boundary'; trigger: string | null; compactDurationMs: number | null }
161
167
  // `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
162
168
  // the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
163
169
  // deterministic quiescence-flush signal, which — unlike the orphaned-reply
@@ -702,6 +708,20 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
702
708
  ]
703
709
  }
704
710
 
711
+ // #4058 — system compact_boundary: written when a mid-turn (auto or manual)
712
+ // compaction FINISHES. Real shape observed in live transcripts:
713
+ // { type:"system", subtype:"compact_boundary", content:"Conversation
714
+ // compacted", compactMetadata:{ trigger:"auto", preTokens, postTokens,
715
+ // durationMs, ... } }
716
+ if (type === 'system' && obj.subtype === 'compact_boundary') {
717
+ const meta = obj.compactMetadata as { trigger?: unknown; durationMs?: unknown } | undefined
718
+ return [{
719
+ kind: 'compact_boundary',
720
+ trigger: typeof meta?.trigger === 'string' ? meta.trigger : null,
721
+ compactDurationMs: typeof meta?.durationMs === 'number' ? meta.durationMs : null,
722
+ }]
723
+ }
724
+
705
725
  return []
706
726
  }
707
727
 
@@ -265,6 +265,25 @@ export interface SilencePokeDeps {
265
265
  * exactly as before.
266
266
  */
267
267
  isTurnLive?: (key: string) => boolean
268
+ /**
269
+ * #4058 — mid-turn auto-compaction defer predicate. Returns true while the
270
+ * Claude CLI is compacting the session for `key`'s turn (the PreCompact hook
271
+ * wrote the compaction marker and the `compact_boundary` transcript record
272
+ * hasn't landed / the marker isn't stale — see gateway/compaction-marker.ts).
273
+ *
274
+ * Why: during compaction the model emits ZERO output and runs ZERO tools
275
+ * for minutes, so every other work signal (`isLegitimatelyWorking`,
276
+ * in-flight tools, alive shells) is false and the 300s fallback fired on a
277
+ * healthy turn — a spurious "framework ended that stalled turn" card + a
278
+ * harmless re-ask, recurring on any session near the context ceiling.
279
+ *
280
+ * Semantics mirror the #1292/#3519 defers exactly: the silence CLOCK is
281
+ * never reset here; the terminal unwedge is DEFERRED (`continue` without
282
+ * setting fallbackFired) and remains bounded by `fallbackHardCeiling`, so
283
+ * a genuinely-wedged compaction still unwedges at the ceiling. Optional:
284
+ * absent (legacy fixtures) ⇒ behaviour unchanged.
285
+ */
286
+ isCompactionInFlight?: (key: string) => boolean
268
287
  }
269
288
 
270
289
  const state = new Map<string, SilencePokeState>()
@@ -773,6 +792,15 @@ function tick(now: number): void {
773
792
  const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
774
793
  const underCeiling = silence < ceiling
775
794
  if (underCeiling) {
795
+ // #4058 — mid-turn auto-compaction: the CLI is summarizing the
796
+ // session, so the model provably CANNOT produce output or tool
797
+ // events; the silence is healthy, not a wedge. Defer exactly like
798
+ // the in-flight-tool paths below (clock untouched, fallbackFired
799
+ // unset, re-checked next tick) and stay bounded by the same hard
800
+ // ceiling via the enclosing `underCeiling` guard. Deliberately NOT
801
+ // gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
802
+ // scopes the TOOL defers; compaction has no tool in flight.
803
+ if (activeDeps.isCompactionInFlight?.(key) === true) continue
776
804
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
777
805
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
778
806
  if (activeDeps.isLegitimatelyWorking(key)) continue
@@ -11,13 +11,17 @@
11
11
  *
12
12
  * Load-bearing constraints:
13
13
  * 1. `activityEverOpened = true` is set only at legitimate feed-OPEN signal
14
- * sites in gateway.ts — the send-message success site in
15
- * drainActivitySummary, AND the sub-agent-handback pre-turn ADOPTION site
16
- * (#3268): an adopted turn inherits an already-open pre-turn card via a
17
- * seeded `activityMessageId`, so it only ever EDITs the feed (never hits
18
- * the open branch), and must stamp the flag itself so the turn-end
19
- * DEGRADED check doesn't false-flag it as "feed never opened". Both are
20
- * set-TRUE (never a reset), preserving the sticky-true invariant.
14
+ * sites — the send-message success site in drainActivitySummary, the
15
+ * sub-agent-handback pre-turn ADOPTION site (#3268), AND the queued-card
16
+ * ADOPTION site (#3927 Part B). The two adoption sites are the same shape:
17
+ * an adopted turn inherits an ALREADY-OPEN, user-visible card via a seeded
18
+ * `activityMessageId`, so it only ever EDITs the feed (never hits the open
19
+ * branch), and must stamp the flag itself so the turn-end DEGRADED check
20
+ * doesn't false-flag it as "feed never opened". A parked message's "⏳
21
+ * Queued" card is a real message the user already sees before the turn
22
+ * mints, so adopting it on dequeue IS a feed-open — same rationale as the
23
+ * handback adoption. All three are set-TRUE (never a reset), preserving the
24
+ * sticky-true invariant.
21
25
  * 2. `turn.activityEverOpened = false` NEVER appears in gateway.ts (it is only
22
26
  * initialised to `false` in the turn-initialiser object literal, never reset
23
27
  * via a standalone assignment).
@@ -49,13 +53,16 @@ const laneSrc = readFileSync(
49
53
  const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc
50
54
 
51
55
  describe('M-2: activityEverOpened sticky-true invariant', () => {
52
- it('activityEverOpened = true appears only at the two feed-OPEN signal sites', () => {
56
+ it('activityEverOpened = true appears only at the three feed-OPEN signal sites', () => {
53
57
  // Site 1: drainActivitySummary send-message success. Site 2: the #3268
54
- // handback pre-turn ADOPTION seed (an adopted turn only edits, so it stamps
55
- // the flag itself). Both are set-TRUE; the sticky invariant (no reset to
56
- // false) is enforced by the next test.
58
+ // handback pre-turn ADOPTION seed. Site 3: the #3927 Part B queued-card
59
+ // ADOPTION seed (stream-render.ts) a parked message's "⏳ Queued" card is
60
+ // an already-open, user-visible card the dequeued turn adopts and only
61
+ // EDITs, so it stamps the flag itself, exactly like the handback adoption.
62
+ // All three are set-TRUE; the sticky invariant (no reset to false) is
63
+ // enforced by the next test.
57
64
  const setTrueMatches = [...gatewayAndStreamSrc.matchAll(/activityEverOpened\s*=\s*true/g)]
58
- expect(setTrueMatches).toHaveLength(2)
65
+ expect(setTrueMatches).toHaveLength(3)
59
66
  })
60
67
 
61
68
  it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
@@ -0,0 +1,15 @@
1
+ {
2
+ "session_id": "e2c9a4f0-1b3d-4c8a-9f21-parent000000",
3
+ "transcript_path": "/home/agent/.claude/projects/-home-agent-work/e2c9a4f0-1b3d-4c8a-9f21-parent000000.jsonl",
4
+ "cwd": "/home/agent/work",
5
+ "permission_mode": "default",
6
+ "agent_id": "e2c9a4f0-1b3d-4c8a-9f21-parent000000",
7
+ "agent_type": "main",
8
+ "effort": "medium",
9
+ "hook_event_name": "PreToolUse",
10
+ "tool_name": "Read",
11
+ "tool_input": {
12
+ "file_path": "/home/agent/work/src/index.ts"
13
+ },
14
+ "tool_use_id": "toolu_01MainReadCall4z"
15
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "session_id": "e2c9a4f0-1b3d-4c8a-9f21-parent000000",
3
+ "transcript_path": "/home/agent/.claude/projects/-home-agent-work/e2c9a4f0-1b3d-4c8a-9f21-parent000000.jsonl",
4
+ "cwd": "/home/agent/work",
5
+ "permission_mode": "default",
6
+ "agent_id": "7a1b9c3d-4e5f-6071-8293-subagentownid0",
7
+ "agent_type": "general-purpose",
8
+ "effort": "medium",
9
+ "hook_event_name": "PreToolUse",
10
+ "tool_name": "Bash",
11
+ "tool_input": {
12
+ "command": "npm run build",
13
+ "description": "Build the worker output"
14
+ },
15
+ "tool_use_id": "toolu_01SubAgentBashCall9x"
16
+ }
@@ -119,7 +119,8 @@ describe('sweepOutbox — end to end (exactly-once, siblings, dedup)', () => {
119
119
  sent,
120
120
  send: async (chatId: string, threadId: number | null, text: string) => {
121
121
  sent.push({ chatId, threadId, text })
122
- return sent.length
122
+ const id = sent.length
123
+ return { messageId: id, chunks: [{ messageId: id, text }] }
123
124
  },
124
125
  }
125
126
  }
@@ -136,6 +137,42 @@ describe('sweepOutbox — end to end (exactly-once, siblings, dedup)', () => {
136
137
  expect(listPendingRecords(dir)).toHaveLength(0)
137
138
  })
138
139
 
140
+ it('persist parity: recordOutbound fires ONCE with the delivered message_id(s) + text after a successful sweep', async () => {
141
+ // The safety-net delivery must land in history.db, exactly like the reply
142
+ // path and the turn-flush backstop. Pre-fix the sweep never called
143
+ // recordOutbound, so every net-delivered handback was silently absent from
144
+ // history — this asserts the recorder fires with the ACTUAL delivered ids +
145
+ // text, and exactly once across repeated (idempotent) sweeps.
146
+ writeOutboxRecordAtomic(rec({ turnNonce: 'p1', chatId: '444', text: 'the recovered final answer', createdAt: 0 }), dir)
147
+ const s = sink()
148
+ const recorded: Array<{ chatId: string; threadId: number | null; messageIds: number[]; texts: string[] }> = []
149
+ const deps = {
150
+ ...s,
151
+ recordOutbound: (chatId: string, threadId: number | null, messageIds: number[], texts: string[]) => {
152
+ recorded.push({ chatId, threadId, messageIds, texts })
153
+ },
154
+ textAlreadyDelivered: () => false,
155
+ stateDir: dir,
156
+ now: () => 10_000,
157
+ }
158
+ await sweepOutbox(deps)
159
+ await sweepOutbox(deps) // journal suppresses — recorder must NOT fire again
160
+ await sweepOutbox(deps)
161
+ expect(s.sent).toHaveLength(1)
162
+ expect(recorded).toHaveLength(1)
163
+ expect(recorded[0].chatId).toBe('444')
164
+ expect(recorded[0].messageIds).toEqual([1])
165
+ expect(recorded[0].texts).toEqual(['the recovered final answer'])
166
+ })
167
+
168
+ it('persist parity: a missing recordOutbound never throws (safety-net stays intact)', async () => {
169
+ // The recorder is optional; a sweep with no recorder wired must still deliver.
170
+ writeOutboxRecordAtomic(rec({ turnNonce: 'p2', chatId: '444', text: 'no recorder wired', createdAt: 0 }), dir)
171
+ const s = sink()
172
+ await sweepOutbox({ ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 })
173
+ expect(s.sent).toHaveLength(1)
174
+ })
175
+
139
176
  it('two concurrent sibling handbacks with distinct nonces both deliver, no clobber', async () => {
140
177
  // Same content, distinct enqueue timestamps → distinct nonces (H2).
141
178
  const n1 = deriveTurnNonce({ chatId: null, threadId: null, messageId: null, anchorTimestampMs: 1000, anchorContent: 'sib' })
@@ -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
  })