switchroom 0.19.14 → 0.19.16

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 (49) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +31 -2
  6. package/telegram-plugin/dist/gateway/gateway.js +1690 -932
  7. package/telegram-plugin/dist/server.js +31 -2
  8. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  9. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  10. package/telegram-plugin/gateway/gateway.ts +10 -57
  11. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  13. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  14. package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
  15. package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
  16. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  17. package/telegram-plugin/gateway/stream-render.ts +107 -15
  18. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  19. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  20. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  21. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  22. package/telegram-plugin/narrative-flush.ts +35 -0
  23. package/telegram-plugin/outbox.ts +73 -3
  24. package/telegram-plugin/session-tail.ts +88 -1
  25. package/telegram-plugin/shown-ledger.ts +145 -0
  26. package/telegram-plugin/silence-poke.ts +118 -1
  27. package/telegram-plugin/silent-end.ts +42 -0
  28. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  29. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  30. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  31. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  32. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  34. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  35. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  36. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  37. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  38. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  40. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  41. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  42. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  43. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  44. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  45. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  46. package/telegram-plugin/tts-normalize.ts +12 -0
  47. package/telegram-plugin/turn-flush-safety.ts +66 -53
  48. package/telegram-plugin/voice-normalize-text.ts +100 -0
  49. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * narration-leak-3513.test.ts — outcome-asserting regression suite for
