diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index d159d1f78..de7e3098f 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -10,6 +10,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; +import { describeNetworkError } from "../../infra/errors.js"; import { resolveAgentDeliveryPlan, resolveAgentOutboundTarget, @@ -398,11 +399,12 @@ export const agentHandlers: GatewayRequestHandlers = { respond(true, payload, undefined, { runId }); }) .catch((err) => { - const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + const summary = describeNetworkError(err); + const error = errorShape(ErrorCodes.UNAVAILABLE, summary); const payload = { runId, status: "error" as const, - summary: String(err), + summary, }; context.dedupe.set(`agent:${idem}`, { ts: Date.now(), diff --git a/src/infra/errors.test.ts b/src/infra/errors.test.ts new file mode 100644 index 000000000..f1b07cbc8 --- /dev/null +++ b/src/infra/errors.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + describeNetworkError, + extractErrorCode, + formatErrorMessage, + formatUncaughtError, +} from "./errors.js"; + +describe("extractErrorCode", () => { + it("returns string code", () => { + expect(extractErrorCode({ code: "ECONNREFUSED" })).toBe("ECONNREFUSED"); + }); + it("returns number code as string", () => { + expect(extractErrorCode({ code: 42 })).toBe("42"); + }); + it("returns undefined for missing code", () => { + expect(extractErrorCode({})).toBeUndefined(); + }); + it("returns undefined for non-object", () => { + expect(extractErrorCode(null)).toBeUndefined(); + expect(extractErrorCode("str")).toBeUndefined(); + }); +}); + +describe("formatErrorMessage", () => { + it("extracts Error.message", () => { + expect(formatErrorMessage(new Error("boom"))).toBe("boom"); + }); + it("returns string as-is", () => { + expect(formatErrorMessage("oops")).toBe("oops"); + }); + it("stringifies primitives", () => { + expect(formatErrorMessage(123)).toBe("123"); + }); +}); + +describe("formatUncaughtError", () => { + it("returns message for INVALID_CONFIG", () => { + const err = Object.assign(new Error("bad config"), { code: "INVALID_CONFIG" }); + expect(formatUncaughtError(err)).toBe("bad config"); + }); + it("returns stack for generic errors", () => { + const err = new Error("fail"); + expect(formatUncaughtError(err)).toContain("fail"); + }); +}); + +describe("describeNetworkError", () => { + it("returns plain message when no cause", () => { + const err = new Error("Connection error."); + expect(describeNetworkError(err)).toBe("Connection error."); + }); + + it("appends cause error code", () => { + const cause = Object.assign(new Error("fetch failed"), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + const err = Object.assign(new Error("Connection error."), { cause }); + expect(describeNetworkError(err)).toBe("Connection error. (UNABLE_TO_VERIFY_LEAF_SIGNATURE)"); + }); + + it("appends cause message when no code", () => { + const cause = new Error("fetch failed"); + const err = Object.assign(new Error("Connection error."), { cause }); + expect(describeNetworkError(err)).toBe("Connection error. (fetch failed)"); + }); + + it("prefers code over message", () => { + const cause = Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }); + const err = Object.assign(new Error("Connection error."), { cause }); + expect(describeNetworkError(err)).toBe("Connection error. (UND_ERR_CONNECT_TIMEOUT)"); + }); + + it("does not duplicate detail already in message", () => { + const cause = new Error("Connection error."); + const err = Object.assign(new Error("Connection error."), { cause }); + expect(describeNetworkError(err)).toBe("Connection error."); + }); + + it("handles non-Error cause with code", () => { + const cause = { code: "ECONNREFUSED" }; + const err = Object.assign(new Error("Connection error."), { cause }); + expect(describeNetworkError(err)).toBe("Connection error. (ECONNREFUSED)"); + }); + + it("handles non-error input", () => { + expect(describeNetworkError("plain string")).toBe("plain string"); + }); +}); diff --git a/src/infra/errors.ts b/src/infra/errors.ts index 9175b3622..fa1aa50b8 100644 --- a/src/infra/errors.ts +++ b/src/infra/errors.ts @@ -21,6 +21,18 @@ export function formatErrorMessage(err: unknown): string { } } +/** Extract diagnostic detail from a network error's cause chain (e.g. APIConnectionError). */ +export function describeNetworkError(err: unknown): string { + const msg = formatErrorMessage(err); + const cause = (err as { cause?: unknown })?.cause; + if (!cause) return msg; + const code = extractErrorCode(cause); + const causeMsg = cause instanceof Error ? cause.message : undefined; + const detail = code ?? causeMsg; + if (!detail || msg.includes(detail)) return msg; + return `${msg} (${detail})`; +} + export function formatUncaughtError(err: unknown): string { if (extractErrorCode(err) === "INVALID_CONFIG") { return formatErrorMessage(err);