From 99693722253c3927cdb0a9f2567645165a3915a8 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sat, 13 Jun 2026 14:02:16 +0100 Subject: [PATCH] fix(run): emit session_error for session failures --- packages/cli/src/cli/cmd/run.errors.ts | 10 +++++-- packages/cli/src/cli/cmd/run.ts | 6 +++++ .../test/cli/classify-session-error.test.ts | 26 +++++++++++++++++++ packages/cli/test/cli/run-schema-v1.test.ts | 10 +++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/cmd/run.errors.ts b/packages/cli/src/cli/cmd/run.errors.ts index adb031e..229c061 100644 --- a/packages/cli/src/cli/cmd/run.errors.ts +++ b/packages/cli/src/cli/cmd/run.errors.ts @@ -15,6 +15,7 @@ export function classifySessionError(err: unknown): ClassifiedSessionError { if (status === 429) return { reason: "rate_limit", code: "429", message } if (status === 401 || status === 403) return { reason: "auth", code: String(status), message } + if (name === "ProviderAuthError") return { reason: "auth", code: status ? String(status) : undefined, message } if (name === "AbortError" || /timeout/i.test(message)) { return { reason: "timeout", code: status ? String(status) : undefined, message } } @@ -31,13 +32,18 @@ function extractMessage(err: unknown): string { if (err instanceof Error) return err.message if (typeof err === "string") return err if (err && typeof err === "object" && "message" in err) return String((err as { message: unknown }).message) + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data: unknown }).data + if (data && typeof data === "object" && "message" in data) return String((data as { message: unknown }).message) + } return String(err) } function extractStatus(err: unknown): number | undefined { if (err && typeof err === "object") { - const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } } - const raw = e.status ?? e.statusCode ?? e.response?.status + const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown }; data?: unknown } + const data = e.data && typeof e.data === "object" ? (e.data as { status?: unknown; statusCode?: unknown }) : undefined + const raw = e.status ?? e.statusCode ?? e.response?.status ?? data?.status ?? data?.statusCode if (typeof raw === "number") return raw if (typeof raw === "string" && /^\d+$/.test(raw)) return Number(raw) } diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index a4cfb2b..f237a1c 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -571,6 +571,12 @@ export const RunCommand = cmd({ // spuriously-green job. process.exitCode (not process.exit) lets the // loop drain to session.status idle and emit session_complete first. process.exitCode = 1 + const classified = classifySessionError(props.error) + emit("session_error", { + reason: classified.reason, + code: classified.code, + message: classified.message, + }) } if (emit("error", { error: props.error, sourceSessionID: props.sessionID })) continue UI.error(err) diff --git a/packages/cli/test/cli/classify-session-error.test.ts b/packages/cli/test/cli/classify-session-error.test.ts index b4a3d1d..f88c532 100644 --- a/packages/cli/test/cli/classify-session-error.test.ts +++ b/packages/cli/test/cli/classify-session-error.test.ts @@ -15,6 +15,18 @@ describe("classifySessionError (#63)", () => { expect(res.code).toBe("401") }) + test("stored ProviderAuthError → auth", () => { + const res = classifySessionError({ + name: "ProviderAuthError", + data: { + providerID: "openai", + message: "Invalid API key", + }, + }) + expect(res.reason).toBe("auth") + expect(res.message).toBe("Invalid API key") + }) + test("AbortError → timeout", () => { const err = new Error("aborted") err.name = "AbortError" @@ -34,4 +46,18 @@ describe("classifySessionError (#63)", () => { const res = classifySessionError({ status: 500, message: "internal" }) expect(res.reason).toBe("provider") }) + + test("stored APIError statusCode → provider", () => { + const res = classifySessionError({ + name: "APIError", + data: { + message: "internal", + statusCode: 500, + isRetryable: true, + }, + }) + expect(res.reason).toBe("provider") + expect(res.code).toBe("500") + expect(res.message).toBe("internal") + }) }) diff --git a/packages/cli/test/cli/run-schema-v1.test.ts b/packages/cli/test/cli/run-schema-v1.test.ts index 51a7b87..ecdd0f1 100644 --- a/packages/cli/test/cli/run-schema-v1.test.ts +++ b/packages/cli/test/cli/run-schema-v1.test.ts @@ -42,6 +42,16 @@ describe("run.ts v1 schema emissions (#63)", () => { expect(errIdx).toBeLessThan(source.lastIndexOf('emit("session_complete"')) }) + test("primary session.error events are surfaced as session_error", async () => { + const source = await Bun.file(RUN_SRC).text() + const idx = source.indexOf('if (event.type === "session.error")') + expect(idx).toBeGreaterThan(-1) + const block = source.slice(idx, idx + 1400) + expect(block).toContain("classifySessionError(props.error)") + expect(block).toContain('emit("session_error"') + expect(block).toContain('emit("error"') + }) + test("text / reasoning / tool_use include sequenceNum", async () => { const source = await Bun.file(RUN_SRC).text() expect(source).toContain("sequenceNum")