import { UserError } from "@openagentpack/sdk"; import { BailianError, ExitCode } from "bailian-cli-core"; import { expect, test } from "vite-plus/test"; import { formatAgentDiagnosticFailure, retainAgentError, withAgentErrors, } from "../src/commands/managed-agent/_engine/errors.ts"; /** * Structural stand-in for the SDK's internal `ApiError` (not exported by the * installed SDK version): withAgentErrors matches on statusCode/responseBody * fields, so any Error carrying them must map through mapApiError. */ class FakeSdkApiError extends Error { constructor( readonly statusCode: number, readonly responseBody: string, ) { super(`Bailian API ${statusCode}: ${responseBody}`); } } async function catchMapped(error: unknown): Promise { try { await withAgentErrors(() => Promise.reject(error)); } catch (mapped) { expect(mapped).toBeInstanceOf(BailianError); return mapped as BailianError; } throw new Error("expected withAgentErrors to throw"); } test("BailianError passes through untouched", async () => { const original = new BailianError("already mapped", ExitCode.AUTH); const mapped = await catchMapped(original); expect(mapped).toBe(original); }); test("SDK UserError maps to USAGE", async () => { const mapped = await catchMapped(new UserError("bad agents.yaml")); expect(mapped.exitCode).toBe(ExitCode.USAGE); expect(mapped.message).toBe("bad agents.yaml"); }); test("SDK polling-timeout UserError maps to TIMEOUT (5), not USAGE", async () => { // 消息形状来自 SDK session-runtime 的 assertNotTimedOut —— 客户端等待超时, // 按 bl 错误边界必须归 TIMEOUT,不能告诉自动化调用方“参数错误”。 const mapped = await catchMapped( new UserError("Session did not complete within the timeout (600 seconds)."), ); expect(mapped.exitCode).toBe(ExitCode.TIMEOUT); expect(mapped.message).toBe("Session did not complete within the timeout (600 seconds)."); expect(mapped.hint).toMatch(/session get/); }); test("提及 timeout 但非轮询超时句式的 UserError 仍归 USAGE", async () => { const mapped = await catchMapped(new UserError("Invalid timeout value in agents.yaml")); expect(mapped.exitCode).toBe(ExitCode.USAGE); }); test("SDK ApiError with DashScope-style JSON body surfaces clean message and api metadata", async () => { const body = JSON.stringify({ code: "InvalidParameter", message: "agent name already exists", request_id: "req-123", }); const mapped = await catchMapped(new FakeSdkApiError(400, body)); expect(mapped.exitCode).toBe(ExitCode.GENERAL); expect(mapped.message).toBe("agent name already exists"); expect(mapped.api).toEqual({ httpStatus: 400, apiCode: "InvalidParameter", requestId: "req-123", }); }); test("SDK ApiError with OpenAI-style error envelope extracts message and type", async () => { const body = JSON.stringify({ error: { message: "model does not exist", type: "invalid_request_error" }, request_id: "req-456", }); const mapped = await catchMapped(new FakeSdkApiError(404, body)); expect(mapped.exitCode).toBe(ExitCode.GENERAL); expect(mapped.message).toBe("model does not exist"); expect(mapped.api?.apiCode).toBe("invalid_request_error"); expect(mapped.api?.requestId).toBe("req-456"); }); test("SDK ApiError with non-JSON body passes raw text through as message", async () => { const mapped = await catchMapped(new FakeSdkApiError(502, "Bad Gateway")); expect(mapped.exitCode).toBe(ExitCode.GENERAL); expect(mapped.message).toBe("Bad Gateway"); expect(mapped.api?.httpStatus).toBe(502); }); test("SDK ApiError with empty body falls back to HTTP status message", async () => { const mapped = await catchMapped(new FakeSdkApiError(503, "")); expect(mapped.message).toBe("HTTP 503"); expect(mapped.api?.httpStatus).toBe(503); }); test("plain Error maps to GENERAL with message passed through", async () => { const mapped = await catchMapped(new Error("boom")); expect(mapped.exitCode).toBe(ExitCode.GENERAL); expect(mapped.message).toBe("boom"); expect(mapped.api).toBeUndefined(); }); test("fetch transport TypeError is rethrown untouched for the runtime NETWORK mapping", async () => { const transportError = new TypeError("fetch failed", { cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1"), { code: "ECONNREFUSED", }), }); try { await withAgentErrors(() => Promise.reject(transportError)); throw new Error("expected withAgentErrors to throw"); } catch (error) { expect(error).toBe(transportError); } }); test("diagnostic failure surfaces the first error and reports the remaining count", () => { const message = formatAgentDiagnosticFailure( [ { severity: "warning", code: "warning.code", message: "warning" }, { severity: "error", code: "first.code", message: "first failure" }, { severity: "error", code: "second.code", message: "second failure" }, { severity: "error", code: "third.code", message: "third failure" }, ], "fallback", ); expect(message).toBe("[first.code] first failure (+2 more errors)"); }); test("diagnostic failure uses its fallback only when no error detail exists", () => { expect( formatAgentDiagnosticFailure( [{ severity: "warning", code: "warning.code", message: "warning" }], "fallback", ), ).toBe("fallback"); }); test("retaining an Agent error adds a hint without losing structured context", () => { const cause = new Error("provider cause"); const original = new BailianError("provider failure", ExitCode.GENERAL, undefined, { api: { httpStatus: 500, apiCode: "PROVIDER_FAILURE", requestId: "req-retain" }, rawResponse: "raw provider response", cause, }); const retained = retainAgentError(original, "retry the create", "fallback"); expect(retained).not.toBe(original); expect(retained.message).toBe("provider failure"); expect(retained.exitCode).toBe(ExitCode.GENERAL); expect(retained.hint).toBe("retry the create"); expect(retained.api).toEqual(original.api); expect(retained.rawResponse).toBe("raw provider response"); expect(retained.cause).toBe(cause); });