fix(gateway): preserve err.stack when chat.send/agent attachment parsing fails

Co-authored-by: keen0206 <233564226+keen0206@users.noreply.github.com>
This commit is contained in:
clawsweeper
2026-05-03 01:19:43 +00:00
parent d7dbf11504
commit 4d8465b013
5 changed files with 120 additions and 3 deletions

View File

@@ -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.

View File

@@ -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<typeof vi.fn>).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 });

View File

@@ -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<GatewayRequestContext["logGateway"], "error">,
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,

View File

@@ -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,6 +2812,17 @@ 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" }]);
});

View File

@@ -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<GatewayRequestContext["logGateway"], "error">,
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,