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
|
@@ -17365,6 +17365,29 @@ function extractToolResultErrorText(content) {
|
|
|
17365
17365
|
}
|
|
17366
17366
|
return "";
|
|
17367
17367
|
}
|
|
17368
|
+
function parseBackgroundTaskId(obj) {
|
|
17369
|
+
const tur = obj.toolUseResult;
|
|
17370
|
+
if (typeof tur === "object" && tur != null) {
|
|
17371
|
+
const id = tur.backgroundTaskId;
|
|
17372
|
+
if (typeof id === "string" && id.length > 0)
|
|
17373
|
+
return id;
|
|
17374
|
+
}
|
|
17375
|
+
return null;
|
|
17376
|
+
}
|
|
17377
|
+
function parseBackgroundLaunchString(content) {
|
|
17378
|
+
const text = typeof content === "string" ? content : extractToolResultErrorText(content);
|
|
17379
|
+
const m = text.match(/Command running in background with ID: (\w+)/);
|
|
17380
|
+
return m != null ? m[1] : null;
|
|
17381
|
+
}
|
|
17382
|
+
function parseTaskNotification(content) {
|
|
17383
|
+
if (!content.includes("<task-notification>"))
|
|
17384
|
+
return null;
|
|
17385
|
+
const idM = content.match(/<task-id>([^<]+)<\/task-id>/);
|
|
17386
|
+
const stM = content.match(/<status>([^<]+)<\/status>/);
|
|
17387
|
+
if (idM == null || stM == null)
|
|
17388
|
+
return null;
|
|
17389
|
+
return { taskId: idM[1].trim(), status: stM[1].trim() };
|
|
17390
|
+
}
|
|
17368
17391
|
function projectAssistantTextBlocks(content, make) {
|
|
17369
17392
|
const out = new Map;
|
|
17370
17393
|
let lastToolUseIdx = -1;
|
|
@@ -17422,6 +17445,10 @@ function projectTranscriptLine(line) {
|
|
|
17422
17445
|
const op = obj.operation;
|
|
17423
17446
|
if (op === "enqueue") {
|
|
17424
17447
|
const content = obj.content ?? "";
|
|
17448
|
+
const notif = parseTaskNotification(content);
|
|
17449
|
+
if (notif != null) {
|
|
17450
|
+
return [{ kind: "task_notification", taskId: notif.taskId, status: notif.status }];
|
|
17451
|
+
}
|
|
17425
17452
|
const { chatId, messageId, threadId } = parseChannelMeta(content);
|
|
17426
17453
|
return [{ kind: "enqueue", chatId, messageId, threadId, rawContent: content }];
|
|
17427
17454
|
}
|
|
@@ -17479,6 +17506,7 @@ function projectTranscriptLine(line) {
|
|
|
17479
17506
|
const content = message?.content;
|
|
17480
17507
|
if (!Array.isArray(content))
|
|
17481
17508
|
return [];
|
|
17509
|
+
const backgroundTaskId = parseBackgroundTaskId(obj);
|
|
17482
17510
|
const events = [];
|
|
17483
17511
|
for (const c of content) {
|
|
17484
17512
|
if (c.type === "tool_result") {
|
|
@@ -17488,7 +17516,8 @@ function projectTranscriptLine(line) {
|
|
|
17488
17516
|
toolUseId: c.tool_use_id ?? "",
|
|
17489
17517
|
toolName: null,
|
|
17490
17518
|
isError,
|
|
17491
|
-
errorText: isError ? extractToolResultErrorText(c.content) : undefined
|
|
17519
|
+
errorText: isError ? extractToolResultErrorText(c.content) : undefined,
|
|
17520
|
+
backgroundTaskId: backgroundTaskId ?? parseBackgroundLaunchString(c.content) ?? undefined
|
|
17492
17521
|
});
|
|
17493
17522
|
}
|
|
17494
17523
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// #3519 sharpen — background-shell liveness signal wiring.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from gateway.ts (the file is under a hard line ratchet — see
|
|
4
|
+
// switchroom#2996). Maps parsed session events to the silence-poke
|
|
5
|
+
// background-shell registry so the 300s silence-fallback defers ONLY while a
|
|
6
|
+
// CLI-side background shell is proven alive, and recovers at ~300s once it
|
|
7
|
+
// finishes. Called once per session event from the gateway's event loop.
|
|
8
|
+
//
|
|
9
|
+
// The three real, deterministic markers (all proven from captured transcript
|
|
10
|
+
// data — carrie session a6d2d33a-…, claude v2.1.197; see the fixture at
|
|
11
|
+
// telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl):
|
|
12
|
+
// • ALIVE — a tool_result carrying `backgroundTaskId` (a foreground Bash the
|
|
13
|
+
// CLI auto-moved to the background past its ~120s window, or an explicit
|
|
14
|
+
// run_in_background:true). session-tail resolves it from the structured
|
|
15
|
+
// `toolUseResult.backgroundTaskId` field, with the launch string as a
|
|
16
|
+
// secondary.
|
|
17
|
+
// • DEAD (completed/failed) — the CLI's proactive `<task-notification>`,
|
|
18
|
+
// projected as a `task_notification` event.
|
|
19
|
+
// • DEAD (explicit) — a `KillShell` tool_use naming the shell via
|
|
20
|
+
// `input.shell_id`.
|
|
21
|
+
import type { SessionEvent } from '../session-tail.js'
|
|
22
|
+
|
|
23
|
+
/** The subset of silence-poke this wiring drives (kept narrow for testability). */
|
|
24
|
+
export interface BackgroundShellRegistry {
|
|
25
|
+
noteBackgroundShellAlive(key: string, shellId: string): void
|
|
26
|
+
noteBackgroundShellDead(key: string, shellId: string): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Terminal `<task-notification>` statuses that genuinely mean the shell ended. */
|
|
30
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed'])
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Apply the background-shell liveness signal carried by `ev` (if any) to the
|
|
34
|
+
* registry for `key`. A no-op for events that carry no marker, so the caller
|
|
35
|
+
* can invoke it unconditionally on every session event.
|
|
36
|
+
*/
|
|
37
|
+
export function applyBackgroundShellLiveness(
|
|
38
|
+
registry: BackgroundShellRegistry,
|
|
39
|
+
key: string,
|
|
40
|
+
ev: SessionEvent,
|
|
41
|
+
): void {
|
|
42
|
+
if (ev.kind === 'tool_result') {
|
|
43
|
+
if (ev.backgroundTaskId != null && ev.backgroundTaskId.length > 0) {
|
|
44
|
+
registry.noteBackgroundShellAlive(key, ev.backgroundTaskId)
|
|
45
|
+
}
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (ev.kind === 'task_notification') {
|
|
49
|
+
// Defensive terminal-status gate: real captured data proves every genuine
|
|
50
|
+
// parseable <task-notification> carries a terminal status, so today ANY
|
|
51
|
+
// parsed notification is safe to treat as DEAD. This guards a hypothetical
|
|
52
|
+
// FUTURE CLI that emits an interim (non-terminal) notification for a still-
|
|
53
|
+
// running shell — we must not drop such a shell from the alive-set early.
|
|
54
|
+
if (ev.taskId.length > 0 && TERMINAL_STATUSES.has(ev.status)) {
|
|
55
|
+
registry.noteBackgroundShellDead(key, ev.taskId)
|
|
56
|
+
}
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (ev.kind === 'tool_use' && ev.toolName === 'KillShell') {
|
|
60
|
+
const killId = (ev.input as { shell_id?: string } | undefined)?.shell_id
|
|
61
|
+
if (typeof killId === 'string' && killId.length > 0) {
|
|
62
|
+
registry.noteBackgroundShellDead(key, killId)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
import {
|
|
61
61
|
VoiceOnDemandCache,
|
|
62
62
|
} from '../voice-ondemand.js'
|
|
63
|
+
import { makeOutboxListenMarkupResolver } from './outbox-listen-markup.js'
|
|
63
64
|
import {
|
|
64
65
|
PreSynthQueue,
|
|
65
66
|
sweepVoiceCacheDir,
|
|
@@ -275,7 +276,6 @@ import { createSessionModelSource } from './session-model-source.js'
|
|
|
275
276
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
276
277
|
import { REPLY_TOOLS } from '../narrative-dedup.js'
|
|
277
278
|
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
278
|
-
import { toolLabel } from '../tool-labels.js'
|
|
279
279
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
280
280
|
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
281
281
|
import {
|
|
@@ -751,6 +751,7 @@ import { createDeliveryConfirmWiring } from './delivery-confirm-wiring.js'
|
|
|
751
751
|
import { createObligationWiring } from './obligation-wiring.js'
|
|
752
752
|
// #2996 P8 PR-C3 — the extracted silence-poke wiring.
|
|
753
753
|
import { buildSilencePokeOptions } from './liveness-wiring.js'
|
|
754
|
+
import { applySilencePokeSessionEvent } from './silence-poke-session-event.js'
|
|
754
755
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
755
756
|
import { dispatchEffects } from './inbound-delivery-machine-dispatch.js'
|
|
756
757
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
@@ -9850,7 +9851,7 @@ function runDeliveryConfirmSweep(): void {
|
|
|
9850
9851
|
const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
|
|
9851
9852
|
_deliveryConfirmSweep?.unref?.()
|
|
9852
9853
|
|
|
9853
|
-
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook
|
|
9854
|
+
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers (../outbox.ts)
|
|
9854
9855
|
|
|
9855
9856
|
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
9856
9857
|
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
@@ -10871,61 +10872,7 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10871
10872
|
// (thinking vs working, plus the longest-running in-flight tool).
|
|
10872
10873
|
if (currentTurn != null) {
|
|
10873
10874
|
const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId)
|
|
10874
|
-
|
|
10875
|
-
silencePoke.noteThinking(key, Date.now())
|
|
10876
|
-
} else if (ev.kind === 'tool_use') {
|
|
10877
|
-
// #1292: track in-flight tool calls so the 300s framework
|
|
10878
|
-
// fallback message can name the actual observable (e.g.
|
|
10879
|
-
// "running Grep \"foo\" for 4m") instead of the dishonest
|
|
10880
|
-
// generic "still working… no update in 5 min" when the agent
|
|
10881
|
-
// is clearly busy on tool calls. Telegram-surface tools are
|
|
10882
|
-
// excluded — their job IS the outbound message, the silence
|
|
10883
|
-
// clock resets via noteOutbound when they fire. Sub-agent
|
|
10884
|
-
// tool_use events (kind='sub_agent_tool_use') intentionally
|
|
10885
|
-
// NOT tracked: the parent's Task tool_use is already on the
|
|
10886
|
-
// map and represents the user-observable wait.
|
|
10887
|
-
if (
|
|
10888
|
-
ev.toolUseId != null
|
|
10889
|
-
&& ev.toolUseId.length > 0
|
|
10890
|
-
&& !isTelegramSurfaceTool(ev.toolName)
|
|
10891
|
-
) {
|
|
10892
|
-
const label = toolLabel(
|
|
10893
|
-
ev.toolName,
|
|
10894
|
-
ev.input,
|
|
10895
|
-
/*preamble*/ undefined,
|
|
10896
|
-
ev.precomputedLabel,
|
|
10897
|
-
)
|
|
10898
|
-
silencePoke.noteToolStart(
|
|
10899
|
-
key,
|
|
10900
|
-
ev.toolUseId,
|
|
10901
|
-
ev.toolName,
|
|
10902
|
-
label.length > 0 ? label : null,
|
|
10903
|
-
Date.now(),
|
|
10904
|
-
)
|
|
10905
|
-
// #1445 cross-turn pending-async ambient. Mark the chat as
|
|
10906
|
-
// having dispatched background work this turn so a turn_end
|
|
10907
|
-
// that follows activates the edit-in-place ambient line.
|
|
10908
|
-
// Covers `Agent` / `Task` (the harness-managed async path
|
|
10909
|
-
// — handback channel turn clears it) and `Bash` with
|
|
10910
|
-
// run_in_background:true (model is expected to poll
|
|
10911
|
-
// BashOutput; the ambient ticks until next inbound or the
|
|
10912
|
-
// 30-min budget cap).
|
|
10913
|
-
const evInput = ev.input as { run_in_background?: boolean } | undefined
|
|
10914
|
-
if (
|
|
10915
|
-
ev.toolName === 'Agent'
|
|
10916
|
-
|| ev.toolName === 'Task'
|
|
10917
|
-
|| (ev.toolName === 'Bash' && evInput?.run_in_background === true)
|
|
10918
|
-
) {
|
|
10919
|
-
pendingProgress.noteAsyncDispatch(key)
|
|
10920
|
-
}
|
|
10921
|
-
}
|
|
10922
|
-
} else if (ev.kind === 'tool_result') {
|
|
10923
|
-
// #1292: drain the in-flight entry. Idempotent on unknown ids
|
|
10924
|
-
// (covers Telegram-surface tools we skipped at start time).
|
|
10925
|
-
if (ev.toolUseId != null && ev.toolUseId.length > 0) {
|
|
10926
|
-
silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
|
|
10927
|
-
}
|
|
10928
|
-
}
|
|
10875
|
+
applySilencePokeSessionEvent(silencePoke, pendingProgress, key, ev)
|
|
10929
10876
|
}
|
|
10930
10877
|
},
|
|
10931
10878
|
|
|
@@ -22498,7 +22445,9 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
|
|
|
22498
22445
|
bot.on('message:pinned_message', ctx => handlePinnedMessage(ctx, pinnedMessageHandlerDeps))
|
|
22499
22446
|
// Bot API 10.1 rich messages (forwarded bot messages carry these with NO
|
|
22500
22447
|
// text/caption — see rich-message-handler.ts; MUST precede the catch-all).
|
|
22501
|
-
|
|
22448
|
+
// Pure forwarded body text, no attachment → coalesce like `message:text`:
|
|
22449
|
+
// bind `handleInbound` to `handleInboundCoalesced`, not the bare one (#3516).
|
|
22450
|
+
bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, { ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }))
|
|
22502
22451
|
installUnhandledMessageCatchAll(
|
|
22503
22452
|
bot,
|
|
22504
22453
|
(ctx, text) => routeInbound(ctx, text, undefined, undefined, inboundRouterDeps),
|
|
@@ -65,9 +65,8 @@ import {
|
|
|
65
65
|
import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
|
|
66
66
|
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
67
67
|
import {
|
|
68
|
-
mintVoiceOnDemandToken,
|
|
69
|
-
buildListenKeyboard,
|
|
70
68
|
mayInjectListenButton,
|
|
69
|
+
planListenButton,
|
|
71
70
|
type VoiceOnDemandCache,
|
|
72
71
|
} from '../voice-ondemand.js'
|
|
73
72
|
import { eagerVoiceEnabled, type PreSynthQueue } from '../voice-presynth.js'
|
|
@@ -1495,31 +1494,34 @@ export async function sendReply(
|
|
|
1495
1494
|
// config never reaches here (it synthesized immediately above), so a Listen
|
|
1496
1495
|
// button is never minted for an engine whose taps would dead-end on the
|
|
1497
1496
|
// local sidecar.
|
|
1498
|
-
if (
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
if (
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1497
|
+
if (useOnDemandButton) {
|
|
1498
|
+
// planListenButton is the SHARED decision (also used by the durable-outbox
|
|
1499
|
+
// safety net, outbox-sweep.ts) — it enforces the empty-TTS guard and the
|
|
1500
|
+
// agent-keyboard collision gate. Null = don't inject.
|
|
1501
|
+
const listenPlan = planListenButton({ voiceOutPlan, rawKeyboard })
|
|
1502
|
+
if (listenPlan == null) {
|
|
1503
|
+
// Log the collision gate ONLY when the agent keyboard is the ACTUAL
|
|
1504
|
+
// reason we skipped — i.e. there IS speakable text. When ttsChunks is
|
|
1505
|
+
// empty the empty-TTS guard is what returned null (there was nothing to
|
|
1506
|
+
// speak), so a "skipping — agent supplied inline_keyboard" line would
|
|
1507
|
+
// misattribute the reason.
|
|
1508
|
+
const hasSpeakableText =
|
|
1509
|
+
voiceOutPlan!.ttsChunks.length > 0 && voiceOutPlan!.ttsChunks[0]!.length > 0
|
|
1510
|
+
if (hasSpeakableText && !mayInjectListenButton(rawKeyboard)) {
|
|
1511
|
+
process.stderr.write(
|
|
1512
|
+
'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
|
|
1513
|
+
)
|
|
1514
|
+
}
|
|
1507
1515
|
} else {
|
|
1508
1516
|
// Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
|
|
1509
1517
|
// invariant the operator is the only authorized sender across all chats,
|
|
1510
1518
|
// and the tap handler re-checks access.allowFrom before synthesizing, so
|
|
1511
1519
|
// a token needs no per-chat scoping to be safe.
|
|
1512
|
-
|
|
1513
|
-
voiceOnDemandCache.put(token, {
|
|
1514
|
-
// ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
|
|
1515
|
-
text: voiceOutPlan.ttsChunks[0]!,
|
|
1516
|
-
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
1517
|
-
speed: voiceOutPlan.speed,
|
|
1518
|
-
})
|
|
1520
|
+
voiceOnDemandCache.put(listenPlan.token, listenPlan.payload)
|
|
1519
1521
|
// The keyboard stays after the tap (never stripped) so it can be
|
|
1520
1522
|
// replayed. The gate above guarantees this is the ONLY button on the
|
|
1521
1523
|
// message, so keeping it is safe (no agent buttons to protect).
|
|
1522
|
-
replyMarkup =
|
|
1524
|
+
replyMarkup = listenPlan.replyMarkup
|
|
1523
1525
|
// #2763 eager pre-synthesis: kick a background synth of the same
|
|
1524
1526
|
// payload so the Listen tap attaches the pre-made file instantly.
|
|
1525
1527
|
// Local-engine only (useOnDemandButton already gates engine==='kokoro'
|
|
@@ -1530,10 +1532,10 @@ export async function sendReply(
|
|
|
1530
1532
|
// to the lazy path).
|
|
1531
1533
|
if (eagerVoiceEnabled()) {
|
|
1532
1534
|
voicePreSynthQueue.enqueue({
|
|
1533
|
-
token,
|
|
1534
|
-
text:
|
|
1535
|
-
...(
|
|
1536
|
-
speed:
|
|
1535
|
+
token: listenPlan.token,
|
|
1536
|
+
text: listenPlan.payload.text,
|
|
1537
|
+
...(listenPlan.payload.voice != null ? { voice: listenPlan.payload.voice } : {}),
|
|
1538
|
+
speed: listenPlan.payload.speed,
|
|
1537
1539
|
})
|
|
1538
1540
|
}
|
|
1539
1541
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-listen-markup.ts — wires the shared `planListenButton` decision into
|
|
3
|
+
* the durable-outbox safety-net delivery (outbox-sweep.ts), so a net-delivered
|
|
4
|
+
* final answer carries the SAME 🔊 Listen button / voice-out keyboard the normal
|
|
5
|
+
* `sendReply` path injects (switchroom #3502 regression: the sweep sent captured
|
|
6
|
+
* prose via a RAW `bot.api.sendMessage` with no voice-out resolution, dropping
|
|
7
|
+
* the button).
|
|
8
|
+
*
|
|
9
|
+
* Extracted from gateway.ts so the wiring lives in a module (the #2996 gateway
|
|
10
|
+
* anti-inflation ratchet) and is independently testable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
planListenButton,
|
|
15
|
+
type ListenButtonVoiceOutPlan,
|
|
16
|
+
type VoiceOnDemandPayload,
|
|
17
|
+
} from '../voice-ondemand.js'
|
|
18
|
+
import type { OutboxDeliveryMarkup } from './outbox-sweep.js'
|
|
19
|
+
|
|
20
|
+
/** The minimal side-effecting deps the resolver needs — mirrors what `sendReply`
|
|
21
|
+
* does on the normal path (cache put + eager pre-synth), kept as an injected
|
|
22
|
+
* surface so gateway.ts passes its own singletons and this stays unit-testable. */
|
|
23
|
+
export interface OutboxListenMarkupDeps {
|
|
24
|
+
/** Resolve the agent's voice-out plan for a reply body (loadAccess().voice_out
|
|
25
|
+
* → resolveVoiceOutPlan). Returns null when voice-out is off / not applicable. */
|
|
26
|
+
resolveVoiceOutPlan: (replyText: string) => ListenButtonVoiceOutPlan | null
|
|
27
|
+
/** Persist a minted token so a Listen tap can resynthesize. A thin closure
|
|
28
|
+
* (not the cache instance) so the gateway can pass it before its module-scope
|
|
29
|
+
* `voiceOnDemandCache` const is initialized (this factory runs at wiring time,
|
|
30
|
+
* earlier in gateway.ts than the cache declaration). */
|
|
31
|
+
cachePut: (token: string, payload: VoiceOnDemandPayload) => void
|
|
32
|
+
/** True when eager pre-synth is enabled (kill switch off). */
|
|
33
|
+
eagerVoiceEnabled: () => boolean
|
|
34
|
+
/** Enqueue an eager pre-synth job (best-effort, off the critical path). */
|
|
35
|
+
enqueuePreSynth: (job: { token: string; text: string; voice?: string; speed: number }) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the `resolveReplyMarkup` callback for `startOutboxSweep`. Returns the
|
|
40
|
+
* Listen keyboard (and persists the token + eager-synth job) when kokoro
|
|
41
|
+
* on-demand voice-out is enabled and the empty-TTS / collision gates pass;
|
|
42
|
+
* otherwise undefined → the sweep delivers plain.
|
|
43
|
+
*
|
|
44
|
+
* A net-delivered captured-prose answer never carries an agent keyboard, so
|
|
45
|
+
* `rawKeyboard` is always undefined here; `planListenButton` still enforces the
|
|
46
|
+
* empty-TTS guard and the kokoro-on-demand gate.
|
|
47
|
+
*/
|
|
48
|
+
export function makeOutboxListenMarkupResolver(
|
|
49
|
+
deps: OutboxListenMarkupDeps,
|
|
50
|
+
): (chatId: string, threadId: number | null, text: string) => OutboxDeliveryMarkup | undefined {
|
|
51
|
+
return (_chatId, _threadId, text) => {
|
|
52
|
+
const voiceOutPlan = deps.resolveVoiceOutPlan(text)
|
|
53
|
+
const listenPlan = planListenButton({ voiceOutPlan, rawKeyboard: undefined })
|
|
54
|
+
if (listenPlan == null) return undefined
|
|
55
|
+
const payload: VoiceOnDemandPayload = listenPlan.payload
|
|
56
|
+
deps.cachePut(listenPlan.token, payload)
|
|
57
|
+
if (deps.eagerVoiceEnabled()) {
|
|
58
|
+
deps.enqueuePreSynth({
|
|
59
|
+
token: listenPlan.token,
|
|
60
|
+
text: payload.text,
|
|
61
|
+
...(payload.voice != null ? { voice: payload.voice } : {}),
|
|
62
|
+
speed: payload.speed,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return listenPlan.replyMarkup
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -244,39 +244,113 @@ export const OUTBOX_SWEEP_INTERVAL_MS = 5_000
|
|
|
244
244
|
* (`resolveSubagentOriginTurnKey`) then the last-real-inbound fallback (H3).
|
|
245
245
|
* Kill switch: `SWITCHROOM_TG_OUTBOX_DELIVERY=0`.
|
|
246
246
|
*/
|
|
247
|
+
/** A Telegram inline keyboard the sweep attaches to the FINAL delivered chunk
|
|
248
|
+
* (the 🔊 Listen button / voice-out keyboard — switchroom #3502 regression fix,
|
|
249
|
+
* so a net-delivered answer keeps the same button a `sendReply` answer gets).
|
|
250
|
+
* Shaped to match `planListenButton().replyMarkup`. */
|
|
251
|
+
export type OutboxDeliveryMarkup = {
|
|
252
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Minimal bot-api surface the sweep needs to deliver text. */
|
|
256
|
+
type OutboxSendBot = {
|
|
257
|
+
api: {
|
|
258
|
+
sendMessage: (
|
|
259
|
+
chatId: string,
|
|
260
|
+
text: string,
|
|
261
|
+
opts: object,
|
|
262
|
+
) => Promise<{ message_id?: number }>
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the chunked `send` the sweep hands to {@link sweepOutbox}. Extracted +
|
|
268
|
+
* exported so the reply-markup attachment (the #3502 fix) is unit-testable
|
|
269
|
+
* without a live gateway or timer.
|
|
270
|
+
*
|
|
271
|
+
* `resolveReplyMarkup` resolves the voice-out Listen button / keyboard for a
|
|
272
|
+
* delivery (null/undefined → no button). It is applied to the FINAL chunk ONLY
|
|
273
|
+
* so the button lands on the last visible message, exactly like the normal
|
|
274
|
+
* `sendReply` path (buttons attach to the last chunk there too). This makes a
|
|
275
|
+
* safety-net-delivered final answer indistinguishable from a normally-delivered
|
|
276
|
+
* one instead of silently dropping the button.
|
|
277
|
+
*/
|
|
278
|
+
export function createOutboxSend(deps: {
|
|
279
|
+
getBot: () => OutboxSendBot | undefined
|
|
280
|
+
retry: Parameters<typeof retryWithThreadFallback>[0]
|
|
281
|
+
resolveReplyMarkup?: (
|
|
282
|
+
chatId: string,
|
|
283
|
+
threadId: number | null,
|
|
284
|
+
text: string,
|
|
285
|
+
) => OutboxDeliveryMarkup | undefined
|
|
286
|
+
}): OutboxSweepDeps['send'] {
|
|
287
|
+
return async (chatId, threadId, text) => {
|
|
288
|
+
const bot = deps.getBot()
|
|
289
|
+
if (bot == null) throw new Error('outbox-sweep: bot unavailable')
|
|
290
|
+
// Empty text → nothing to deliver. Telegram rejects an empty message body,
|
|
291
|
+
// so sending one chunk of '' would throw every tick and wedge the sweep in
|
|
292
|
+
// a permanent retry (the record never journals → never clears). Return
|
|
293
|
+
// early, matching the pre-refactor loop's zero-chunk behaviour.
|
|
294
|
+
if (text.length === 0) return undefined
|
|
295
|
+
// Resolve the Listen button / keyboard ONCE from the full answer text; it
|
|
296
|
+
// rides only on the final chunk below.
|
|
297
|
+
const replyMarkup = deps.resolveReplyMarkup?.(chatId, threadId, text)
|
|
298
|
+
// Chunk to Telegram's 4096-char ceiling; each chunk goes through the
|
|
299
|
+
// standard retry / flood-wait / thread-fallback wrapper. A thrown send
|
|
300
|
+
// propagates so the sweep releases the claim and retries next tick (the
|
|
301
|
+
// record is never journaled → never lost).
|
|
302
|
+
let lastId: number | undefined
|
|
303
|
+
const chunkCount = Math.ceil(text.length / 4000)
|
|
304
|
+
for (let i = 0, idx = 0; i < text.length; i += 4000, idx++) {
|
|
305
|
+
const chunk = text.slice(i, i + 4000)
|
|
306
|
+
const isLast = idx === chunkCount - 1
|
|
307
|
+
const res = await retryWithThreadFallback(
|
|
308
|
+
deps.retry,
|
|
309
|
+
(tid) => {
|
|
310
|
+
const base = tid != null ? { message_thread_id: tid } : {}
|
|
311
|
+
// Button on the LAST chunk only (final visible message).
|
|
312
|
+
const opts =
|
|
313
|
+
isLast && replyMarkup != null ? { ...base, reply_markup: replyMarkup } : base
|
|
314
|
+
return bot.api.sendMessage(chatId, chunk, opts)
|
|
315
|
+
},
|
|
316
|
+
{ threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
|
|
317
|
+
)
|
|
318
|
+
lastId = res?.message_id
|
|
319
|
+
}
|
|
320
|
+
return lastId
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
247
324
|
export function startOutboxSweep(deps: {
|
|
248
325
|
isGatewayMain: boolean
|
|
249
326
|
stateDir: string
|
|
250
|
-
getBot: () =>
|
|
327
|
+
getBot: () => OutboxSendBot | undefined
|
|
251
328
|
getTurnsDb: () => Parameters<typeof resolveSubagentOriginTurnKey>[0] | null
|
|
252
329
|
dedupCheck: (chatId: string, threadId: number | undefined, text: string) => boolean
|
|
330
|
+
/** Resolve the voice-out Listen button / keyboard for a net delivery. Wired
|
|
331
|
+
* by the gateway (loadAccess → resolveVoiceOutPlan → planListenButton +
|
|
332
|
+
* cache put + eager pre-synth). Absent → deliver plain (legacy behaviour). */
|
|
333
|
+
resolveReplyMarkup?: (
|
|
334
|
+
chatId: string,
|
|
335
|
+
threadId: number | null,
|
|
336
|
+
text: string,
|
|
337
|
+
) => OutboxDeliveryMarkup | undefined
|
|
253
338
|
log?: (line: string) => void
|
|
254
339
|
}): ReturnType<typeof setInterval> | undefined {
|
|
255
340
|
if (!deps.isGatewayMain || process.env.SWITCHROOM_TG_OUTBOX_DELIVERY === '0') return undefined
|
|
256
341
|
const retry = createRetryApiCall({ log: deps.log })
|
|
342
|
+
const send = createOutboxSend({
|
|
343
|
+
getBot: deps.getBot,
|
|
344
|
+
retry,
|
|
345
|
+
...(deps.resolveReplyMarkup != null ? { resolveReplyMarkup: deps.resolveReplyMarkup } : {}),
|
|
346
|
+
})
|
|
257
347
|
const tick = () => {
|
|
258
348
|
const bot = deps.getBot()
|
|
259
349
|
if (bot == null) return
|
|
260
350
|
void sweepOutbox({
|
|
261
351
|
stateDir: deps.stateDir,
|
|
262
352
|
log: deps.log,
|
|
263
|
-
send
|
|
264
|
-
// Chunk to Telegram's 4096-char ceiling; each chunk goes through the
|
|
265
|
-
// standard retry / flood-wait / thread-fallback wrapper. A thrown send
|
|
266
|
-
// propagates so the sweep releases the claim and retries next tick (the
|
|
267
|
-
// record is never journaled → never lost).
|
|
268
|
-
let lastId: number | undefined
|
|
269
|
-
for (let i = 0; i < text.length; i += 4000) {
|
|
270
|
-
const chunk = text.slice(i, i + 4000)
|
|
271
|
-
const res = await retryWithThreadFallback(
|
|
272
|
-
retry,
|
|
273
|
-
(tid) => bot.api.sendMessage(chatId, chunk, tid != null ? { message_thread_id: tid } : {}),
|
|
274
|
-
{ threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
|
|
275
|
-
)
|
|
276
|
-
lastId = res?.message_id
|
|
277
|
-
}
|
|
278
|
-
return lastId
|
|
279
|
-
},
|
|
353
|
+
send,
|
|
280
354
|
textAlreadyDelivered: (chatId, threadId, text) => deps.dedupCheck(chatId, threadId ?? undefined, text),
|
|
281
355
|
registryChainLookup: (taskId) => {
|
|
282
356
|
const db = deps.getTurnsDb()
|
|
@@ -216,10 +216,16 @@ export function extractRichMessageText(rich: unknown): string | undefined {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* `message:rich_message` handler —
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
219
|
+
* `message:rich_message` handler — renders the body and hands it to the
|
|
220
|
+
* normal COALESCING inbound pipeline, identical to `message:text`. It shares
|
|
221
|
+
* the cluster-A `MediaEnvelopeDeps` SHAPE, but gateway.ts binds this handler's
|
|
222
|
+
* `deps.handleInbound` to `handleInboundCoalesced` via an inline spread at the
|
|
223
|
+
* registration site — `{ ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }`
|
|
224
|
+
* — NOT the bare `handleInbound` the media-envelope handlers use: a rich
|
|
225
|
+
* message is pure forwarded body text with no attachment, so a forwarded bot
|
|
226
|
+
* message arriving in the same sliding window as another inbound folds into one
|
|
227
|
+
* turn (same coalescing contract as `message:text`). Access gating +
|
|
228
|
+
* forward-origin parsing happen downstream, unchanged.
|
|
223
229
|
*/
|
|
224
230
|
export async function handleRichMessageMessage(
|
|
225
231
|
ctx: Filter<Context, 'message:rich_message'>,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// #1122 silence-poke session-event wiring.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from gateway.ts (the file is under a hard line ratchet — see
|
|
4
|
+
// switchroom#2996). Maps a parsed session event onto the silence-poke activity
|
|
5
|
+
// registry so the 300s framework-fallback message wording is honest (thinking
|
|
6
|
+
// vs working, plus the longest-running in-flight tool), threads the #1445
|
|
7
|
+
// cross-turn pending-async ambient, and feeds the #3519 background-shell
|
|
8
|
+
// liveness signal. Called once per session event from the gateway's event loop
|
|
9
|
+
// while a turn is live (the caller owns the `currentTurn != null` guard and the
|
|
10
|
+
// `key` derivation).
|
|
11
|
+
import type { SessionEvent } from '../session-tail.js'
|
|
12
|
+
import { isTelegramSurfaceTool } from '../tool-names.js'
|
|
13
|
+
import { toolLabel } from '../tool-labels.js'
|
|
14
|
+
import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
|
|
15
|
+
|
|
16
|
+
/** The silence-poke module namespace (kept as `typeof` so no surface drift). */
|
|
17
|
+
type SilencePoke = typeof import('../silence-poke.js')
|
|
18
|
+
/** The pending-work-progress module namespace. */
|
|
19
|
+
type PendingProgress = typeof import('../pending-work-progress.js')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply the activity signals carried by `ev` to silence-poke / pending-progress
|
|
23
|
+
* for the live turn identified by `key`. Behaviour is identical to the inline
|
|
24
|
+
* block this replaced — a pure extract/move.
|
|
25
|
+
*/
|
|
26
|
+
export function applySilencePokeSessionEvent(
|
|
27
|
+
silencePoke: SilencePoke,
|
|
28
|
+
pendingProgress: PendingProgress,
|
|
29
|
+
key: string,
|
|
30
|
+
ev: SessionEvent,
|
|
31
|
+
): void {
|
|
32
|
+
if (ev.kind === 'thinking') {
|
|
33
|
+
silencePoke.noteThinking(key, Date.now())
|
|
34
|
+
} else if (ev.kind === 'tool_use') {
|
|
35
|
+
// #1292: track in-flight tool calls so the 300s framework
|
|
36
|
+
// fallback message can name the actual observable (e.g.
|
|
37
|
+
// "running Grep \"foo\" for 4m") instead of the dishonest
|
|
38
|
+
// generic "still working… no update in 5 min" when the agent
|
|
39
|
+
// is clearly busy on tool calls. Telegram-surface tools are
|
|
40
|
+
// excluded — their job IS the outbound message, the silence
|
|
41
|
+
// clock resets via noteOutbound when they fire. Sub-agent
|
|
42
|
+
// tool_use events (kind='sub_agent_tool_use') intentionally
|
|
43
|
+
// NOT tracked: the parent's Task tool_use is already on the
|
|
44
|
+
// map and represents the user-observable wait.
|
|
45
|
+
if (
|
|
46
|
+
ev.toolUseId != null
|
|
47
|
+
&& ev.toolUseId.length > 0
|
|
48
|
+
&& !isTelegramSurfaceTool(ev.toolName)
|
|
49
|
+
) {
|
|
50
|
+
const label = toolLabel(
|
|
51
|
+
ev.toolName,
|
|
52
|
+
ev.input,
|
|
53
|
+
/*preamble*/ undefined,
|
|
54
|
+
ev.precomputedLabel,
|
|
55
|
+
)
|
|
56
|
+
silencePoke.noteToolStart(
|
|
57
|
+
key,
|
|
58
|
+
ev.toolUseId,
|
|
59
|
+
ev.toolName,
|
|
60
|
+
label.length > 0 ? label : null,
|
|
61
|
+
Date.now(),
|
|
62
|
+
)
|
|
63
|
+
// #1445 cross-turn pending-async ambient. Mark the chat as
|
|
64
|
+
// having dispatched background work this turn so a turn_end
|
|
65
|
+
// that follows activates the edit-in-place ambient line.
|
|
66
|
+
// Covers `Agent` / `Task` (the harness-managed async path
|
|
67
|
+
// — handback channel turn clears it) and `Bash` with
|
|
68
|
+
// run_in_background:true (model is expected to poll
|
|
69
|
+
// BashOutput; the ambient ticks until next inbound or the
|
|
70
|
+
// 30-min budget cap).
|
|
71
|
+
const evInput = ev.input as { run_in_background?: boolean } | undefined
|
|
72
|
+
if (
|
|
73
|
+
ev.toolName === 'Agent'
|
|
74
|
+
|| ev.toolName === 'Task'
|
|
75
|
+
|| (ev.toolName === 'Bash' && evInput?.run_in_background === true)
|
|
76
|
+
) {
|
|
77
|
+
pendingProgress.noteAsyncDispatch(key)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} else if (ev.kind === 'tool_result') {
|
|
81
|
+
// #1292: drain the in-flight entry. Idempotent on unknown ids
|
|
82
|
+
// (covers Telegram-surface tools we skipped at start time).
|
|
83
|
+
if (ev.toolUseId != null && ev.toolUseId.length > 0) {
|
|
84
|
+
silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
|
|
88
|
+
applyBackgroundShellLiveness(silencePoke, key, ev)
|
|
89
|
+
}
|