diff --git a/doc/run-log-events.md b/doc/run-log-events.md index 33648bb8ef0..12bbc26e666 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -485,6 +485,16 @@ byte budgets, including truncation markers. `retrievalTruncated` directs callers to the full adapter-bounded text in the run error or transcript. Other provider metadata and action payloads are not copied. +When an ACP request fails with a JSON-RPC error, the engine also keeps the +provider explanation. It reads a string `data`, or the string `data.details`, or +the string `data.message`, in that order. It adds the explanation on a new line +after the error message unless the message already contains it. The combined +text passes through the same redaction and the 4,096-character title bound +before it becomes the run error, the summary, and the `acpx.error` `message`. +A numeric JSON-RPC error code appears as the optional integer `rpcCode` in +`errorMeta` and in the `acpx.error` record. Errors without these fields keep +their existing message and omit `rpcCode`. + Recovery still uses the typed failure category and the adapter's existing classifier. Provider warnings do not become failures, and timeouts or lost control channels keep their authoritative failure messages. These diagnostics diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index d11ca17b494..c480b9fa002 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -2537,6 +2537,91 @@ describe("shared ACPX engine runtime behavior", () => { ).toBe("second"); }); + it.each(["create_runtime", "ensure_session", "configure_session", "turn"])( + "preserves sanitized JSON-RPC diagnostics when %s fails", + async (phase) => { + for (const shape of ["string", "details", "message"]) { + const root = await makeTempRoot(); + const detail = "Invalid session configuration: model_context_window must be a number; private-setting-value"; + const data = shape === "string" ? detail : { [shape]: detail }; + const err = Object.assign(new Error("Internal error"), { code: -32603, data }); + const fail = () => { throw err; }; + const logs: string[] = []; + const execute = createAcpxEngineExecutor({ + createRuntime: () => { + if (phase === "create_runtime") fail(); + return { + ...buildRuntime(), + ...(phase === "ensure_session" ? { ensureSession: fail } : {}), + ...(phase === "configure_session" ? { setConfigOption: fail } : {}), + ...(phase === "turn" ? { startTurn: () => ({ + events: (async function* () { fail(); })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }) } : {}), + } as never; + }, + }); + const result = await execute({ + runId: "run-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + thinkingEffort: "high", + env: { PRIVATE_SETTING: "private-setting-value" }, + }, + context: {}, + onLog: async (_stream: string, text: string) => { logs.push(text); }, + onMeta: async () => {}, + } as never); + expect(result.exitCode).toBe(1); + expect(result.errorMessage).toContain("Internal error\nInvalid session configuration: model_context_window must be a number;"); + expect(result.errorMessage).not.toContain("private-setting-value"); + expect(result.summary).toBe(result.errorMessage); + expect(result.errorMeta).toMatchObject({ phase, rpcCode: -32603 }); + const payload = JSON.parse(logs.find((text) => text.includes('"type":"acpx.error"'))!.trim()); + expect(payload.message).toBe(result.errorMessage); + expect(payload.rpcCode).toBe(-32603); + } + }, + ); + + it("does not repeat JSON-RPC details already in the message and leaves plain errors unchanged", async () => { + const run = async (err: Error) => { + const root = await makeTempRoot(); + const execute = createAcpxEngineExecutor({ + createRuntime: () => ({ + ...buildRuntime(), + ensureSession: async () => { throw err; }, + }) as never, + }); + return execute({ + runId: "run-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + }; + + const repeated = await run(Object.assign( + new Error("Internal error: Invalid session configuration"), + { code: -32603, data: { details: "Invalid session configuration" } }, + )); + expect(repeated.errorMessage).toBe("Internal error: Invalid session configuration"); + expect(repeated.errorMeta).toMatchObject({ rpcCode: -32603 }); + + const plain = await run(new Error("session/new failed")); + expect(plain.errorMessage).toBe("session/new failed"); + expect(plain.summary).toBe("session/new failed"); + expect(plain.errorMeta?.rpcCode).toBeUndefined(); + }); + it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index b48c1414ca1..9fe1f8184a8 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -3623,9 +3623,22 @@ async function emitAcpxFailure(input: { childStderrTail: string | null; }> { const { ctx, prepared, err, phase, messageOverride, suppressChildStderrTail } = input; + const requestError = parseObject(err); + const data = parseObject(requestError.data); + const detail = typeof requestError.data === "string" + ? requestError.data + : asString(data.details, asString(data.message, "")); const rawMessage = err instanceof Error ? err.message : String(err); - const message = messageOverride ?? rawMessage; + const message = messageOverride ?? (detail + ? sanitizeTerminalSessionFailure({ + category: "unknown", + title: rawMessage.includes(detail) ? rawMessage : `${rawMessage}\n${detail}`, + }, prepared.env, ctx.authToken, parseObject(ctx.config.env)).title ?? "ACP request failed." + : rawMessage); const classified = classifyError(err, phase); + if (typeof requestError.code === "number" && Number.isSafeInteger(requestError.code)) { + classified.errorMeta = { ...classified.errorMeta, rpcCode: requestError.code }; + } const childStderrTail = suppressChildStderrTail ? null : await readChildStderrTail({ logPath: prepared.childStderrLogPath });