From c6db48b346f919d045e3d97ace7ee810379eadc7 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 19:47:18 -0600 Subject: [PATCH] fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --- CHANGELOG.md | 2 ++ src/auto-reply/reply/commands-tts.ts | 6 +++--- src/auto-reply/reply/commands.test.ts | 14 ++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 10 ++++++++-- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed99095aa..8abb35bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,8 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. +- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. - CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index bba7e2b02..04b60a4e9 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -25,11 +25,11 @@ type ParsedTtsCommand = { }; function parseTtsCommand(normalized: string): ParsedTtsCommand | null { - // Accept `/tts [args]` - return null for `/tts` alone to trigger inline menu. - if (normalized === "/tts") return null; + // Accept `/tts` and `/tts [args]` as a single control surface. + if (normalized === "/tts") return { action: "status", args: "" }; if (!normalized.startsWith("/tts ")) return null; const rest = normalized.slice(5).trim(); - if (!rest) return null; + if (!rest) return { action: "status", args: "" }; const [action, ...tail] = rest.split(/\s+/); return { action: action.toLowerCase(), args: tail.join(" ").trim() }; } diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 7078c15dc..fd8236c95 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -420,3 +420,17 @@ describe("handleCommands subagents", () => { expect(result.reply?.text).toContain("Status: done"); }); }); + +describe("handleCommands /tts", () => { + it("returns status for bare /tts on text command surfaces", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } }, + } as ClawdbotConfig; + const params = buildParams("/tts", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("TTS status"); + }); +}); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 146e39f57..1dcd770bc 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; -import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js"; +import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i; const AUDIO_HEADER_RE = /^\[Audio\b/i; @@ -342,10 +342,16 @@ export async function dispatchReplyFromConfig(params: { } } + const ttsMode = resolveTtsConfig(cfg).mode ?? "final"; // Generate TTS-only reply after block streaming completes (when there's no final reply). // This handles the case where block streaming succeeds and drops final payloads, // but we still want TTS audio to be generated from the accumulated block content. - if (replies.length === 0 && blockCount > 0 && accumulatedBlockText.trim()) { + if ( + ttsMode === "final" && + replies.length === 0 && + blockCount > 0 && + accumulatedBlockText.trim() + ) { try { const ttsSyntheticReply = await maybeApplyTtsToPayload({ payload: { text: accumulatedBlockText },