diff --git a/src/acp/runtime/error-text.test.ts b/src/acp/runtime/error-text.test.ts index 14f7ff91f55..80d3eea39e5 100644 --- a/src/acp/runtime/error-text.test.ts +++ b/src/acp/runtime/error-text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatAcpRuntimeErrorText } from "./error-text.js"; -import { AcpRuntimeError } from "./errors.js"; +import { formatAcpRuntimeErrorText, toAcpRuntimeErrorText } from "./error-text.js"; +import { AcpRuntimeError, toAcpRuntimeError } from "./errors.js"; describe("formatAcpRuntimeErrorText", () => { it("adds actionable next steps for known ACP runtime error codes", () => { @@ -18,4 +18,49 @@ describe("formatAcpRuntimeErrorText", () => { "ACP error (ACP_TURN_FAILED): turn failed\nnext: Retry, or use `/acp cancel` and send the message again.", ); }); + + it("surfaces redacted numeric RequestError details in runtime failure text", () => { + const token = "sk-abcdefghijklmnopqrstuvwxyz123456"; + const requestError = Object.assign(new Error("Internal error"), { + name: "RequestError", + code: -32603, + data: { + details: `Unknown config option: timeout; token=${token}`, + }, + }); + + const text = formatAcpRuntimeErrorText( + toAcpRuntimeError({ + error: requestError, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }), + ); + + expect(text).toContain( + "ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout", + ); + expect(text).toContain("next: Retry"); + expect(text).not.toContain(token); + }); + + it("applies the same RequestError details normalization through text conversion", () => { + const requestError = Object.assign(new Error("Internal error"), { + name: "RequestError", + code: -32603, + data: { + details: "Unknown config option: timeout", + }, + }); + + const text = toAcpRuntimeErrorText({ + error: requestError, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }); + + expect(text).toContain( + "ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout", + ); + }); }); diff --git a/src/acp/runtime/errors.test.ts b/src/acp/runtime/errors.test.ts index c05ccfb705a..e91a0d2bb3b 100644 --- a/src/acp/runtime/errors.test.ts +++ b/src/acp/runtime/errors.test.ts @@ -3,6 +3,7 @@ import { AcpRuntimeError, formatAcpErrorChain, isAcpRuntimeError, + toAcpRuntimeError, withAcpRuntimeErrorBoundary, } from "./errors.js"; @@ -72,6 +73,65 @@ describe("withAcpRuntimeErrorBoundary", () => { expect(error.cause).toBe(foreignError); expect(isAcpRuntimeError(foreignError)).toBe(true); }); + + it("preserves redacted RequestError details from numeric ACP errors", () => { + const token = "sk-abcdefghijklmnopqrstuvwxyz123456"; + const requestError = Object.assign(new Error("Internal error"), { + name: "RequestError", + code: -32603, + data: { + details: `unknown config option: timeout; token=${token}`, + }, + }); + + const error = toAcpRuntimeError({ + error: requestError, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }); + + expect(error.code).toBe("ACP_TURN_FAILED"); + expect(error.message).toContain("Internal error: unknown config option: timeout"); + expect(error.message).not.toContain(token); + expect(error.cause).toBe(requestError); + }); + + it("keeps foreign OpenClaw ACP string code behavior unchanged", () => { + const foreignError = Object.assign(new Error("backend missing"), { + code: "ACP_BACKEND_MISSING", + data: { + details: "extra backend diagnostic", + }, + }); + + const error = toAcpRuntimeError({ + error: foreignError, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }); + + expect(error.code).toBe("ACP_BACKEND_MISSING"); + expect(error.message).toBe("backend missing"); + expect(error.cause).toBe(foreignError); + }); + + it("keeps generic non-RequestError messages unchanged", () => { + const sourceError = Object.assign(new Error("boom"), { + data: { + details: "extra diagnostic", + }, + }); + + const error = toAcpRuntimeError({ + error: sourceError, + fallbackCode: "ACP_TURN_FAILED", + fallbackMessage: "fallback", + }); + + expect(error.code).toBe("ACP_TURN_FAILED"); + expect(error.message).toBe("boom"); + expect(error.cause).toBe(sourceError); + }); }); describe("formatAcpErrorChain redaction", () => { diff --git a/src/acp/runtime/errors.ts b/src/acp/runtime/errors.ts index 2cfe3dbc4c8..d37e0991c75 100644 --- a/src/acp/runtime/errors.ts +++ b/src/acp/runtime/errors.ts @@ -43,6 +43,31 @@ function getForeignAcpRuntimeError(value: unknown): { }; } +function readAcpRequestErrorDetails(value: Error): string | undefined { + const code = (value as { code?: unknown }).code; + if (typeof code !== "number") { + return undefined; + } + const data = (value as { data?: unknown }).data; + if (!data || typeof data !== "object") { + return undefined; + } + const details = (data as { details?: unknown }).details; + if (details === undefined || details === null) { + return undefined; + } + const rendered = redactSensitiveText(stringifyNonErrorCause(details)).trim(); + return rendered.length > 0 ? rendered : undefined; +} + +function messageWithAcpRequestErrorDetails(error: Error): string { + const details = readAcpRequestErrorDetails(error); + if (!details || error.message.includes(details)) { + return error.message; + } + return `${error.message}: ${details}`; +} + export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError { return value instanceof AcpRuntimeError || getForeignAcpRuntimeError(value) !== null; } @@ -62,9 +87,13 @@ export function toAcpRuntimeError(params: { }); } if (params.error instanceof Error) { - return new AcpRuntimeError(params.fallbackCode, params.error.message, { - cause: params.error, - }); + return new AcpRuntimeError( + params.fallbackCode, + messageWithAcpRequestErrorDetails(params.error), + { + cause: params.error, + }, + ); } return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, { cause: params.error,