diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b901c90fa..e58206df890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway: preserve stack diagnostics when `chat.send` or agent attachment parsing/staging fails, improving image-send failure triage. Refs #63432. (#75135) Thanks @keen0206. - Maintainer workflow: push prepared PR heads through GitHub's verified commit API by default and require an explicit override before git-protocol pushes can publish unsigned commits. Thanks @BunsDev. - Feishu: resolve setup/status probes through the selected/default account so multi-account configs with account-scoped app credentials show as configured and probeable. Fixes #72930. Thanks @brokemac79. - Gateway/responses: emit every client tool call from `/v1/responses` JSON and SSE responses when the agent invokes multiple client tools in a single turn, so multi-tool plans, graph orchestration calls, and similar batched flows no longer drop every call but the last. Fixes #52288. Thanks @CharZhou and @bonelli. diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 3e9ee2f5f53..61de662839d 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -1038,6 +1038,52 @@ describe("gateway agent handler", () => { expect(mocks.agentCommand).not.toHaveBeenCalled(); }); + it("logs attachment parse failures with stack details", async () => { + primeMainAgentRun(); + mocks.agentCommand.mockClear(); + const context = makeContext(); + const respond = vi.fn(); + + await invokeAgent( + { + message: "inspect this", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: "test-agent-attachment-parse-stack", + attachments: [ + { + type: "file", + mimeType: "image/png", + fileName: "broken.png", + content: "not-base64", + }, + ], + }, + { respond, context, reqId: "agent-attachment-parse-stack", flushDispatch: false }, + ); + + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + message: expect.stringContaining("attachment broken.png: invalid base64 content"), + }), + ); + expect(context.logGateway.error).toHaveBeenCalledWith( + "agent attachment parse failed", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "agent attachment parse failed: Error: attachment broken.png", + ), + error: expect.stringContaining("Error: attachment broken.png: invalid base64 content"), + }), + ); + const logMeta = (context.logGateway.error as unknown as ReturnType).mock + .calls[0]?.[1] as { error?: string } | undefined; + expect(logMeta?.error).toContain("\n at "); + }); + it("keeps model-run gateway prompts undecorated and forwards raw-run flags", async () => { setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z"); primeMainAgentRun({ cfg: mocks.loadConfigReturn }); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 911924d9196..3e254bf11d4 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -41,6 +41,7 @@ import { } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; +import { formatUncaughtError } from "../../infra/errors.js"; import { resolveAgentDeliveryPlan, resolveAgentOutboundTarget, @@ -126,10 +127,38 @@ import { waitForTerminalGatewayDedupe, } from "./agent-wait-dedupe.js"; import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; -import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; +import type { + GatewayRequestContext, + GatewayRequestHandlerOptions, + GatewayRequestHandlers, +} from "./types.js"; const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i; +function formatAttachmentFailureForLog(err: unknown): string { + const primary = formatUncaughtError(err); + const cause = err instanceof Error ? err.cause : undefined; + if (cause === undefined) { + return primary; + } + const causeText = formatUncaughtError(cause); + if (!causeText || causeText === primary) { + return primary; + } + return `${primary}\nCaused by: ${causeText}`; +} + +function logAttachmentFailure( + logGateway: Pick, + label: string, + err: unknown, +): void { + logGateway.error(label, { + error: formatAttachmentFailureForLog(err), + consoleMessage: `${label}: ${formatForLog(err)}`, + }); +} + function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["client"]): boolean { const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; return scopes.includes(ADMIN_SCOPE); @@ -632,6 +661,7 @@ export const agentHandlers: GatewayRequestHandlers = { // MediaOffloadError indicates a server-side storage fault (ENOSPC, EPERM, // etc.). Map it to UNAVAILABLE so clients can retry without treating it as // a bad request. All other errors are input-validation failures → 4xx. + logAttachmentFailure(context.logGateway, "agent attachment parse failed", err); const isServerFault = err instanceof MediaOffloadError; respond( false, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 87a2da9484a..fe26ee538f2 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -417,6 +417,7 @@ function createChatContext(): Pick< logGateway: { warn: vi.fn(), debug: vi.fn(), + error: vi.fn(), } as unknown as GatewayRequestContext["logGateway"], }; } @@ -2772,9 +2773,12 @@ describe("chat directive tag stripping for non-streaming final payloads", () => { path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" }, ]; mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" }; - mockState.stageSandboxMediaError = Object.assign(new Error("ENOSPC: no space left on device"), { + const stageError = Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC", }); + stageError.stack = + "Error: ENOSPC: no space left on device\n at stageSandboxMedia (stage-sandbox-media.ts:1:1)"; + mockState.stageSandboxMediaError = stageError; const respond = vi.fn(); const context = createChatContext(); const pdf = Buffer.from("%PDF-1.4\n%µ¶\n1 0 obj\n<<>>\nendobj\n").toString("base64"); @@ -2808,10 +2812,68 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(payload).toBeUndefined(); expect(error?.code).toBe(ErrorCodes.UNAVAILABLE); expect(error?.message ?? String(error)).toMatch(/ENOSPC|non-image attachments/i); + expect(context.logGateway.error).toHaveBeenCalledWith( + "chat.send attachment parse/stage failed", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "chat.send attachment parse/stage failed: MediaOffloadError", + ), + error: expect.stringContaining( + "Caused by: Error: ENOSPC: no space left on device\n at stageSandboxMedia", + ), + }), + ); // Orphaned media-store files are cleaned up before the 5xx surfaces. expect(mockState.deleteMediaBufferCalls).toEqual([{ id: "saved-media", subdir: "inbound" }]); }); + it("logs chat.send attachment parse failures with stack details", async () => { + createTranscriptFixture("openclaw-chat-send-attachment-parse-stack-"); + const respond = vi.fn(); + const context = createChatContext(); + + await runNonStreamingChatSend({ + context, + respond, + idempotencyKey: "idem-chat-send-attachment-parse-stack", + message: "inspect this", + requestParams: { + attachments: [ + { + type: "file", + mimeType: "image/png", + fileName: "broken.png", + content: "not-base64", + }, + ], + }, + expectBroadcast: false, + waitFor: "none", + }); + + expect(mockState.lastDispatchCtx).toBeUndefined(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.INVALID_REQUEST, + message: expect.stringContaining("attachment broken.png: invalid base64 content"), + }), + ); + expect(context.logGateway.error).toHaveBeenCalledWith( + "chat.send attachment parse/stage failed", + expect.objectContaining({ + consoleMessage: expect.stringContaining( + "chat.send attachment parse/stage failed: Error: attachment broken.png", + ), + error: expect.stringContaining("Error: attachment broken.png: invalid base64 content"), + }), + ); + const logMeta = (context.logGateway.error as unknown as ReturnType).mock + .calls[0]?.[1] as { error?: string } | undefined; + expect(logMeta?.error).toContain("\n at "); + }); + it("surfaces partial non-image staging failures as 5xx UNAVAILABLE", async () => { // Regression: stageSandboxMedia keeps unstaged entries as their original // absolute path, so a simple `stagedPaths.length === nonImage.length` diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 662c4fd2367..7ce6daeb332 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -15,7 +15,7 @@ import type { MsgContext, TemplateContext } from "../../auto-reply/templating.js import { extractCanvasFromText } from "../../chat/canvas-render.js"; import { resolveSessionFilePath } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { formatErrorMessage } from "../../infra/errors.js"; +import { formatErrorMessage, formatUncaughtError } from "../../infra/errors.js"; import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; @@ -242,6 +242,30 @@ type ChatSendExplicitOrigin = { messageThreadId?: string; }; +function formatAttachmentFailureForLog(err: unknown): string { + const primary = formatUncaughtError(err); + const cause = err instanceof Error ? err.cause : undefined; + if (cause === undefined) { + return primary; + } + const causeText = formatUncaughtError(cause); + if (!causeText || causeText === primary) { + return primary; + } + return `${primary}\nCaused by: ${causeText}`; +} + +function logAttachmentFailure( + logGateway: Pick, + label: string, + err: unknown, +): void { + logGateway.error(label, { + error: formatAttachmentFailureForLog(err), + consoleMessage: `${label}: ${formatForLog(err)}`, + }); +} + type SideResultPayload = { kind: "btw"; runId: string; @@ -2041,6 +2065,7 @@ export const chatHandlers: GatewayRequestHandlers = { agentId, })); } catch (err) { + logAttachmentFailure(context.logGateway, "chat.send attachment parse/stage failed", err); respond( false, undefined,