From 46be5eac7d53b4b7826c59a744ba01d0b885ab10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Nov 2025 17:52:57 +0100 Subject: [PATCH] Auto-reply: send timeout fallback and tests --- CHANGELOG.md | 3 ++- src/auto-reply/reply.ts | 8 ++++++ src/index.core.test.ts | 60 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba971847..d67c62382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## [Unreleased] 0.1.4 ### Pending -- (add entries here) +- Auto-replies now send a WhatsApp fallback message when a command/Claude run hits the timeout, including up to 800 chars of partial stdout so the user still sees progress. +- Added tests covering the new timeout fallback behavior and partial-output truncation. ## 0.1.3 — 2025-11-25 diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index e6178a603..518b95eb4 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -386,6 +386,14 @@ export async function getReplyFromConfig( console.error( `Command auto-reply timed out after ${elapsed}ms (limit ${timeoutMs}ms)`, ); + const baseMsg = `Command timed out after ${timeoutSeconds}s. Try a shorter prompt or split the request.`; + const partial = errorObj.stdout?.trim(); + const partialSnippet = + partial && partial.length > 800 ? `${partial.slice(0, 800)}...` : partial; + const text = partialSnippet + ? `${baseMsg}\n\nPartial output before timeout:\n${partialSnippet}` + : baseMsg; + return { text }; } else { logError( `Command auto-reply failed after ${elapsed}ms: ${String(err)}`, diff --git a/src/index.core.test.ts b/src/index.core.test.ts index 820cedb22..1e134cf5d 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -300,6 +300,66 @@ describe("config and templating", () => { expect(result?.mediaUrl).toBeUndefined(); }); + it("returns timeout reply with partial stdout snippet", async () => { + const partial = "x".repeat(900); + const runSpy = vi.fn().mockRejectedValue({ + killed: true, + signal: "SIGKILL", + stdout: partial, + stderr: "", + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + timeoutSeconds: 42, + }, + }, + }; + + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + + expect(result?.text).toContain("Command timed out after 42s"); + expect(result?.text).toContain("Partial output before timeout"); + expect(result?.text).toContain(`${partial.slice(0, 800)}...`); + expect(result?.text).not.toContain(partial); + }); + + it("returns timeout reply without partial output when none is available", async () => { + const runSpy = vi.fn().mockRejectedValue({ + killed: true, + signal: "SIGKILL", + stdout: "", + stderr: "", + }); + const cfg = { + inbound: { + reply: { + mode: "command" as const, + command: ["echo", "{{Body}}"], + timeoutSeconds: 5, + }, + }, + }; + + const result = await index.getReplyFromConfig( + { Body: "hi", From: "+1", To: "+2" }, + undefined, + cfg, + runSpy, + ); + + expect(result?.text).toBe( + "Command timed out after 5s. Try a shorter prompt or split the request.", + ); + }); + it("splitMediaFromOutput strips media token and preserves text", () => { const { text, mediaUrl } = splitMediaFromOutput( "line1\nMEDIA:https://x/y.png\nline2",