diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1b3b024..61576172d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **Thinking directives & state:** `/t|/think|/thinking ` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi/Tau get `--thinking ` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`. - **Group chats (web provider):** New `inbound.groupChat` config (requireMention, mentionPatterns, historyLimit). Warelay now listens to WhatsApp groups, only replies when mentioned, injects recent group history into the prompt, and keeps group sessions separate from personal chats; heartbeats are skipped for group threads. - **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker. +- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped. - **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi/Tau, etc.) are forwarded as metadata-only `[🛠️ ]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: ` hint. - **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise. - **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged). diff --git a/src/index.core.test.ts b/src/index.core.test.ts index ae70814b0..5828b1a4b 100644 --- a/src/index.core.test.ts +++ b/src/index.core.test.ts @@ -763,7 +763,7 @@ describe("config and templating", () => { expect(rpcSpy).toHaveBeenCalled(); const payloads = Array.isArray(res) ? res : res ? [res] : []; expect(payloads.length).toBeGreaterThanOrEqual(2); - expect(payloads[0]?.text).toBe("[🛠️ bash] ls"); + expect(payloads[0]?.text).toBe("💻 ls — “ls output”"); expect(payloads[1]?.text).toContain("summary"); }); diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index d992fd174..8a5390757 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -699,7 +699,60 @@ describe("web auto-reply", () => { }); expect(sendMedia).toHaveBeenCalledTimes(1); - expect(reply).toHaveBeenCalledWith("hi"); + const fallback = reply.mock.calls[0]?.[0] as string; + expect(fallback).toContain("hi"); + expect(fallback).toContain("Media failed"); + fetchMock.mockRestore(); + }); + + it("returns a warning when remote media fetch 404s", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ + text: "caption", + mediaUrl: "https://example.com/missing.jpg", + }); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 404, + body: null, + arrayBuffer: async () => new ArrayBuffer(0), + headers: { get: () => "text/plain" }, + } as unknown as Response); + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg1", + sendComposing, + reply, + sendMedia, + }); + + expect(sendMedia).not.toHaveBeenCalled(); + const fallback = reply.mock.calls[0]?.[0] as string; + expect(fallback).toContain("caption"); + expect(fallback).toContain("Media failed"); + expect(fallback).toContain("404"); + fetchMock.mockRestore(); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 06f691994..4149d4223 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -559,7 +559,15 @@ async function deliverWebReply(params: { ); replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); if (index === 0) { - const fallbackText = remainingText.shift() ?? caption ?? ""; + const warning = + err instanceof Error + ? `⚠️ Media failed: ${err.message}` + : "⚠️ Media failed."; + const fallbackTextParts = [ + remainingText.shift() ?? caption ?? "", + warning, + ].filter(Boolean); + const fallbackText = fallbackTextParts.join("\n"); if (fallbackText) { console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`); await msg.reply(fallbackText);