3
+ * switchroom#3513 ("agent intent-narration leaks into the final Telegram reply
4
+ * as a real chat message").
5
+ *
6
+ * The single-surface invariant: every trailing plain-text block is assigned once
7
+ * to exactly one surface — delivered chat answer / ephemeral progress card /
8
+ * suppressed — by ONE shared classifier, and NO delivery path (E1 turn-flush,
9
+ * E2 quiescence, E3 captured-prose bridge, E4 outbox sweep) ever emits a block
10
+ * classified as structural narration; the one genuine unsent answer still
11
+ * delivers exactly once.
12
+ *
13
+ * Every test here asserts an OUTCOME (was the narration delivered? was the real
14
+ * answer delivered?), not merely that a code path ran, and each is written to be
15
+ * RED on the pre-fix code (which had two hand-synced regex classifiers and no
16
+ * structural/ledger guard).
17
+ */
18
+
19
+ import { describe, it, expect, vi } from 'vitest'
20
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+
24
+ import {
25
+ isStructuralNarration,
26
+ isNarrationBlock,
27
+ ledgerHashHex,
28
+ SUBSTANTIVE_MIN_CHARS,
29
+ } from '../hooks/narration-classify.mjs'
30
+ import { selectFlushDeliveryText, FLUSH_SUBSTANTIVE_MIN_CHARS } from '../turn-flush-safety.js'
31
+ import { decideOutboxSweep } from '../outbox.js'
32
+ import { decideCapturedProseDelivery } from '../silent-end.js'
33
+ import { appendShownBlock, isShownBlock, readShownHashes } from '../shown-ledger.js'
34
+ import { NarrativeFlushController } from '../narrative-flush.js'
35
+
36
+ const NARRATION = 'Now let me check the gateway logs to see what happened.'
37
+ const REAL_ANSWER =
38
+ 'The gateway crashed because the outbox sweep tried to deliver a record whose ' +
39
+ 'turnNonce had already been journaled, and the dedup cache had been evicted, so ' +
40
+ 'the exactly-once guard fell back to the text-dedup path which returned false. ' +
41
+ 'The fix is to check the shown-ledger before the quiet window, not after.'
42
+
43
+ // A substantive real message that the model happens to precede a NON-delivering
44
+ // tool with (react / pin / typing). Must NOT be treated as narration.
45
+ const SUBSTANTIVE_BEFORE_REACT = REAL_ANSWER
46
+
47
+ describe('shared classifier — the ONE source of truth (correction 2 + 3)', () => {
48
+ it('short/narration-shaped block followed by a tool IS structural narration', () => {
49
+ expect(isStructuralNarration(NARRATION, true)).toBe(true)
50
+ expect(isStructuralNarration('On it — pulling the numbers…', true)).toBe(true)
51
+ expect(isStructuralNarration('', true)).toBe(true)
52
+ })
53
+
54
+ it('SUBSTANTIVE block followed by a tool is NOT narration (react/pin/typing still delivers)', () => {
55
+ // The #3237 asymmetry: a real answer preceding react/pin/typing must deliver.
56
+ expect(SUBSTANTIVE_BEFORE_REACT.length).toBeGreaterThan(SUBSTANTIVE_MIN_CHARS)
57
+ expect(isStructuralNarration(SUBSTANTIVE_BEFORE_REACT, true)).toBe(false)
58
+ })
59
+
60
+ it('a terminal block (nothing followed it) is NEVER structural narration', () => {
61
+ // followedByToolUse false OR absent → never structural (could be the answer).
62
+ expect(isStructuralNarration(NARRATION, false)).toBe(false)
63
+ expect(isStructuralNarration(NARRATION, undefined)).toBe(false)
64
+ expect(isStructuralNarration('Yes.', false)).toBe(false)
65
+ })
66
+
67
+ it('FLUSH_SUBSTANTIVE_MIN_CHARS is wired to the shared constant (no drift)', () => {
68
+ expect(FLUSH_SUBSTANTIVE_MIN_CHARS).toBe(SUBSTANTIVE_MIN_CHARS)
69
+ })
70
+ })
71
+
72
+ describe('E1/E2 turn-flush — narration never becomes the delivered answer', () => {
73
+ it('drops a trailing narration block that a tool followed, keeps the real answer', () => {
74
+ const out = selectFlushDeliveryText([REAL_ANSWER, NARRATION], [false, true])
75
+ expect(out).toContain(REAL_ANSWER)
76
+ expect(out).not.toContain(NARRATION)
77
+ })
78
+
79
+ it('drops a SHORT non-regex block a tool followed (the class the old regex missed)', () => {
80
+ // "The logs show three errors." matches NO wording heuristic (no opener, no
81
+ // trailing ellipsis/colon) — the pre-fix regex-only classifier would have
82
+ // DELIVERED it. It is structural narration only because a tool followed it
83
+ // AND it is under the substantive floor. This is the core #3513 discriminator.
84
+ const shortNonRegex = 'The logs show three errors.'
85
+ expect(isNarrationBlock(shortNonRegex)).toBe(false)
86
+ expect(shortNonRegex.length).toBeLessThan(SUBSTANTIVE_MIN_CHARS)
87
+ const out = selectFlushDeliveryText([REAL_ANSWER, shortNonRegex], [false, true])
88
+ expect(out).toContain(REAL_ANSWER)
89
+ expect(out).not.toContain(shortNonRegex)
90
+ })
91
+
92
+ it('zero-reply, narration-only turn (all blocks followed by tools) flushes NOTHING', () => {
93
+ const out = selectFlushDeliveryText(['Let me pull the logs.', NARRATION], [true, true])
94
+ expect(out).toBe('')
95
+ })
96
+
97
+ it('a genuine terminal answer with no provenance still flushes (fail-open)', () => {
98
+ // Provenance-less single block — conservative: deliver it.
99
+ expect(selectFlushDeliveryText(['Here are the results:'])).toBe('Here are the results:')
100
+ })
101
+
102
+ it('a SUBSTANTIVE answer followed by a non-delivering tool STILL flushes', () => {
103
+ const out = selectFlushDeliveryText([SUBSTANTIVE_BEFORE_REACT], [true])
104
+ expect(out).toBe(SUBSTANTIVE_BEFORE_REACT)
105
+ })
106
+ })
107
+
108
+ describe('E4 outbox sweep — a ledger-marked block is never re-delivered (correction 1)', () => {
109
+ const base = {
110
+ now: 10_000_000,
111
+ deliveredNonces: new Set<string>(),
112
+ textAlreadyDelivered: false,
113
+ routable: true,
114
+ quietMs: 0,
115
+ }
116
+
117
+ it('skips a record whose text was shown on the ephemeral card', () => {
118
+ const d = decideOutboxSweep({
119
+ ...base,
120
+ record: { turnNonce: 'c:_#5', text: NARRATION, createdAt: 0 },
121
+ shownLedgerHit: true,
122
+ })
123
+ expect(d.action).toBe('skip-ephemeral-shown')
124
+ expect(d.text).toBeUndefined()
125
+ })
126
+
127
+ it('delivers a real answer that was NOT shown on the card', () => {
128
+ const d = decideOutboxSweep({
129
+ ...base,
130
+ record: { turnNonce: 'c:_#5', text: REAL_ANSWER, createdAt: base.now },
131
+ shownLedgerHit: false,
132
+ })
133
+ expect(d.action).toBe('send')
134
+ expect(d.text).toContain(REAL_ANSWER)
135
+ })
136
+
137
+ it('the ledger hit is checked AFTER journaled (exactly-once wins) but BEFORE quiet', () => {
138
+ // Already-journaled record short-circuits regardless of ledger state.
139
+ const journaled = decideOutboxSweep({
140
+ ...base,
141
+ deliveredNonces: new Set(['c:_#5']),
142
+ record: { turnNonce: 'c:_#5', text: NARRATION, createdAt: 0 },
143
+ shownLedgerHit: true,
144
+ })
145
+ expect(journaled.action).toBe('skip-journaled')
146
+ })
147
+ })
148
+
149
+ describe('E4 outbox sweep × durable shown-ledger — end-to-end wiring (correction 1 + 4)', () => {
150
+ // Integration test: exercise the REAL ledger round-trip (append/markShown +
151
+ // isShownBlock/isBlockShown) keyed by turnNonce THROUGH the outbox-sweep
152
+ // decision, mirroring the production composition at
153
+ // gateway/outbox-sweep.ts:122 (`shownLedgerHit: isShownBlock(record.turnNonce,
154
+ // record.text, deps.stateDir)`). Unlike the unit tests above that pass a
155
+ // hand-set `shownLedgerHit` boolean, this drives the flag FROM the durable
156
+ // ledger file — so if the ledger wiring is removed (append becomes a no-op, or
157
+ // decideOutboxSweep stops consulting the hit), the skip assertion goes RED.
158
+ const base = {
159
+ now: 10_000_000,
160
+ deliveredNonces: new Set<string>(),
161
+ textAlreadyDelivered: false,
162
+ routable: true,
163
+ quietMs: 0,
164
+ }
165
+
166
+ it('a block marked shown for its turnNonce is skipped; a non-shown block delivers', () => {
167
+ const dir = mkdtempSync(join(tmpdir(), 'outbox-ledger-'))
168
+ try {
169
+ const nonce = 'c:_#42'
170
+ // markShown: the mid-turn narrative paint appended this block to the
171
+ // durable ledger for THIS turn's nonce.
172
+ appendShownBlock(nonce, NARRATION, dir)
173
+
174
+ // Sweep the SAME (nonce, text): the production wiring derives the flag from
175
+ // the ledger, so the record must be skipped as ephemeral-shown.
176
+ const shown = decideOutboxSweep({
177
+ ...base,
178
+ record: { turnNonce: nonce, text: NARRATION, createdAt: 0 },
179
+ shownLedgerHit: isShownBlock(nonce, NARRATION, dir),
180
+ })
181
+ expect(isShownBlock(nonce, NARRATION, dir)).toBe(true)
182
+ expect(shown.action).toBe('skip-ephemeral-shown')
183
+ expect(shown.text).toBeUndefined()
184
+
185
+ // A NON-shown block (the genuine unsent answer, never appended) still
186
+ // delivers exactly once — the ledger miss falls through to send.
187
+ const notShown = decideOutboxSweep({
188
+ ...base,
189
+ record: { turnNonce: nonce, text: REAL_ANSWER, createdAt: base.now },
190
+ shownLedgerHit: isShownBlock(nonce, REAL_ANSWER, dir),
191
+ })
192
+ expect(isShownBlock(nonce, REAL_ANSWER, dir)).toBe(false)
193
+ expect(notShown.action).toBe('send')
194
+ expect(notShown.text).toContain(REAL_ANSWER)
195
+
196
+ // Cross-turn isolation: the SAME text under a DIFFERENT turnNonce is not a
197
+ // ledger hit, so it delivers (no cross-turn suppression through the sweep).
198
+ const otherTurn = decideOutboxSweep({
199
+ ...base,
200
+ record: { turnNonce: 'c:_#43', text: NARRATION, createdAt: base.now },
201
+ shownLedgerHit: isShownBlock('c:_#43', NARRATION, dir),
202
+ })
203
+ expect(otherTurn.action).toBe('send')
204
+ } finally {
205
+ rmSync(dir, { recursive: true, force: true })
206
+ }
207
+ })
208
+ })
209
+
210
+ describe('E3 captured-prose bridge — a shown block is refused (correction 1)', () => {
211
+ /** Write a real silent-end-pending.json into a temp stateDir. */
212
+ function withState(text: string, run: (deps: { stateDir: string }) => void): void {
213
+ const dir = mkdtempSync(join(tmpdir(), 'silent-end-'))
214
+ try {
215
+ writeFileSync(
216
+ join(dir, 'silent-end-pending.json'),
217
+ JSON.stringify({
218
+ chatId: 'c',
219
+ threadId: null,
220
+ turnKey: 'c:_',
221
+ turnId: 'c:_#5',
222
+ pendingText: text,
223
+ retryCount: 0,
224
+ timestamp: Date.now(),
225
+ }),
226
+ )
227
+ run({ stateDir: dir })
228
+ } finally {
229
+ rmSync(dir, { recursive: true, force: true })
230
+ }
231
+ }
232
+
233
+ it('refuses to bridge prose already surfaced on the card', () => {
234
+ withState(REAL_ANSWER, ({ stateDir }) => {
235
+ const d = decideCapturedProseDelivery(
236
+ { turnKey: 'c:_', turnId: 'c:_#5', minChars: 10 },
237
+ { stateDir, isBlockShown: () => true },
238
+ )
239
+ expect(d.deliver).toBe(false)
240
+ expect(d.reason).toBe('ephemeral-shown')
241
+ })
242
+ })
243
+
244
+ it('bridges a genuine unsent answer that was NOT shown (empty-capture divergence)', () => {
245
+ withState(REAL_ANSWER, ({ stateDir }) => {
246
+ const d = decideCapturedProseDelivery(
247
+ { turnKey: 'c:_', turnId: 'c:_#5', minChars: 10 },
248
+ { stateDir, isBlockShown: () => false },
249
+ )
250
+ expect(d.deliver).toBe(true)
251
+ if (d.deliver) expect(d.text).toContain(REAL_ANSWER)
252
+ })
253
+ })
254
+ })
255
+
256
+ describe('durable shown-ledger — round trip + envelope-less no-op (correction 4)', () => {
257
+ it('marks then recognises a structural narration block for its turnNonce', () => {
258
+ const dir = mkdtempSync(join(tmpdir(), 'shown-ledger-'))
259
+ try {
260
+ appendShownBlock('c:_#7', NARRATION, dir)
261
+ expect(isShownBlock('c:_#7', NARRATION, dir)).toBe(true)
262
+ // A different turn's nonce must NOT match (no cross-turn suppression).
263
+ expect(isShownBlock('c:_#8', NARRATION, dir)).toBe(false)
264
+ // The stored hash matches the shared classifier hash.
265
+ expect(readShownHashes('c:_#7', dir).has(ledgerHashHex(NARRATION))).toBe(true)
266
+ } finally {
267
+ rmSync(dir, { recursive: true, force: true })
268
+ }
269
+ })
270
+
271
+ it('a null turnNonce (envelope-less handback/cron) is never written', () => {
272
+ const dir = mkdtempSync(join(tmpdir(), 'shown-ledger-'))
273
+ try {
274
+ appendShownBlock(null, NARRATION, dir)
275
+ expect(isShownBlock(null, NARRATION, dir)).toBe(false)
276
+ expect(readShownHashes(null, dir).size).toBe(0)
277
+ } finally {
278
+ rmSync(dir, { recursive: true, force: true })
279
+ }
280
+ })
281
+ })
282
+
283
+ /** Fake at-most-one scheduler mirroring the real unref'd setTimeout wiring. */
284
+ class FakeScheduler {
285
+ private fn: (() => void) | null = null
286
+ arm(fn: () => void): void {
287
+ this.fn = fn
288
+ }
289
+ disarm(): void {
290
+ this.fn = null
291
+ }
292
+ fire(): void {
293
+ const fn = this.fn
294
+ this.fn = null
295
+ fn?.()
296
+ }
297
+ }
298
+
299
+ describe('narrative controller — durable-mark ONLY mid-turn narration (correction 4)', () => {
300
+ function make() {
301
+ const show = vi.fn<[string], void>()
302
+ const retractShown = vi.fn<[string], void>()
303
+ const markDurableNarration = vi.fn<[string], void>()
304
+ const scheduler = new FakeScheduler()
305
+ const ctrl = new NarrativeFlushController(
306
+ { show, retractShown, markDurableNarration },
307
+ scheduler,
308
+ 250,
309
+ )
310
+ return { ctrl, show, markDurableNarration, scheduler }
311
+ }
312
+
313
+ it('marks a block SHOWN mid-turn because another narration block followed it (stage)', () => {
314
+ const { ctrl, show, markDurableNarration } = make()
315
+ ctrl.stage('First, let me check the logs…')
316
+ ctrl.stage('Now scanning the outbox…') // lookahead → shows + marks the first
317
+ expect(show).toHaveBeenCalledWith('First, let me check the logs…')
318
+ expect(markDurableNarration).toHaveBeenCalledWith('First, let me check the logs…')
319
+ })
320
+
321
+ it('marks a block SHOWN mid-turn because a tool followed it (resolveOnTool)', () => {
322
+ const { ctrl, markDurableNarration } = make()
323
+ ctrl.stage(NARRATION)
324
+ ctrl.resolveOnTool('Bash', { command: 'grep' }) // non-reply tool lookahead
325
+ expect(markDurableNarration).toHaveBeenCalledWith(NARRATION)
326
+ })
327
+
328
+ it('NEVER marks the timer-painted terminal block (could be the real answer)', () => {
329
+ const { ctrl, show, markDurableNarration, scheduler } = make()
330
+ ctrl.stage(REAL_ANSWER)
331
+ scheduler.fire() // timer paints the LAST block early
332
+ expect(show).toHaveBeenCalledWith(REAL_ANSWER)
333
+ expect(markDurableNarration).not.toHaveBeenCalled()
334
+ })
335
+
336
+ it('NEVER marks the turn-end paint (terminal, could be the real answer)', () => {
337
+ const { ctrl, show, markDurableNarration } = make()
338
+ ctrl.stage(REAL_ANSWER)
339
+ ctrl.flushAtTurnEnd('') // no reply delivered → shows trailing prose, but never marks
340
+ expect(show).toHaveBeenCalledWith(REAL_ANSWER)
341
+ expect(markDurableNarration).not.toHaveBeenCalled()
342
+ })
343
+
344
+ it('does NOT mark a SUBSTANTIVE block even mid-turn (substance-gated, correction 3)', () => {
345
+ const { ctrl, markDurableNarration } = make()
346
+ ctrl.stage(SUBSTANTIVE_BEFORE_REACT)
347
+ ctrl.resolveOnTool('react', { emoji: '👍' }) // non-delivering tool
348
+ // Followed by a tool, but substantive → not structural narration → not marked,
349
+ // so a backstop could still deliver it if it were the genuine unsent answer.
350
+ expect(markDurableNarration).not.toHaveBeenCalled()
351
+ })
352
+ })
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Regression: switchroom #3502 — the durable-outbox safety net dropped the 🔊
3
+ * Listen button from a net-delivered final answer.
4
+ *
5
+ * PR #3502 / commit 1a531ebb added the outbox-sweep deliverer, which sent the
6
+ * captured final answer via a RAW `bot.api.sendMessage` with no voice-out
7
+ * resolution — so any answer the net delivered lost the Listen button that the
8
+ * normal `sendReply` path injects. The fix threads a shared `planListenButton`
9
+ * decision into the sweep's chunked send (`createOutboxSend`) so a net-delivered
10
+ * answer is indistinguishable from a normally-delivered one.
11
+ *
12
+ * These tests assert OUTCOMES, not code paths:
13
+ * - the delivered message actually carries the Listen keyboard when kokoro
14
+ * on-demand voice-out is enabled;
15
+ * - it does NOT when the agent supplied its own keyboard, when the TTS text is
16
+ * empty, or when voice-out is not kokoro-on-demand.
17
+ * A test that only walked the code without asserting `reply_markup` on the sent
18
+ * message would not fail on the pre-fix RAW-send bug, so we assert the exact
19
+ * bytes handed to `sendMessage`.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import { createOutboxSend } from '../gateway/outbox-sweep.js'
24
+ import { makeOutboxListenMarkupResolver } from '../gateway/outbox-listen-markup.js'
25
+ import {
26
+ planListenButton,
27
+ type ListenButtonVoiceOutPlan,
28
+ } from '../voice-ondemand.js'
29
+
30
+ const kokoroOnDemand: ListenButtonVoiceOutPlan = {
31
+ engine: 'kokoro',
32
+ speed: 1.1,
33
+ replyMode: 'on-demand',
34
+ ttsChunks: ['the spoken answer'],
35
+ }
36
+
37
+ // A passthrough retry that simply invokes the send fn once (no flood/thread
38
+ // fallback needed for the unit under test).
39
+ const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
40
+
41
+ function fakeBot() {
42
+ const calls: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
43
+ let n = 100
44
+ return {
45
+ calls,
46
+ api: {
47
+ sendMessage: async (chatId: string, text: string, opts: object) => {
48
+ calls.push({ chatId, text, opts: opts as Record<string, unknown> })
49
+ return { message_id: n++ }
50
+ },
51
+ },
52
+ }
53
+ }
54
+
55
+ describe('planListenButton — shared Listen-button decision', () => {
56
+ it('injects for kokoro on-demand with non-empty TTS and no agent keyboard', () => {
57
+ const plan = planListenButton({ voiceOutPlan: kokoroOnDemand, rawKeyboard: undefined })
58
+ expect(plan).not.toBeNull()
59
+ expect(plan!.replyMarkup.inline_keyboard[0]![0]!.text).toBe('🔊 Listen')
60
+ expect(plan!.replyMarkup.inline_keyboard[0]![0]!.callback_data).toBe(`voice:${plan!.token}`)
61
+ expect(plan!.payload.text).toBe('the spoken answer')
62
+ expect(plan!.payload.speed).toBe(1.1)
63
+ })
64
+
65
+ it('does NOT inject when the agent supplied its own keyboard (collision gate)', () => {
66
+ const plan = planListenButton({
67
+ voiceOutPlan: kokoroOnDemand,
68
+ rawKeyboard: [[{ text: 'Approve', callback_data: 'ok' }]],
69
+ })
70
+ expect(plan).toBeNull()
71
+ })
72
+
73
+ it('does NOT inject when the TTS text is empty (empty-TTS guard)', () => {
74
+ const plan = planListenButton({
75
+ voiceOutPlan: { ...kokoroOnDemand, ttsChunks: [''] },
76
+ rawKeyboard: undefined,
77
+ })
78
+ expect(plan).toBeNull()
79
+ })
80
+
81
+ it('does NOT inject for openai on-demand (taps would dead-end on local sidecar)', () => {
82
+ const plan = planListenButton({
83
+ voiceOutPlan: { ...kokoroOnDemand, engine: 'openai' },
84
+ rawKeyboard: undefined,
85
+ })
86
+ expect(plan).toBeNull()
87
+ })
88
+
89
+ it('does NOT inject when reply_mode is not on-demand', () => {
90
+ const plan = planListenButton({
91
+ voiceOutPlan: { ...kokoroOnDemand, replyMode: 'voice+text' },
92
+ rawKeyboard: undefined,
93
+ })
94
+ expect(plan).toBeNull()
95
+ })
96
+
97
+ it('returns null when there is no voice-out plan', () => {
98
+ expect(planListenButton({ voiceOutPlan: null, rawKeyboard: undefined })).toBeNull()
99
+ })
100
+ })
101
+
102
+ describe('createOutboxSend — net-delivered answer carries the Listen button (#3502)', () => {
103
+ it('attaches the Listen keyboard as reply_markup when voice-out on-demand is enabled', async () => {
104
+ const bot = fakeBot()
105
+ const send = createOutboxSend({
106
+ getBot: () => bot,
107
+ retry: passthroughRetry,
108
+ resolveReplyMarkup: () => planListenButton({ voiceOutPlan: kokoroOnDemand, rawKeyboard: undefined })!.replyMarkup,
109
+ })
110
+
111
+ await send('123', null, 'the final answer')
112
+
113
+ expect(bot.calls).toHaveLength(1)
114
+ const markup = bot.calls[0]!.opts.reply_markup as
115
+ | { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> }
116
+ | undefined
117
+ expect(markup).toBeDefined()
118
+ expect(markup!.inline_keyboard[0]![0]!.text).toBe('🔊 Listen')
119
+ // Guards against the pre-fix RAW send: no button would have been attached.
120
+ })
121
+
122
+ it('attaches the keyboard to the LAST chunk only (final visible message)', async () => {
123
+ const bot = fakeBot()
124
+ const markup = { inline_keyboard: [[{ text: '🔊 Listen', callback_data: 'voice:abcd1234' }]] }
125
+ const send = createOutboxSend({
126
+ getBot: () => bot,
127
+ retry: passthroughRetry,
128
+ resolveReplyMarkup: () => markup,
129
+ })
130
+
131
+ // > 4000 chars → two chunks.
132
+ await send('123', null, 'x'.repeat(4500))
133
+
134
+ expect(bot.calls).toHaveLength(2)
135
+ expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
136
+ expect(bot.calls[1]!.opts.reply_markup).toEqual(markup)
137
+ })
138
+
139
+ it('delivers plain (no reply_markup) when the resolver returns undefined', async () => {
140
+ const bot = fakeBot()
141
+ const send = createOutboxSend({
142
+ getBot: () => bot,
143
+ retry: passthroughRetry,
144
+ resolveReplyMarkup: () => undefined,
145
+ })
146
+
147
+ await send('123', 42, 'plain answer')
148
+
149
+ expect(bot.calls).toHaveLength(1)
150
+ expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
151
+ // thread id still threaded through
152
+ expect(bot.calls[0]!.opts.message_thread_id).toBe(42)
153
+ })
154
+
155
+ it('delivers plain when no resolver is wired at all (legacy behaviour preserved)', async () => {
156
+ const bot = fakeBot()
157
+ const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry })
158
+
159
+ await send('123', null, 'legacy answer')
160
+
161
+ expect(bot.calls).toHaveLength(1)
162
+ expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
163
+ })
164
+
165
+ it('empty text sends NOTHING and returns undefined (no empty-message retry wedge)', async () => {
166
+ // Telegram rejects an empty message body; a stray '' chunk would throw
167
+ // every sweep tick and wedge the record in a permanent retry. The send
168
+ // must short-circuit instead of calling sendMessage.
169
+ const bot = fakeBot()
170
+ const resolverCalls: string[] = []
171
+ const send = createOutboxSend({
172
+ getBot: () => bot,
173
+ retry: passthroughRetry,
174
+ resolveReplyMarkup: (_c, _t, text) => {
175
+ resolverCalls.push(text)
176
+ return undefined
177
+ },
178
+ })
179
+
180
+ const result = await send('123', null, '')
181
+
182
+ expect(result).toBeUndefined()
183
+ expect(bot.calls).toHaveLength(0)
184
+ // No point resolving a Listen button for a body we never send.
185
+ expect(resolverCalls).toEqual([])
186
+ })
187
+ })
188
+
189
+ describe('gateway wiring — makeOutboxListenMarkupResolver end-to-end (#3502)', () => {
190
+ // Pins the ACTUAL gateway resolver: the sweep must obtain the Listen keyboard
191
+ // AND persist the tap token when kokoro on-demand voice-out is enabled. If a
192
+ // future change re-severs the sweep delivery from voice-out resolution (the
193
+ // PR #3502 / commit 1a531ebb raw-sendMessage regression), the delivered
194
+ // message loses its button and these assertions go red.
195
+ it('a net-delivered answer carries the button and the tap token is cached', async () => {
196
+ const cached: Array<{ token: string; text: string }> = []
197
+ const enqueued: string[] = []
198
+ const resolve = makeOutboxListenMarkupResolver({
199
+ resolveVoiceOutPlan: () => kokoroOnDemand,
200
+ cachePut: (token, payload) => cached.push({ token, text: payload.text }),
201
+ eagerVoiceEnabled: () => true,
202
+ enqueuePreSynth: (j) => enqueued.push(j.token),
203
+ })
204
+
205
+ const bot = fakeBot()
206
+ const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry, resolveReplyMarkup: resolve })
207
+ await send('123', null, 'the final answer the net is delivering')
208
+
209
+ const markup = bot.calls[0]!.opts.reply_markup as
210
+ | { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> }
211
+ | undefined
212
+ expect(markup).toBeDefined()
213
+ const btn = markup!.inline_keyboard[0]![0]!
214
+ expect(btn.text).toBe('🔊 Listen')
215
+ // The token on the button must be the one cached + eagerly pre-synthesized,
216
+ // so the tap resolves to real audio (not a dead token).
217
+ const token = btn.callback_data.replace('voice:', '')
218
+ expect(cached).toEqual([{ token, text: 'the spoken answer' }])
219
+ expect(enqueued).toEqual([token])
220
+ })
221
+
222
+ it('resolves to no button (and caches nothing) when voice-out is off', async () => {
223
+ const cached: string[] = []
224
+ const resolve = makeOutboxListenMarkupResolver({
225
+ resolveVoiceOutPlan: () => null,
226
+ cachePut: (token) => cached.push(token),
227
+ eagerVoiceEnabled: () => true,
228
+ enqueuePreSynth: () => {},
229
+ })
230
+ const bot = fakeBot()
231
+ const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry, resolveReplyMarkup: resolve })
232
+ await send('123', null, 'plain answer, voice-out disabled')
233
+
234
+ expect(bot.calls[0]!.opts.reply_markup).toBeUndefined()
235
+ expect(cached).toEqual([])
236
+ })
237
+
238
+ it('skips eager pre-synth when the kill switch is off but still shows the button', async () => {
239
+ const enqueued: string[] = []
240
+ const resolve = makeOutboxListenMarkupResolver({
241
+ resolveVoiceOutPlan: () => kokoroOnDemand,
242
+ cachePut: () => {},
243
+ eagerVoiceEnabled: () => false,
244
+ enqueuePreSynth: (j) => enqueued.push(j.token),
245
+ })
246
+ const bot = fakeBot()
247
+ const send = createOutboxSend({ getBot: () => bot, retry: passthroughRetry, resolveReplyMarkup: resolve })
248
+ await send('123', null, 'answer')
249
+
250
+ expect(bot.calls[0]!.opts.reply_markup).toBeDefined()
251
+ expect(enqueued).toEqual([])
252
+ })
253
+ })