From 716f31f17a7f9688b693dbf6c632d3237544bace Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 06:33:49 +0100 Subject: [PATCH] auto-reply: handle empty stdout gracefully --- src/auto-reply/reply.ts | 41 ++++++++++++++++++----------------------- src/index.core.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index a5f109a09..bea1492e9 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -290,23 +290,14 @@ const mediaNote = ); const rawStdout = stdout.trim(); let mediaFromCommand: string | undefined; - const { text: trimmedText, mediaUrl: mediaDirect } = - splitMediaFromOutput(rawStdout); - mediaFromCommand = mediaDirect; - if (isVerbose()) { - logVerbose( - mediaFromCommand - ? `MEDIA token extracted from stdout: ${mediaFromCommand}` - : "No MEDIA token extracted from stdout", - ); - } - let trimmed = trimmedText; + let trimmed = rawStdout; if (stderr?.trim()) { logVerbose(`Command auto-reply stderr: ${stderr.trim()}`); } + let parsed: ClaudeJsonParseResult | undefined; if (trimmed && (reply.claudeOutputFormat === "json" || isClaudeInvocation)) { // Claude JSON mode: extract the human text for both logging and reply while keeping metadata. - const parsed = parseClaudeJson(trimmed); + parsed = parseClaudeJson(trimmed); if (parsed?.parsed && isVerbose()) { const summary = summarizeClaudeMetadata(parsed.parsed); if (summary) logVerbose(`Claude JSON meta: ${summary}`); @@ -319,21 +310,25 @@ const mediaNote = `Claude JSON parsed -> ${parsed.text.slice(0, 120)}${parsed.text.length > 120 ? "…" : ""}`, ); trimmed = parsed.text.trim(); - if (!mediaFromCommand) { - const { mediaUrl: mediaFromParsed } = splitMediaFromOutput( - parsed.text, - ); - if (mediaFromParsed) { - mediaFromCommand = mediaFromParsed; - logVerbose( - `MEDIA token extracted after JSON parse: ${mediaFromParsed}`, - ); - } - } } else { logVerbose("Claude JSON parse failed; returning raw stdout"); } } + // Run media extraction once on the final human text (post-JSON parse if available). + const { text: cleanedText, mediaUrl: mediaFound } = + splitMediaFromOutput(trimmed); + trimmed = cleanedText; + if (mediaFound) { + mediaFromCommand = mediaFound; + if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`); + } else if (isVerbose()) { + logVerbose("No MEDIA token extracted from final text"); + } + if (!trimmed && !mediaFromCommand) { + const meta = parsed ? summarizeClaudeMetadata(parsed.parsed) : undefined; + trimmed = `(command produced no output${meta ? `; ${meta}` : ""})`; + logVerbose("No text/media produced; injecting fallback notice to user"); + } logVerbose( `Command auto-reply stdout (trimmed): ${trimmed || ""}`, ); diff --git a/src/index.core.test.ts b/src/index.core.test.ts index 73e32773e..83d3ef643 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -274,6 +274,32 @@ describe("config and templating", () => { expect(result?.mediaUrl).toBeUndefined(); }); + it("injects fallback text when command returns nothing", async () => { + const runSpy = vi.spyOn(index, "runCommandWithTimeout").mockResolvedValue({ + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + }, + }, + }; + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + expect(result?.text).toContain("command produced no output"); + expect(result?.mediaUrl).toBeUndefined(); + }); + it("splitMediaFromOutput strips media token and preserves text", () => { const { text, mediaUrl } = splitMediaFromOutput( "line1\nMEDIA:https://x/y.png\nline2",