switchroom 0.19.15 → 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 (27) 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/dist/bridge/bridge.js +30 -1
  5. package/telegram-plugin/dist/gateway/gateway.js +693 -433
  6. package/telegram-plugin/dist/server.js +30 -1
  7. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  8. package/telegram-plugin/gateway/gateway.ts +7 -58
  9. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  10. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  11. package/telegram-plugin/gateway/outbox-sweep.ts +92 -18
  12. package/telegram-plugin/gateway/rich-message-handler.ts +10 -4
  13. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  14. package/telegram-plugin/session-tail.ts +88 -1
  15. package/telegram-plugin/silence-poke.ts +118 -1
  16. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  17. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  18. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  19. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  20. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  21. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  22. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  23. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  24. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  25. package/telegram-plugin/tts-normalize.ts +12 -0
  26. package/telegram-plugin/voice-normalize-text.ts +100 -0
  27. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Outcome regression (#3516 follow-up): a forwarded rich (bot) message MUST
3
+ * coalesce through the same sliding window as `message:text`.
4
+ *
5
+ * THE BUG: PR #3516 registered `message:rich_message` but wired it with
6
+ * `mediaEnvelopeDeps`, whose `handleInbound` is the BARE (non-coalescing)
7
+ * inbound function — the same surface the cluster-A media-envelope handlers
8
+ * (contact/location/poll/…) use. The sibling text/photo/voice/attachment
9
+ * handlers all route through `handleInboundCoalesced`. Result: a forwarded
10
+ * bot message arriving in the same coalesce window as another inbound
11
+ * message did NOT fold into one turn — it bypassed the window entirely,
12
+ * contradicting rich-message-handler.ts's own docstring ("hands it to the
13
+ * normal COALESCING inbound pipeline … identical to message:text").
14
+ *
15
+ * Two guards, both anchored to the real production code:
16
+ *
17
+ * 1. WIRING (source-parse, deterministic RED/GREEN): parse gateway.ts and
18
+ * assert the deps object passed to the `message:rich_message`
19
+ * registration binds `handleInbound` to `handleInboundCoalesced` (the
20
+ * coalescing entry point), the SAME path `message:text` takes. This
21
+ * guard is RED on the pre-fix wiring (`mediaEnvelopeDeps`, bare
22
+ * `handleInbound`) and GREEN after it.
23
+ *
24
+ * 2. OUTCOME (real grammy Bot + real handler + real coalescer): drive the
25
+ * production `handleRichMessageMessage` and `message:text` through one
26
+ * shared `createInboundCoalescer` (the exact module gateway.ts uses),
27
+ * and assert a rich message + a text message in the same window flush
28
+ * as ONE ordered turn — and that the bare (non-coalescing) surface the
29
+ * bug used would instead emit TWO turns.
30
+ */
31
+
32
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
33
+ import { readFileSync } from 'node:fs'
34
+ import { fileURLToPath } from 'node:url'
35
+ import { dirname, resolve } from 'node:path'
36
+ import ts from 'typescript'
37
+ import { Bot } from 'grammy'
38
+ import type { Update } from 'grammy/types'
39
+ import { resetUpdateCounters } from './update-factory.js'
40
+ import { handleRichMessageMessage } from '../gateway/rich-message-handler.js'
41
+ import type { MediaEnvelopeDeps } from '../gateway/media-message-handlers.js'
42
+ import {
43
+ createInboundCoalescer,
44
+ inboundCoalesceKey,
45
+ } from '../gateway/inbound-coalesce.js'
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Guard 1 — wiring source-parse
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const __dirname = dirname(fileURLToPath(import.meta.url))
52
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
53
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
54
+ const sourceFile = ts.createSourceFile(
55
+ GATEWAY_PATH,
56
+ GATEWAY_SRC,
57
+ ts.ScriptTarget.Latest,
58
+ true,
59
+ ts.ScriptKind.TS,
60
+ )
61
+
62
+ /** The deps argument (arg 2) of `bot.on('message:rich_message', ctx =>
63
+ * handleRichMessageMessage(ctx, DEPS))` as an AST node, or null. */
64
+ function findRichMessageDepsArg(): ts.Expression | null {
65
+ let found: ts.Expression | null = null
66
+ const visit = (node: ts.Node): void => {
67
+ if (found) return
68
+ if (
69
+ ts.isCallExpression(node) &&
70
+ ts.isPropertyAccessExpression(node.expression) &&
71
+ ts.isIdentifier(node.expression.expression) &&
72
+ node.expression.expression.text === 'bot' &&
73
+ node.expression.name.text === 'on'
74
+ ) {
75
+ const arg0 = node.arguments[0]
76
+ const evName = arg0 && ts.isStringLiteralLike(arg0) ? arg0.text : null
77
+ if (evName === 'message:rich_message') {
78
+ // arg1 is `ctx => handleRichMessageMessage(ctx, DEPS)`
79
+ const arg1 = node.arguments[1]
80
+ if (arg1 && (ts.isArrowFunction(arg1) || ts.isFunctionExpression(arg1))) {
81
+ const body = arg1.body
82
+ const inner = !ts.isBlock(body) && ts.isCallExpression(body) ? body : undefined
83
+ if (inner && inner.arguments.length >= 2) found = inner.arguments[1]
84
+ }
85
+ }
86
+ }
87
+ ts.forEachChild(node, visit)
88
+ }
89
+ visit(sourceFile)
90
+ return found
91
+ }
92
+
93
+ /** Resolve the deps arg to its source text — inline object literal directly,
94
+ * or the initializer of the `const <name> = { … }` it names. */
95
+ function resolveDepsText(arg: ts.Expression): string | null {
96
+ if (ts.isObjectLiteralExpression(arg)) return arg.getText(sourceFile)
97
+ if (ts.isIdentifier(arg)) {
98
+ let text: string | null = null
99
+ const visit = (node: ts.Node): void => {
100
+ if (text) return
101
+ if (
102
+ ts.isVariableDeclaration(node) &&
103
+ ts.isIdentifier(node.name) &&
104
+ node.name.text === arg.text &&
105
+ node.initializer
106
+ ) {
107
+ text = node.initializer.getText(sourceFile)
108
+ }
109
+ ts.forEachChild(node, visit)
110
+ }
111
+ visit(sourceFile)
112
+ return text
113
+ }
114
+ return null
115
+ }
116
+
117
+ describe('wiring: message:rich_message dispatches through the coalescing path', () => {
118
+ it('the rich_message registration passes deps that bind handleInbound to handleInboundCoalesced', () => {
119
+ const depsArg = findRichMessageDepsArg()
120
+ expect(depsArg, 'could not locate the message:rich_message registration deps arg').not.toBeNull()
121
+
122
+ const depsText = resolveDepsText(depsArg as ts.Expression)
123
+ expect(depsText, 'could not resolve the rich_message deps source text').not.toBeNull()
124
+
125
+ // The coalescing entry point — the same one message:text routes through.
126
+ // Pre-fix the deps was `mediaEnvelopeDeps` (bare `handleInbound,`
127
+ // shorthand), so this assertion is RED on the bug and GREEN with the fix.
128
+ expect(depsText).toContain('handleInbound: handleInboundCoalesced')
129
+ })
130
+ })
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Guard 2 — outcome: real handler + real coalescer
134
+ //
135
+ // NOTE: Guard 2 drives its OWN `richCoalesces` boolean toggle rather than
136
+ // importing the gateway's dispatch wiring, so it validates the coalescer
137
+ // CONTRACT (folding/ordering into one turn vs. the pre-fix split) — it is NOT
138
+ // a wiring regression guard. Guard 1 (the AST/source-parse over gateway.ts) is
139
+ // the SOLE guard that regresses if the rich_message registration stops routing
140
+ // through `handleInboundCoalesced`.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ const CHAT_ID = -1004223464247
144
+ const USER_ID = 777
145
+
146
+ function makeBotInfo() {
147
+ return {
148
+ id: 999,
149
+ is_bot: true,
150
+ first_name: 'TestBot',
151
+ username: 'test_bot',
152
+ can_join_groups: true,
153
+ can_read_all_group_messages: false,
154
+ supports_inline_queries: false,
155
+ can_connect_to_business: false,
156
+ has_main_web_app: false,
157
+ }
158
+ }
159
+
160
+ /** Forwarded rich (bot) message, no top-level text/caption — the live shape. */
161
+ function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
162
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
163
+ return {
164
+ update_id,
165
+ message: {
166
+ message_id: update_id,
167
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
168
+ from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
169
+ date: 1784837003,
170
+ forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
171
+ forward_from: originUser,
172
+ forward_date: 1784830000,
173
+ rich_message: { blocks },
174
+ },
175
+ } as unknown as Update
176
+ }
177
+
178
+ function makeTextUpdate(update_id: number, text: string): Update {
179
+ return {
180
+ update_id,
181
+ message: {
182
+ message_id: update_id,
183
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
184
+ from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
185
+ date: 1784837004,
186
+ text,
187
+ },
188
+ } as unknown as Update
189
+ }
190
+
191
+ interface HarnessOptions {
192
+ /** Bind the rich handler's dispatch to the coalescing path (the fix) or to
193
+ * the bare non-coalescing path (the pre-fix bug). */
194
+ richCoalesces: boolean
195
+ }
196
+
197
+ /**
198
+ * Wire a real grammy Bot the way the production gateway does: message:text
199
+ * enqueues into a shared coalescer; message:rich_message goes through the
200
+ * REAL production handler with deps whose `handleInbound` is bound to either
201
+ * the same coalescer (fix) or a bare immediate dispatch (bug).
202
+ */
203
+ function buildHarness(opts: HarnessOptions) {
204
+ const flushed: string[] = []
205
+
206
+ // The exact coalescer module the gateway uses. 1500ms sliding window.
207
+ const coalescer = createInboundCoalescer<{ text: string }>({
208
+ gapMs: 1500,
209
+ merge: entries => ({ text: entries.map(e => e.text).join('\n') }),
210
+ onFlush: (_key, merged) => flushed.push(merged.text),
211
+ })
212
+
213
+ const key = inboundCoalesceKey(String(CHAT_ID), null, String(USER_ID))
214
+
215
+ /** Coalescing dispatch — mirrors handleInboundCoalesced's enqueue step. */
216
+ const coalescedDispatch = async (text: string): Promise<void> => {
217
+ const { bypass } = coalescer.enqueue(key, { text })
218
+ if (bypass) flushed.push(text)
219
+ }
220
+ /** Bare dispatch — the pre-fix bug: every message is its own turn. */
221
+ const bareDispatch = async (text: string): Promise<void> => {
222
+ flushed.push(text)
223
+ }
224
+
225
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
226
+ bot.botInfo = makeBotInfo()
227
+
228
+ bot.on('message:text', async ctx => {
229
+ await coalescedDispatch(ctx.message.text)
230
+ })
231
+
232
+ const richDeps: MediaEnvelopeDeps = {
233
+ handleInbound: async (_ctx, text) => {
234
+ await (opts.richCoalesces ? coalescedDispatch(text) : bareDispatch(text))
235
+ },
236
+ handleAckOnly: async () => {},
237
+ handleRefusal: async () => {},
238
+ log: () => {},
239
+ }
240
+ bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, richDeps))
241
+
242
+ return { bot, flushed }
243
+ }
244
+
245
+ const BLOCKS = [{ type: 'paragraph', text: 'forwarded body from a bot' }]
246
+
247
+ describe('outcome: forwarded rich message coalesces with a sibling inbound', () => {
248
+ beforeEach(() => {
249
+ resetUpdateCounters()
250
+ vi.useFakeTimers()
251
+ })
252
+ afterEach(() => vi.useRealTimers())
253
+
254
+ it('rich + text in the same window flush as ONE ordered turn (the fix)', async () => {
255
+ const { bot, flushed } = buildHarness({ richCoalesces: true })
256
+
257
+ await bot.handleUpdate(makeForwardedRichUpdate(9001, BLOCKS))
258
+ // A second inbound arrives 500ms later — inside the 1500ms window.
259
+ vi.advanceTimersByTime(500)
260
+ await bot.handleUpdate(makeTextUpdate(9002, 'and my own note about it'))
261
+
262
+ // Nothing flushed yet — the window is still open.
263
+ expect(flushed).toEqual([])
264
+
265
+ // Window closes 1500ms after the LAST message (sliding window).
266
+ vi.advanceTimersByTime(1500)
267
+
268
+ // ONE turn, both bodies, rich body first (arrival order preserved).
269
+ expect(flushed).toHaveLength(1)
270
+ expect(flushed[0]).toBe('forwarded body from a bot\nand my own note about it')
271
+ })
272
+
273
+ it('the pre-fix bare wiring would split them into TWO turns (bug characterization)', async () => {
274
+ const { bot, flushed } = buildHarness({ richCoalesces: false })
275
+
276
+ await bot.handleUpdate(makeForwardedRichUpdate(9003, BLOCKS))
277
+ // The rich message bypassed the window: it flushes immediately.
278
+ expect(flushed).toEqual(['forwarded body from a bot'])
279
+
280
+ vi.advanceTimersByTime(500)
281
+ await bot.handleUpdate(makeTextUpdate(9004, 'and my own note about it'))
282
+ vi.advanceTimersByTime(1500)
283
+
284
+ // Two separate turns — the fragmentation the fix eliminates.
285
+ expect(flushed).toEqual([
286
+ 'forwarded body from a bot',
287
+ 'and my own note about it',
288
+ ])
289
+ })
290
+ })
@@ -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
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest'
2
- import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync, utimesSync } from 'fs'
2
+ import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync, utimesSync, readFileSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
5
  import {
@@ -1018,3 +1018,93 @@ describe('projectAssistantTextBlocks (shared text→narrative kernel)', () => {
1018
1018
  })
1019
1019
  })
