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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +30 -1
- package/telegram-plugin/dist/gateway/gateway.js +693 -433
- package/telegram-plugin/dist/server.js +30 -1
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/gateway.ts +7 -58
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +92 -18
- package/telegram-plugin/gateway/rich-message-handler.ts +10 -4
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
- package/telegram-plugin/tests/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- 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
|
+
})
|