From 481bd333eb75c84493b68911b8ff1b43b650ea3a Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:51:53 -0400 Subject: [PATCH] fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Shadow --- CHANGELOG.md | 2 + src/auto-reply/commands-registry.data.ts | 39 +++++- src/auto-reply/commands-registry.test.ts | 12 +- src/auto-reply/commands-registry.ts | 32 +++-- src/auto-reply/commands-registry.types.ts | 6 +- src/auto-reply/reply/commands-tts.ts | 135 +++++++++---------- src/auto-reply/reply/commands.test.ts | 14 ++ src/auto-reply/reply/dispatch-from-config.ts | 72 +++++++++- src/discord/monitor/native-command.ts | 18 ++- src/infra/unhandled-rejections.test.ts | 129 ++++++++++++++++++ src/infra/unhandled-rejections.ts | 123 ++++++++++++----- src/slack/monitor/slash.ts | 6 +- src/telegram/bot-native-commands.ts | 4 +- src/tts/tts.ts | 54 ++++---- 14 files changed, 487 insertions(+), 159 deletions(-) create mode 100644 src/infra/unhandled-rejections.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ea7235c..d1a29c4d0 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/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 12fec300b..5ba6826fe 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "tts", nativeName: "tts", - description: "Configure text-to-speech.", + description: "Control text-to-speech (TTS).", textAlias: "/tts", - acceptsArgs: true, + args: [ + { + name: "action", + description: "TTS action", + type: "string", + choices: [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + { value: "status", label: "Status" }, + { value: "provider", label: "Provider" }, + { value: "limit", label: "Limit" }, + { value: "summary", label: "Summary" }, + { value: "audio", label: "Audio" }, + { value: "help", label: "Help" }, + ], + }, + { + name: "value", + description: "Provider, limit, or text", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: { + arg: "action", + title: + "TTS Actions:\n" + + "• On – Enable TTS for responses\n" + + "• Off – Disable TTS\n" + + "• Status – Show current settings\n" + + "• Provider – Set voice provider (edge, elevenlabs, openai)\n" + + "• Limit – Set max characters for TTS\n" + + "• Summary – Toggle AI summary for long texts\n" + + "• Audio – Generate TTS from custom text\n" + + "• Help – Show usage guide", + }, }), defineChatCommand({ key: "whoami", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 6a6efbced..69f3ac1ae 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -229,7 +229,12 @@ describe("commands registry args", () => { const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); expect(menu?.arg.name).toBe("mode"); - expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]); + expect(menu?.choices).toEqual([ + { label: "off", value: "off" }, + { label: "tokens", value: "tokens" }, + { label: "full", value: "full" }, + { label: "cost", value: "cost" }, + ]); }); it("does not show menus when arg already provided", () => { @@ -284,7 +289,10 @@ describe("commands registry args", () => { const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); expect(menu?.arg.name).toBe("level"); - expect(menu?.choices).toEqual(["low", "high"]); + expect(menu?.choices).toEqual([ + { label: "low", value: "low" }, + { label: "high", value: "high" }, + ]); expect(seen?.commandKey).toBe("think"); expect(seen?.argName).toBe("level"); expect(seen?.provider).toBeTruthy(); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 5bca565f0..f772ac7fc 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): { }; } +export type ResolvedCommandArgChoice = { value: string; label: string }; + export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: ClawdbotConfig; provider?: string; model?: string; -}): string[] { +}): ResolvedCommandArgChoice[] { const { command, arg, cfg } = params; if (!arg.choices) return []; const provided = arg.choices; - if (Array.isArray(provided)) return provided; - const defaults = resolveDefaultCommandContext(cfg); - const context: CommandArgChoiceContext = { - cfg, - provider: params.provider ?? defaults.provider, - model: params.model ?? defaults.model, - command, - arg, - }; - return provided(context); + const raw = Array.isArray(provided) + ? provided + : (() => { + const defaults = resolveDefaultCommandContext(cfg); + const context: CommandArgChoiceContext = { + cfg, + provider: params.provider ?? defaults.provider, + model: params.model ?? defaults.model, + command, + arg, + }; + return provided(context); + })(); + return raw.map((choice) => + typeof choice === "string" ? { value: choice, label: choice } : choice, + ); } export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs; cfg?: ClawdbotConfig; -}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null { +}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null { const { command, args, cfg } = params; if (!command.args || !command.argsMenu) return null; if (command.argsParsing === "none") return null; diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index c19c9d9a7..5e5bdd8cb 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -12,14 +12,16 @@ export type CommandArgChoiceContext = { arg: CommandArgDefinition; }; -export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[]; +export type CommandArgChoice = string | { value: string; label: string }; + +export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[]; export type CommandArgDefinition = { name: string; description: string; type: CommandArgType; required?: boolean; - choices?: string[] | CommandArgChoicesProvider; + choices?: CommandArgChoice[] | CommandArgChoicesProvider; captureRemaining?: boolean; }; diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index 5c65fb94c..04b60a4e9 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -6,20 +6,18 @@ import { getTtsMaxLength, getTtsProvider, isSummarizationEnabled, + isTtsEnabled, isTtsProviderConfigured, - normalizeTtsAutoMode, - resolveTtsAutoMode, resolveTtsApiKey, resolveTtsConfig, resolveTtsPrefsPath, - resolveTtsProviderOrder, setLastTtsAttempt, setSummarizationEnabled, + setTtsEnabled, setTtsMaxLength, setTtsProvider, textToSpeech, } from "../../tts/tts.js"; -import { updateSessionStore } from "../../config/sessions.js"; type ParsedTtsCommand = { action: string; @@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload { // Keep usage in one place so help/validation stays consistent. return { text: - "⚙️ Usage: /tts [value]" + - "\nExamples:\n" + - "/tts always\n" + - "/tts provider openai\n" + - "/tts provider edge\n" + - "/tts limit 2000\n" + - "/tts summary off\n" + - "/tts audio Hello from Clawdbot", + `🔊 **TTS (Text-to-Speech) Help**\n\n` + + `**Commands:**\n` + + `• /tts on — Enable automatic TTS for replies\n` + + `• /tts off — Disable TTS\n` + + `• /tts status — Show current settings\n` + + `• /tts provider [name] — View/change provider\n` + + `• /tts limit [number] — View/change text limit\n` + + `• /tts summary [on|off] — View/change auto-summary\n` + + `• /tts audio — Generate audio from text\n\n` + + `**Providers:**\n` + + `• edge — Free, fast (default)\n` + + `• openai — High quality (requires API key)\n` + + `• elevenlabs — Premium voices (requires API key)\n\n` + + `**Text Limit (default: 1500, max: 4096):**\n` + + `When text exceeds the limit:\n` + + `• Summary ON: AI summarizes, then generates audio\n` + + `• Summary OFF: Truncates text, then generates audio\n\n` + + `**Examples:**\n` + + `/tts provider edge\n` + + `/tts limit 2000\n` + + `/tts audio Hello, this is a test!`, }; } @@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand return { shouldContinue: false, reply: ttsUsage() }; } - const requestedAuto = normalizeTtsAutoMode( - action === "on" ? "always" : action === "off" ? "off" : action, - ); - if (requestedAuto) { - const entry = params.sessionEntry; - const sessionKey = params.sessionKey; - const store = params.sessionStore; - if (entry && store && sessionKey) { - entry.ttsAuto = requestedAuto; - entry.updatedAt = Date.now(); - store[sessionKey] = entry; - if (params.storePath) { - await updateSessionStore(params.storePath, (store) => { - store[sessionKey] = entry; - }); - } - } - const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto; - return { - shouldContinue: false, - reply: { - text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`, - }, - }; + if (action === "on") { + setTtsEnabled(prefsPath, true); + return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } }; + } + + if (action === "off") { + setTtsEnabled(prefsPath, false); + return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } }; } if (action === "audio") { if (!args.trim()) { - return { shouldContinue: false, reply: ttsUsage() }; + return { + shouldContinue: false, + reply: { + text: + `🎤 Generate audio from text.\n\n` + + `Usage: /tts audio \n` + + `Example: /tts audio Hello, this is a test!`, + }, + }; } const start = Date.now(); @@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "provider") { const currentProvider = getTtsProvider(config, prefsPath); if (!args.trim()) { - const fallback = resolveTtsProviderOrder(currentProvider) - .slice(1) - .filter((provider) => isTtsProviderConfigured(config, provider)); const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai")); const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs")); const hasEdge = isTtsProviderConfigured(config, "edge"); @@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand text: `🎙️ TTS provider\n` + `Primary: ${currentProvider}\n` + - `Fallbacks: ${fallback.join(", ") || "none"}\n` + `OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` + `ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` + `Edge enabled: ${hasEdge ? "✅" : "❌"}\n` + @@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand } setTtsProvider(prefsPath, requested); - const fallback = resolveTtsProviderOrder(requested) - .slice(1) - .filter((provider) => isTtsProviderConfigured(config, provider)); return { shouldContinue: false, - reply: { - text: - `✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` + - (requested === "edge" - ? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true." - : ""), - }, + reply: { text: `✅ TTS provider set to ${requested}.` }, }; } @@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand const currentLimit = getTtsMaxLength(prefsPath); return { shouldContinue: false, - reply: { text: `📏 TTS limit: ${currentLimit} characters.` }, + reply: { + text: + `📏 TTS limit: ${currentLimit} characters.\n\n` + + `Text longer than this triggers summary (if enabled).\n` + + `Range: 100-4096 chars (Telegram max).\n\n` + + `To change: /tts limit \n` + + `Example: /tts limit 2000`, + }, }; } const next = Number.parseInt(args.trim(), 10); - if (!Number.isFinite(next) || next < 100 || next > 10_000) { - return { shouldContinue: false, reply: ttsUsage() }; + if (!Number.isFinite(next) || next < 100 || next > 4096) { + return { + shouldContinue: false, + reply: { text: "❌ Limit must be between 100 and 4096 characters." }, + }; } setTtsMaxLength(prefsPath, next); return { @@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "summary") { if (!args.trim()) { const enabled = isSummarizationEnabled(prefsPath); + const maxLen = getTtsMaxLength(prefsPath); return { shouldContinue: false, - reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` }, + reply: { + text: + `📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` + + `When text exceeds ${maxLen} chars:\n` + + `• ON: summarizes text, then generates audio\n` + + `• OFF: truncates text, then generates audio\n\n` + + `To change: /tts summary on | off`, + }, }; } const requested = args.trim().toLowerCase(); @@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand } if (action === "status") { - const sessionAuto = params.sessionEntry?.ttsAuto; - const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto }); - const enabled = autoMode !== "off"; + const enabled = isTtsEnabled(config, prefsPath); const provider = getTtsProvider(config, prefsPath); const hasKey = isTtsProviderConfigured(config, provider); - const providerStatus = - provider === "edge" - ? hasKey - ? "✅ enabled" - : "❌ disabled" - : hasKey - ? "✅ key" - : "❌ no key"; const maxLength = getTtsMaxLength(prefsPath); const summarize = isSummarizationEnabled(prefsPath); const last = getLastTtsAttempt(); - const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode; const lines = [ "📊 TTS status", - `Auto: ${enabled ? autoLabel : "off"}`, - `Provider: ${provider} (${providerStatus})`, + `State: ${enabled ? "✅ enabled" : "❌ disabled"}`, + `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`, `Text limit: ${maxLength} chars`, `Auto-summary: ${summarize ? "on" : "off"}`, ]; 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 f946c05f9..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; @@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: { return { queuedFinal, counts }; } + // Track accumulated block text for TTS generation after streaming completes. + // When block streaming succeeds, there's no final reply, so we need to generate + // TTS audio separately from the accumulated block content. + let accumulatedBlockText = ""; + let blockCount = 0; + const replyResult = await (params.replyResolver ?? getReplyFromConfig)( ctx, { ...params.replyOptions, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { + // Accumulate block text for TTS generation after streaming + if (payload.text) { + if (accumulatedBlockText.length > 0) { + accumulatedBlockText += "\n"; + } + accumulatedBlockText += payload.text; + blockCount++; + } const ttsPayload = await maybeApplyTtsToPayload({ payload, cfg, @@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: { queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal; } } + + 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 ( + ttsMode === "final" && + replies.length === 0 && + blockCount > 0 && + accumulatedBlockText.trim() + ) { + try { + const ttsSyntheticReply = await maybeApplyTtsToPayload({ + payload: { text: accumulatedBlockText }, + cfg, + channel: ttsChannel, + kind: "final", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + // Only send if TTS was actually applied (mediaUrl exists) + if (ttsSyntheticReply.mediaUrl) { + // Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content + const ttsOnlyPayload: ReplyPayload = { + mediaUrl: ttsSyntheticReply.mediaUrl, + audioAsVoice: ttsSyntheticReply.audioAsVoice, + }; + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload: ttsOnlyPayload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: ctx.MessageThreadId, + cfg, + }); + queuedFinal = result.ok || queuedFinal; + if (result.ok) routedFinalCount += 1; + if (!result.ok) { + logVerbose( + `dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`, + ); + } + } else { + const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload); + queuedFinal = didQueue || queuedFinal; + } + } + } catch (err) { + logVerbose( + `dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + await dispatcher.waitForIdle(); const counts = dispatcher.getQueuedCounts(); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 75c9b3b2b..2340da2da 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: { typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : ""; const choices = resolveCommandArgChoices({ command, arg, cfg }); const filtered = focusValue - ? choices.filter((choice) => choice.toLowerCase().includes(focusValue)) + ? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue)) : choices; await interaction.respond( - filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })), + filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })), ); } : undefined; const choices = resolvedChoices.length > 0 && !autocomplete - ? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice })) + ? resolvedChoices + .slice(0, 25) + .map((choice) => ({ name: choice.label, value: choice.value })) : undefined; return { name: arg.name, @@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC function buildDiscordCommandArgMenu(params: { command: ChatCommandDefinition; - menu: { arg: CommandArgDefinition; choices: string[]; title?: string }; + menu: { + arg: CommandArgDefinition; + choices: Array<{ value: string; label: string }>; + title?: string; + }; interaction: CommandInteraction; cfg: ReturnType; discordConfig: DiscordConfig; @@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: { const buttons = choices.map( (choice) => new DiscordCommandArgButton({ - label: choice, + label: choice.label, customId: buildDiscordCommandArgCustomId({ command: commandLabel, arg: menu.arg.name, - value: choice, + value: choice.value, userId, }), cfg: params.cfg, diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts new file mode 100644 index 000000000..1ec144ba1 --- /dev/null +++ b/src/infra/unhandled-rejections.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { isAbortError, isTransientNetworkError } from "./unhandled-rejections.js"; + +describe("isAbortError", () => { + it("returns true for error with name AbortError", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for error with "This operation was aborted" message', () => { + const error = new Error("This operation was aborted"); + expect(isAbortError(error)).toBe(true); + }); + + it("returns true for undici-style AbortError", () => { + // Node's undici throws errors with this exact message + const error = Object.assign(new Error("This operation was aborted"), { name: "AbortError" }); + expect(isAbortError(error)).toBe(true); + }); + + it("returns true for object with AbortError name", () => { + expect(isAbortError({ name: "AbortError", message: "test" })).toBe(true); + }); + + it("returns false for regular errors", () => { + expect(isAbortError(new Error("Something went wrong"))).toBe(false); + expect(isAbortError(new TypeError("Cannot read property"))).toBe(false); + expect(isAbortError(new RangeError("Invalid array length"))).toBe(false); + }); + + it("returns false for errors with similar but different messages", () => { + expect(isAbortError(new Error("Operation aborted"))).toBe(false); + expect(isAbortError(new Error("aborted"))).toBe(false); + expect(isAbortError(new Error("Request was aborted"))).toBe(false); + }); + + it("returns false for null and undefined", () => { + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isAbortError("string error")).toBe(false); + expect(isAbortError(42)).toBe(false); + }); + + it("returns false for plain objects without AbortError name", () => { + expect(isAbortError({ message: "plain object" })).toBe(false); + }); +}); + +describe("isTransientNetworkError", () => { + it("returns true for errors with transient network codes", () => { + const codes = [ + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ECONNABORTED", + "EPIPE", + "EHOSTUNREACH", + "ENETUNREACH", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_SOCKET", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + ]; + + for (const code of codes) { + const error = Object.assign(new Error("test"), { code }); + expect(isTransientNetworkError(error), `code: ${code}`).toBe(true); + } + }); + + it('returns true for TypeError with "fetch failed" message', () => { + const error = new TypeError("fetch failed"); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns true for fetch failed with network cause", () => { + const cause = Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" }); + const error = Object.assign(new TypeError("fetch failed"), { cause }); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns true for nested cause chain with network error", () => { + const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); + const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause }); + const error = Object.assign(new TypeError("fetch failed"), { cause: outerCause }); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns true for AggregateError containing network errors", () => { + const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + const error = new AggregateError([networkError], "Multiple errors"); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns false for regular errors without network codes", () => { + expect(isTransientNetworkError(new Error("Something went wrong"))).toBe(false); + expect(isTransientNetworkError(new TypeError("Cannot read property"))).toBe(false); + expect(isTransientNetworkError(new RangeError("Invalid array length"))).toBe(false); + }); + + it("returns false for errors with non-network codes", () => { + const error = Object.assign(new Error("test"), { code: "INVALID_CONFIG" }); + expect(isTransientNetworkError(error)).toBe(false); + }); + + it("returns false for null and undefined", () => { + expect(isTransientNetworkError(null)).toBe(false); + expect(isTransientNetworkError(undefined)).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isTransientNetworkError("string error")).toBe(false); + expect(isTransientNetworkError(42)).toBe(false); + expect(isTransientNetworkError({ message: "plain object" })).toBe(false); + }); + + it("returns false for AggregateError with only non-network errors", () => { + const error = new AggregateError([new Error("regular error")], "Multiple errors"); + expect(isTransientNetworkError(error)).toBe(false); + }); +}); diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index ac7ac91d5..86e80e9a3 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -1,11 +1,88 @@ import process from "node:process"; -import { formatErrorMessage, formatUncaughtError } from "./errors.js"; +import { formatUncaughtError } from "./errors.js"; type UnhandledRejectionHandler = (reason: unknown) => boolean; const handlers = new Set(); +/** + * Checks if an error is an AbortError. + * These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash. + */ +export function isAbortError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const name = "name" in err ? String(err.name) : ""; + if (name === "AbortError") return true; + // Check for "This operation was aborted" message from Node's undici + const message = "message" in err && typeof err.message === "string" ? err.message : ""; + if (message === "This operation was aborted") return true; + return false; +} + +// Network error codes that indicate transient failures (shouldn't crash the gateway) +const TRANSIENT_NETWORK_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ECONNABORTED", + "EPIPE", + "EHOSTUNREACH", + "ENETUNREACH", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_SOCKET", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", +]); + +function getErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== "object") return undefined; + const code = (err as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function getErrorCause(err: unknown): unknown { + if (!err || typeof err !== "object") return undefined; + return (err as { cause?: unknown }).cause; +} + +/** + * Checks if an error is a transient network error that shouldn't crash the gateway. + * These are typically temporary connectivity issues that will resolve on their own. + */ +export function isTransientNetworkError(err: unknown): boolean { + if (!err) return false; + + // Check the error itself + const code = getErrorCode(err); + if (code && TRANSIENT_NETWORK_CODES.has(code)) return true; + + // "fetch failed" TypeError from undici (Node's native fetch) + if (err instanceof TypeError && err.message === "fetch failed") { + const cause = getErrorCause(err); + // The cause often contains the actual network error + if (cause) return isTransientNetworkError(cause); + // Even without a cause, "fetch failed" is typically a network issue + return true; + } + + // Check the cause chain recursively + const cause = getErrorCause(err); + if (cause && cause !== err) { + return isTransientNetworkError(cause); + } + + // AggregateError may wrap multiple causes + if (err instanceof AggregateError && err.errors?.length) { + return err.errors.some((e) => isTransientNetworkError(e)); + } + + return false; +} + export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void { handlers.add(handler); return () => { @@ -13,36 +90,6 @@ export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHan }; } -/** - * Check if an error is a recoverable/transient error that shouldn't crash the process. - * These include network errors and abort signals during shutdown. - */ -function isRecoverableError(reason: unknown): boolean { - if (!reason) return false; - - // Check error name for AbortError - if (reason instanceof Error && reason.name === "AbortError") { - return true; - } - - const message = reason instanceof Error ? reason.message : formatErrorMessage(reason); - const lowerMessage = message.toLowerCase(); - return ( - lowerMessage.includes("fetch failed") || - lowerMessage.includes("network request") || - lowerMessage.includes("econnrefused") || - lowerMessage.includes("econnreset") || - lowerMessage.includes("etimedout") || - lowerMessage.includes("socket hang up") || - lowerMessage.includes("enotfound") || - lowerMessage.includes("network error") || - lowerMessage.includes("getaddrinfo") || - lowerMessage.includes("client network socket disconnected") || - lowerMessage.includes("this operation was aborted") || - lowerMessage.includes("aborted") - ); -} - export function isUnhandledRejectionHandled(reason: unknown): boolean { for (const handler of handlers) { try { @@ -61,9 +108,17 @@ export function installUnhandledRejectionHandler(): void { process.on("unhandledRejection", (reason, _promise) => { if (isUnhandledRejectionHandled(reason)) return; - // Don't crash on recoverable/transient errors - log them and continue - if (isRecoverableError(reason)) { - console.error("[clawdbot] Recoverable error (not crashing):", formatUncaughtError(reason)); + // AbortError is typically an intentional cancellation (e.g., during shutdown) + // Log it but don't crash - these are expected during graceful shutdown + if (isAbortError(reason)) { + console.warn("[clawdbot] Suppressed AbortError:", formatUncaughtError(reason)); + return; + } + + // Transient network errors (fetch failed, connection reset, etc.) shouldn't crash + // These are temporary connectivity issues that will resolve on their own + if (isTransientNetworkError(reason)) { + console.error("[clawdbot] Network error (non-fatal):", formatUncaughtError(reason)); return; } diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d1c2a00ca..ae6d61106 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -103,7 +103,7 @@ function buildSlackCommandArgMenuBlocks(params: { title: string; command: string; arg: string; - choices: string[]; + choices: Array<{ value: string; label: string }>; userId: string; }) { const rows = chunkItems(params.choices, 5).map((choices) => ({ @@ -111,11 +111,11 @@ function buildSlackCommandArgMenuBlocks(params: { elements: choices.map((choice) => ({ type: "button", action_id: SLACK_COMMAND_ARG_ACTION_ID, - text: { type: "plain_text", text: choice }, + text: { type: "plain_text", text: choice.label }, value: encodeSlackCommandArgValue({ command: params.command, arg: params.arg, - value: choice, + value: choice.value, userId: params.userId, }), })), diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index c33f1e18e..e9d287d0d 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -366,10 +366,10 @@ export const registerTelegramNativeCommands = ({ rows.push( slice.map((choice) => { const args: CommandArgs = { - values: { [menu.arg.name]: choice }, + values: { [menu.arg.name]: choice.value }, }; return { - text: choice, + text: choice.label, callback_data: buildCommandTextFromArgs(commandDefinition, args), }; }), diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 847876d04..9507c5535 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -40,7 +40,7 @@ import { resolveModel } from "../agents/pi-embedded-runner/model.js"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TTS_MAX_LENGTH = 1500; const DEFAULT_TTS_SUMMARIZE = true; -const DEFAULT_MAX_TEXT_LENGTH = 4000; +const DEFAULT_MAX_TEXT_LENGTH = 4096; const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io"; @@ -1386,32 +1386,34 @@ export async function maybeApplyTtsToPayload(params: { if (textForAudio.length > maxLength) { if (!isSummarizationEnabled(prefsPath)) { + // Truncate text when summarization is disabled logVerbose( - `TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`, + `TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`, ); - return nextPayload; - } - - try { - const summary = await summarizeText({ - text: textForAudio, - targetLength: maxLength, - cfg: params.cfg, - config, - timeoutMs: config.timeoutMs, - }); - textForAudio = summary.summary; - wasSummarized = true; - if (textForAudio.length > config.maxTextLength) { - logVerbose( - `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`, - ); - textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`; + textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`; + } else { + // Summarize text when enabled + try { + const summary = await summarizeText({ + text: textForAudio, + targetLength: maxLength, + cfg: params.cfg, + config, + timeoutMs: config.timeoutMs, + }); + textForAudio = summary.summary; + wasSummarized = true; + if (textForAudio.length > config.maxTextLength) { + logVerbose( + `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`, + ); + textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`; + } + } catch (err) { + const error = err as Error; + logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`); + textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`; } - } catch (err) { - const error = err as Error; - logVerbose(`TTS: summarization failed: ${error.message}`); - return nextPayload; } } @@ -1436,12 +1438,12 @@ export async function maybeApplyTtsToPayload(params: { const channelId = resolveChannelId(params.channel); const shouldVoice = channelId === "telegram" && result.voiceCompatible === true; - - return { + const finalPayload = { ...nextPayload, mediaUrl: result.audioPath, audioAsVoice: shouldVoice || params.payload.audioAsVoice, }; + return finalPayload; } lastTtsAttempt = {