mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user