1020
1020
  })
1021
+
1022
+ // ─── #3519 sharpen: background-shell liveness markers, from REAL captured data ─
1023
+ // Fixtures are VERBATIM lines from a real agent transcript — no hand-written
1024
+ // approximations. Provenance (cited so a reviewer can independently verify;
1025
+ // this is a SURVIVING, reachable session — the earlier 1db49136 session was
1026
+ // rotated off host, so the fixture was regenerated from this one):
1027
+ // file: /host-home/.switchroom/agents/carrie/.claude/projects/
1028
+ // -home-kenthompson--switchroom-agents-carrie/
1029
+ // a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89.jsonl (claude CLI v2.1.197)
1030
+ // line 109 → ALIVE: a FOREGROUND Bash (tool_use input has NO
1031
+ // run_in_background) that the CLI auto-moved to the background at its
1032
+ // ~120s foreground window (tool_result at 00:35:56Z) — the exact
1033
+ // #3519 auto-background case. Carries BOTH the structured
1034
+ // `toolUseResult.backgroundTaskId:"bxa4sv3dq"` and the launch string
1035
+ // "Command running in background with ID: bxa4sv3dq. …".
1036
+ // line 175 → DEAD: the CLI's proactive `<task-notification>` for the SAME id
1037
+ // (`<task-id>bxa4sv3dq</task-id>`, `<status>completed</status>`),
1038
+ // enqueued as a queue-operation.
1039
+ // line 180 → the mirrored `type:"attachment"` copy of the same notification.
1040
+ // The three lines are copied into tests/fixtures/bg-shell-liveness-3519.jsonl
1041
+ // byte-for-byte EXCEPT the operator username, scrubbed in BOTH encodings — the
1042
+ // slash home path (`/home/<user>` → `~`) and the dashed tmp-path form
1043
+ // (`-home-<user>-` → `-home-user-`) — per repo PII policy
1044
+ // (scripts/check-no-pii-secrets.mjs). Every marker-bearing field —
1045
+ // backgroundTaskId, the launch string + id, the <task-notification> tags — is
1046
+ // untouched.
1047
+ describe('projectTranscriptLine — #3519 background-shell liveness (real fixtures)', () => {
1048
+ const FIXTURE = join(__dirname, 'fixtures', 'bg-shell-liveness-3519.jsonl')
1049
+ const lines = readFileSync(FIXTURE, 'utf8').split('\n').filter(l => l.length > 0)
1050
+ const [aliveLine, deadEnqueueLine] = lines
1051
+
1052
+ it('ALIVE: emits backgroundTaskId from the real structured toolUseResult field', () => {
1053
+ // The whole plumbing seam end-to-end: the raw captured bytes → the parsed
1054
+ // tool_result event actually CARRIES backgroundTaskId, so the gateway can
1055
+ // register the shell alive. If this field weren't in the real event, the
1056
+ // signal would be unavailable — this proves it is.
1057
+ const events = projectTranscriptLine(aliveLine)
1058
+ const tr = events.find(e => e.kind === 'tool_result')
1059
+ expect(tr).toBeDefined()
1060
+ expect(tr).toMatchObject({
1061
+ kind: 'tool_result',
1062
+ toolUseId: 'toolu_01B7T3y1t95oHDEqYKwSmqaW',
1063
+ backgroundTaskId: 'bxa4sv3dq',
1064
+ })
1065
+ })
1066
+
1067
+ it('DEAD: projects the real <task-notification> enqueue as a task_notification (completed)', () => {
1068
+ // Proves the DEAD seam: the CLI's completion signal — which arrives as a
1069
+ // queue-operation enqueue, NOT a tool_result — is parsed to the shell id +
1070
+ // status the gateway drops from the alive-set. It must NOT be mis-read as
1071
+ // an inbound user turn (no `enqueue` event).
1072
+ const events = projectTranscriptLine(deadEnqueueLine)
1073
+ expect(events).toEqual([
1074
+ { kind: 'task_notification', taskId: 'bxa4sv3dq', status: 'completed' },
1075
+ ])
1076
+ })
1077
+
1078
+ it('the ALIVE and DEAD ids MATCH — a real launch pairs with its real completion', () => {
1079
+ const alive = projectTranscriptLine(aliveLine).find(e => e.kind === 'tool_result')
1080
+ const dead = projectTranscriptLine(deadEnqueueLine)[0]
1081
+ expect(alive?.kind === 'tool_result' ? alive.backgroundTaskId : null)
1082
+ .toBe(dead.kind === 'task_notification' ? dead.taskId : undefined)
1083
+ })
1084
+
1085
+ it('SAFE DEGRADATION: a CLI that renamed the marker yields NO backgroundTaskId', () => {
1086
+ // Simulate a future CLI that changed the launch shape: drop the structured
1087
+ // field AND mutate the launch string. The parser must return NO id (so the
1088
+ // gateway never registers a phantom-alive shell), which is what lets the
1089
+ // silence-poke layer fall back to the 900s-bounded guard. Built by mutating
1090
+ // the REAL line, so it stays traceable.
1091
+ const obj = JSON.parse(aliveLine)
1092
+ delete obj.toolUseResult.backgroundTaskId // structured field gone
1093
+ obj.message.content[0].content =
1094
+ 'Task moved to the background (id withheld by a newer CLI).' // string changed
1095
+ const events = projectTranscriptLine(JSON.stringify(obj))
1096
+ const tr = events.find(e => e.kind === 'tool_result')
1097
+ expect(tr).toBeDefined()
1098
+ expect(tr?.kind === 'tool_result' ? tr.backgroundTaskId : 'X').toBeUndefined()
1099
+ })
1100
+
1101
+ it('secondary path: the launch STRING alone still yields the id when the field is absent', () => {
1102
+ // If a CLI keeps the human string but drops the structured field, the regex
1103
+ // fallback still recovers the id — traceable, built from the real line.
1104
+ const obj = JSON.parse(aliveLine)
1105
+ delete obj.toolUseResult.backgroundTaskId
1106
+ const events = projectTranscriptLine(JSON.stringify(obj))
1107
+ const tr = events.find(e => e.kind === 'tool_result')
1108
+ expect(tr?.kind === 'tool_result' ? tr.backgroundTaskId : null).toBe('bxa4sv3dq')
1109
+ })
1110
+ })