From eb9ccea8545cffce574df3b251de227455db4371 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:39:46 +0900 Subject: [PATCH 01/11] fix(claude-sdk-oauth): surface SDK error text and classify is_error results Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../builtin/claude-sdk-oauth/auth-lane.ts | 25 +++---- .../builtin/claude-sdk-oauth/errors.ts | 34 +++++++++- .../builtin/claude-sdk-oauth/guidance.ts | 10 +++ .../claude-sdk-oauth/session-registry-pump.ts | 11 +++- .../claude-sdk-oauth/session-turn-attempt.ts | 5 +- .../claude-sdk-oauth/stream-guidance.ts | 6 +- .../builtin/claude-sdk-oauth/stream.ts | 14 ++-- .../test/claude-sdk-oauth-errors.test.ts | 61 +++++++++++++++++ ...1169-claude-sdk-oauth-result-error.test.ts | 65 +++++++++++++++++++ ...laude-sdk-oauth-version-floor-text.test.ts | 36 ++++++++++ 10 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 packages/coding-agent/test/claude-sdk-oauth-errors.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts index c95885ec8f..4c056f55d7 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts @@ -17,7 +17,7 @@ import { selectAccount } from "./affinity.ts"; import { type AuthenticatedAttemptInput, createAttemptMessages, type RetainableAttempt } from "./auth-attempt.ts"; import { hasRequestOauthToken, mergeRequestAuthEnvironment, stripManagedAuthEnvironment } from "./auth-environment.ts"; import { writeConfigDirCredential } from "./config-dir-credentials.ts"; -import { classifySdkError } from "./errors.ts"; +import { classifySdkError, sdkAssistantFailure, sdkResultFailure } from "./errors.ts"; import { runFailover } from "./failover.ts"; import { refusalError } from "./refusal.ts"; import type { Options, SDKMessage, SdkQuery } from "./sdk-boundary.ts"; @@ -143,7 +143,12 @@ async function prepareSlot( Object.assign(slot, updated); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`authentication_failed: ${detail}`); + const classification = classifySdkError(detail); + throw new Error( + classification.kind === "other" && classification.retryable + ? `server_error: ${detail}` + : `authentication_failed: ${detail}`, + ); } } const access = slot.source === "env" ? envSlotToken((name) => environment[name], slot.name) : slot.access; @@ -157,20 +162,8 @@ async function prepareSlot( function sdkFailure(message: SDKMessage): unknown | undefined { const refusal = refusalError(message); if (refusal) return refusal; - if (message.type === "assistant" && message.error) return message.error; - if (message.type === "result" && message.subtype !== "success") { - const errors = "errors" in message && Array.isArray(message.errors) ? (message.errors as unknown[]) : []; - if (errors.length > 0) return new Error(String(errors[0])); - // `subtype` alone is too coarse to classify: a subscription limit and an - // ordinary tool failure both arrive as "error_during_execution". The SDK - // carries the real cause in `terminal_reason` (e.g. "blocking_limit"), so - // append it — otherwise classifySdkError() scores every result error as - // non-retryable "other", the exhausted account is never blocked, and a - // multi-account pool never rotates past it. - const reason = - "terminal_reason" in message && typeof message.terminal_reason === "string" ? message.terminal_reason : ""; - return new Error(reason ? `Claude Code ${message.subtype}: ${reason}` : `Claude Code ${message.subtype}`); - } + if (message.type === "assistant") return sdkAssistantFailure(message); + if (message.type === "result") return sdkResultFailure(message); return undefined; } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index 0cad7c0d6f..ec76724bc5 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -1,4 +1,5 @@ import type { SDKAssistantMessageError } from "@anthropic-ai/claude-agent-sdk"; +import type { SDKMessage } from "./sdk-boundary.ts"; export type SdkErrorKind = "rate_limit" | "overloaded" | "auth_error" | "billing" | "org_not_allowed" | "other"; @@ -15,6 +16,9 @@ const SDK_ERROR_CLASSIFICATIONS: Partial): Error | undefined { + if (message.subtype === "success" && message.is_error !== true) return undefined; + const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : []; + const firstError = errors.find((error): error is string => typeof error === "string" && error.length > 0); + const resultText = "result" in message && typeof message.result === "string" ? message.result.trim() : ""; + const detail = firstError ?? (message.is_error === true && resultText ? resultText : undefined) ?? `Claude Code ${message.subtype}`; + const status = "api_error_status" in message && message.api_error_status != null ? `HTTP ${message.api_error_status}` : ""; + const reason = "terminal_reason" in message && typeof message.terminal_reason === "string" ? message.terminal_reason : ""; + const suffix = [status, reason].filter(Boolean).join(", "); + return new Error(suffix ? `${detail} (${suffix})` : detail); +} + +export function sdkAssistantFailure(message: Extract): Error | undefined { + if (!message.error) return undefined; + const content = message.message.content; + const text = (typeof content === "string" ? content : content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map((block) => block.text) + .join(" ")).trim(); + return new Error(text ? (message.error === "unknown" ? text : `${text} (${message.error})`) : message.error); +} + /** Classifies Claude SDK OAuth error codes and HTTP-shaped fallback text in one place. */ export function classifySdkError(error: unknown): SdkErrorClassification { const text = errorText(error).toLowerCase(); + if (/\b(enotfound|eai_again|econnreset|econnrefused|etimedout|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|socket hang up|connection reset by peer/.test(text)) { + return { kind: "other", retryable: true }; + } for (const [code, classification] of Object.entries(SDK_ERROR_CLASSIFICATIONS)) { - if (new RegExp(`\\b${code}\\b`).test(text)) return classification; + if (new RegExp(`\\b${code}\\b`).test(text)) return classification ?? OTHER_ERROR; } if (/\b(?:http\s*)?429\b|too many requests|rate[ _-]?limit/.test(text)) { return { kind: "rate_limit", retryable: true }; @@ -61,5 +90,8 @@ export function classifySdkError(error: unknown): SdkErrorClassification { return { kind: "rate_limit", retryable: true }; } if (/\b(?:http\s*)?529\b|overloaded/.test(text)) return { kind: "overloaded", retryable: true }; + if (/\binvalid_grant\b|\binvalid_token\b|\b(?:http\s*)?401\b|\bunauthorized\b/.test(text)) { + return { kind: "auth_error", retryable: true }; + } return OTHER_ERROR; } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts index 5407361237..5de260deba 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts @@ -30,6 +30,16 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined) ].join("\n"); } +export function claudeCodeVersionFloorGuidance(text: string): string | undefined { + if (/does not support this model; version (\S+?) or newer is required|claude_code_version_too_old/i.test(text)) { + return "The bundled Claude Code binary is too old for this model. Update senpi/omo (it ships a newer @anthropic-ai/claude-agent-sdk) or set CLAUDE_CODE_EXECUTABLE to a Claude Code or newer binary."; + } + if (/\bmodel_not_found\b|unrecognized_model|not found for provider/i.test(text)) { + return "The bundled Claude Code binary does not know this model id; update senpi/omo or set CLAUDE_CODE_EXECUTABLE to a newer Claude Code binary."; + } + return undefined; +} + export function sdkErrorGuidance(kind: SdkErrorKind): string | undefined { switch (kind) { case "org_not_allowed": diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts index 2282d0ac25..7c02fde057 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts @@ -1,3 +1,4 @@ +import { sdkResultFailure } from "./errors.ts"; import { refusalError } from "./refusal.ts"; import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts"; import { evaluateAbortOutcome } from "./session-reattach.ts"; @@ -134,8 +135,14 @@ function handleMessage( failTurn(registry, entry, refusal); return true; } - if (message.type === "result") finishTurn(registry, entry, turn, message); - else deliver(entry, turn, message); + if (message.type === "result") { + const failure = sdkResultFailure(message); + if (failure) { + failTurn(registry, entry, failure); + return true; + } + finishTurn(registry, entry, turn, message); + } else deliver(entry, turn, message); return false; } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts index 9ca8e3a95e..59e49c3b84 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts @@ -1,4 +1,5 @@ import { BoundedAsyncQueue, SESSION_STREAM_QUEUE_CAPACITY } from "./bounded-queue.ts"; +import { sdkResultFailure } from "./errors.ts"; import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts"; import { bindingFromEntry, rememberBinding } from "./session-reattach.ts"; import { @@ -13,7 +14,9 @@ import { recordSyncedStream, sentHashPrefixDigest } from "./session-sync.ts"; type StagedContinuityDecision = { emit(): void }; function successfulTurn(messages: readonly SDKMessage[]): boolean { - return messages.some((message) => message.type === "result" && message.subtype === "success"); + return messages.some( + (message) => message.type === "result" && message.subtype === "success" && sdkResultFailure(message) === undefined, + ); } function recordAssistantUuid(entry: ClaudeSdkOauthSessionEntry, sentCount: number, message: SDKMessage): void { diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream-guidance.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream-guidance.ts index 3340fda8af..036d20f8b8 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream-guidance.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream-guidance.ts @@ -1,9 +1,11 @@ import { AllAccountsBlockedError } from "./affinity.ts"; import { classifySdkError } from "./errors.ts"; -import { allAccountsBlockedGuidance, sdkErrorGuidance } from "./guidance.ts"; +import { allAccountsBlockedGuidance, claudeCodeVersionFloorGuidance, sdkErrorGuidance } from "./guidance.ts"; export function withAuthGuidance(error: unknown, message: string): string { if (error instanceof AllAccountsBlockedError) return allAccountsBlockedGuidance(error.soonestUnblockAt); const guidance = sdkErrorGuidance(classifySdkError(error).kind); - return guidance ? `${message}\n${guidance}` : message; + const versionGuidance = claudeCodeVersionFloorGuidance(message); + const hints = [guidance, versionGuidance].filter((hint): hint is string => hint !== undefined); + return hints.length > 0 ? `${message}\n${hints.join("\n")}` : message; } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts index 482b3335f6..6cdce5ac79 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts @@ -20,6 +20,7 @@ import { type ContinuityObservation, emitContinuityObservation } from "./session import { residentSessionMessages } from "./session-stream.ts"; import { loadClaudeSdkOauthProviderSettingsFromDisk } from "./settings.ts"; import { applyStreamEvent } from "./stream-events.ts"; +import { sdkAssistantFailure, sdkResultFailure } from "./errors.ts"; import { withAuthGuidance } from "./stream-guidance.ts"; import { emptyOutput, errorMessage, mapStopReason, type StreamBlock, updateUsage } from "./stream-protocol.ts"; import { toolWatch } from "./tool-watch.ts"; @@ -155,6 +156,13 @@ export function streamClaudeSdkOauth( for await (const message of messages) { const refusal = refusalError(message); if (refusal) throw refusal; + const failure = + message.type === "assistant" + ? sdkAssistantFailure(message) + : message.type === "result" + ? sdkResultFailure(message) + : undefined; + if (failure) throw failure; if (!started) { stream.push({ type: "start", partial: output }); started = true; @@ -195,12 +203,6 @@ export function streamClaudeSdkOauth( output.stopReason = mapStopReason(message.stop_reason); } if (!sawStreamEvent) output.content.push({ type: "text", text: message.result }); - } else if (message.type === "result") { - const reason = - "errors" in message && Array.isArray(message.errors) && message.errors.length > 0 - ? String(message.errors[0]) - : `Claude Code ${message.subtype}`; - throw new Error(reason); } } diff --git a/packages/coding-agent/test/claude-sdk-oauth-errors.test.ts b/packages/coding-agent/test/claude-sdk-oauth-errors.test.ts new file mode 100644 index 0000000000..1f61f749f3 --- /dev/null +++ b/packages/coding-agent/test/claude-sdk-oauth-errors.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import * as errors from "../src/core/extensions/builtin/claude-sdk-oauth/errors.ts"; + +const versionFloor = { + type: "result", + subtype: "success", + is_error: true, + api_error_status: 400, + terminal_reason: "api_error", + result: + "API Error: 400 Claude Code 2.1.241 does not support this model; version 2.1.251 or newer is required. Run 'claude update', or update the Claude desktop app, then try again.", + modelUsage: {}, +}; + +function exported(name: string): unknown { + return Reflect.get(errors, name); +} + +describe("Claude SDK OAuth error extraction", () => { + it("surfaces result text, status, and terminal reason from real SDK result shapes", () => { + const failure = exported("sdkResultFailure"); + expect(typeof failure).toBe("function"); + if (typeof failure !== "function") return; + const error = failure(versionFloor); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("does not support this model"); + expect(error.message).toContain("HTTP 400, api_error"); + expect(failure({ type: "result", subtype: "success", is_error: false, result: "ok" })).toBeUndefined(); + expect( + failure({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["You've hit your session limit"], + }), + ).toMatchObject({ message: "You've hit your session limit" }); + }); + + it("prefers assistant text over unknown while retaining informative SDK codes", () => { + const failure = exported("sdkAssistantFailure"); + expect(typeof failure).toBe("function"); + if (typeof failure !== "function") return; + const assistant = (error: string, text: string) => ({ + type: "assistant", + error, + message: { content: [{ type: "text", text }] }, + }); + expect(failure(assistant("unknown", versionFloor.result))).toMatchObject({ message: versionFloor.result }); + const rateLimit = failure(assistant("rate_limit", "You've hit your session limit")); + expect(rateLimit.message).toMatch(/\(rate_limit\)$/); + expect(errors.classifySdkError(rateLimit)).toEqual({ kind: "rate_limit", retryable: true }); + }); + + it("distinguishes transient refresh transport failures from rejected credentials", () => { + expect(errors.classifySdkError("authentication_failed: getaddrinfo ENOTFOUND platform.claude.com")).toEqual({ + kind: "other", + retryable: true, + }); + expect(errors.classifySdkError("invalid_grant")).toEqual({ kind: "auth_error", retryable: true }); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts new file mode 100644 index 0000000000..9a401fa1cd --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { streamClaudeSdkOauth } from "../../../src/core/extensions/builtin/claude-sdk-oauth/stream.ts"; +import { overrideSdkBoundary, resetSdkBoundary, type SDKMessage } from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; + +function message(value: unknown): SDKMessage { + return value as SDKMessage; +} + +const model = { + id: "claude-test", + name: "Claude", + api: "claude-sdk-oauth", + provider: "claude-sdk-oauth", + baseUrl: "", + reasoning: true, + input: ["text"] as ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, +}; + +describe("regression #1169: Claude SDK is_error results", () => { + it("fails a success-subtype session limit and preserves ordinary success", async () => { + overrideSdkBoundary({ + query: () => ({ + async *[Symbol.asyncIterator]() { + yield message({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 429, + terminal_reason: "blocking_limit", + result: "You've hit your session limit · resets 2:50pm (Asia/Seoul)", + }); + }, + async interrupt() {}, + close() {}, + }), + }); + try { + const failure = await streamClaudeSdkOauth(model, { messages: [] }).result(); + expect(failure.stopReason).toBe("error"); + expect(failure.errorMessage).toContain("session limit"); + } finally { + resetSdkBoundary(); + } + + overrideSdkBoundary({ + query: () => ({ + async *[Symbol.asyncIterator]() { + yield message({ type: "result", subtype: "success", is_error: false, result: "ordinary answer" }); + }, + async interrupt() {}, + close() {}, + }), + }); + try { + const success = await streamClaudeSdkOauth(model, { messages: [] }).result(); + expect(success.stopReason).not.toBe("error"); + expect(success.content).toEqual([{ type: "text", text: "ordinary answer" }]); + } finally { + resetSdkBoundary(); + } + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts b/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts new file mode 100644 index 0000000000..be9dc26ff9 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { streamClaudeSdkOauth } from "../../../src/core/extensions/builtin/claude-sdk-oauth/stream.ts"; +import { overrideSdkBoundary, resetSdkBoundary, type SDKMessage } from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; + +function message(value: unknown): SDKMessage { + return value as SDKMessage; +} + +const text = "API Error: 400 Claude Code 2.1.241 does not support this model; version 2.1.251 or newer is required."; + +describe("regression #1298: Claude SDK version-floor errors", () => { + it("surfaces assistant text and actionable executable guidance", async () => { + overrideSdkBoundary({ + query: () => ({ + async *[Symbol.asyncIterator]() { + yield message({ type: "assistant", error: "unknown", message: { content: [{ type: "text", text }] } }); + yield message({ type: "result", subtype: "success", is_error: true, api_error_status: 400, result: text }); + }, + async interrupt() {}, + close() {}, + }), + }); + try { + const result = await streamClaudeSdkOauth( + { id: "claude-test", name: "Claude", api: "claude-sdk-oauth", provider: "claude-sdk-oauth", baseUrl: "", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 8_192 }, + { messages: [] }, + ).result(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("does not support this model"); + expect(result.errorMessage).toContain("CLAUDE_CODE_EXECUTABLE"); + expect(result.errorMessage).not.toBe("unknown"); + } finally { + resetSdkBoundary(); + } + }); +}); From cf4b678bbe3c0c834282bf36adb1d9279763faed Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:41:04 +0900 Subject: [PATCH 02/11] docs(claude-sdk-oauth): track SDK error surfacing Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/CHANGELOG.md | 2 ++ .../builtin/claude-sdk-oauth/changes.md | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f83029714b..3d3f833cef 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,8 @@ - Bundled Claude Code is now 2.1.259 via `@anthropic-ai/claude-agent-sdk` 0.3.259, so `claude-sdk-oauth` sessions on `claude-fable-5-1` no longer fail with the API 400 that required version 2.1.251 or newer ([#1298](https://github.com/code-yeongyu/senpi/issues/1298)). - Claude SDK OAuth maps malformed or raw-string content entries to text (or an omission placeholder) instead of image blocks with undefined `media_type`/`data`, which made Claude Code abort the next query ([oh-my-openagent#7660](https://github.com/code-yeongyu/oh-my-openagent/issues/7660)). - A second `claude-sdk-oauth` login now stores the newly issued OAuth tokens instead of a broken slot holding the managed placeholder, and no longer fails with `Provider is not configured: claude-sdk-oauth` when account rotation has selected a single account ([#1279](https://github.com/code-yeongyu/senpi/issues/1279)). +- Claude SDK OAuth `is_error` results now trigger model fallback and multi-account failover instead of being treated as successful results ([#1169](https://github.com/code-yeongyu/senpi/issues/1169)). +- Claude SDK OAuth now surfaces the SDK assistant's API error text and explains version-floor and unknown-model failures instead of reporting `unknown` ([#1298](https://github.com/code-yeongyu/senpi/issues/1298), [oh-my-openagent#7626](https://github.com/code-yeongyu/oh-my-openagent/issues/7626)). ### New Features ### Breaking Changes diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index e68b28ca9f..023cdf93e3 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -22,6 +22,7 @@ - LOW in `session-sync.ts` around `appendContent`. - NEW file `content-blocks.ts`. ## 2026-09-03 - Accept a rotation-projected OAuth slot as configured +## Surface SDK error text and classify is_error results (2026-09-03) ### What changed @@ -38,6 +39,26 @@ ### Expected merge conflict zones - LOW: `oauth-login.ts` around the `accountCount` computation in `configuredFor`. The same hunk appears in the open PRs #1304 and #1196. +- `errors.ts`: added shared extraction for assistant text and `is_error` result failures, transport classification, and SDK code classifications. +- `auth-lane.ts`: shared SDK failure extraction now carries real text, and transient token refresh failures are marked as server errors instead of permanent auth errors. +- `stream.ts`: assistant and result failures terminate ambient streams with their actual text. +- `session-registry-pump.ts`: `is_error` results reject and close claimed resident turns. +- `session-turn-attempt.ts`: only genuine successful results record a successful turn. +- `guidance.ts`: added version-floor and model-not-found remediation guidance. +- `stream-guidance.ts`: appends actionable binary guidance to surfaced SDK errors. + +### Why + +- Claude Code emits useful API text beside a bare `unknown` assistant error and can mark an otherwise `success` result as `is_error`; losing either signal hides version failures and prevents session-limit and API-error failover. + +### Why an extension could not handle it + +- These SDK messages are classified inside the builtin provider's ambient stream, managed auth lane, and resident session pump before any extension-facing result exists. + +### Expected merge conflict zones + +- MEDIUM in `errors.ts`, `auth-lane.ts`, and `stream.ts` around SDK message failure extraction. +- LOW in `session-registry-pump.ts`, `session-turn-attempt.ts`, `guidance.ts`, and `stream-guidance.ts`. ## 2026-09-02 - Honor tool-less summarization requests ### What changed From 13779477c8d8df7bd652d9a1b1848f88de86256a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:42:34 +0900 Subject: [PATCH 03/11] fix(claude-sdk-oauth): narrow SDK assistant text blocks Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../extensions/builtin/claude-sdk-oauth/errors.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index ec76724bc5..67e5884f3d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -56,10 +56,13 @@ export function sdkResultFailure(message: Extract): Error | undefined { if (!message.error) return undefined; const content = message.message.content; - const text = (typeof content === "string" ? content : content - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map((block) => block.text) - .join(" ")).trim(); + const text = (typeof content === "string" + ? content + : content + .filter((block) => block.type === "text" && "text" in block && typeof block.text === "string") + .map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")) + .join(" ") + ).trim(); return new Error(text ? (message.error === "unknown" ? text : `${text} (${message.error})`) : message.error); } From d4714283d21f67d080d7200890a4f6c667bec4f7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:46:19 +0900 Subject: [PATCH 04/11] test(claude-sdk-oauth): expect transport retries Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/test/claude-sdk-oauth-failover.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts index e25ac2fc55..4df14fcf86 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts @@ -91,7 +91,7 @@ describe("Claude SDK OAuth failover", () => { kind: "other", retryable: false, }); - expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: false }); + expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: true }); }); it("walks HRW order after a rate limit, persists the cooldown, and emits failover", async () => { From d73c87997980e80cc3fd533fbb2107f0ae805bc1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:56:12 +0900 Subject: [PATCH 05/11] style(claude-sdk-oauth): apply biome formatting to the error-surfacing lane --- .../builtin/claude-sdk-oauth/errors.ts | 30 ++++++++++++------- .../claude-sdk-oauth/session-turn-attempt.ts | 3 +- .../builtin/claude-sdk-oauth/stream.ts | 2 +- ...1169-claude-sdk-oauth-result-error.test.ts | 6 +++- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index 67e5884f3d..6b40da80dd 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -46,9 +46,14 @@ export function sdkResultFailure(message: Extract typeof error === "string" && error.length > 0); const resultText = "result" in message && typeof message.result === "string" ? message.result.trim() : ""; - const detail = firstError ?? (message.is_error === true && resultText ? resultText : undefined) ?? `Claude Code ${message.subtype}`; - const status = "api_error_status" in message && message.api_error_status != null ? `HTTP ${message.api_error_status}` : ""; - const reason = "terminal_reason" in message && typeof message.terminal_reason === "string" ? message.terminal_reason : ""; + const detail = + firstError ?? + (message.is_error === true && resultText ? resultText : undefined) ?? + `Claude Code ${message.subtype}`; + const status = + "api_error_status" in message && message.api_error_status != null ? `HTTP ${message.api_error_status}` : ""; + const reason = + "terminal_reason" in message && typeof message.terminal_reason === "string" ? message.terminal_reason : ""; const suffix = [status, reason].filter(Boolean).join(", "); return new Error(suffix ? `${detail} (${suffix})` : detail); } @@ -56,12 +61,13 @@ export function sdkResultFailure(message: Extract): Error | undefined { if (!message.error) return undefined; const content = message.message.content; - const text = (typeof content === "string" - ? content - : content - .filter((block) => block.type === "text" && "text" in block && typeof block.text === "string") - .map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")) - .join(" ") + const text = ( + typeof content === "string" + ? content + : content + .filter((block) => block.type === "text" && "text" in block && typeof block.text === "string") + .map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")) + .join(" ") ).trim(); return new Error(text ? (message.error === "unknown" ? text : `${text} (${message.error})`) : message.error); } @@ -69,7 +75,11 @@ export function sdkAssistantFailure(message: Extract message.type === "result" && message.subtype === "success" && sdkResultFailure(message) === undefined, + (message) => + message.type === "result" && message.subtype === "success" && sdkResultFailure(message) === undefined, ); } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts index 6cdce5ac79..0da3e5f873 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts @@ -10,6 +10,7 @@ import { import { getSessionClaudeAccountPin } from "./account-command.ts"; import { queryWithAuthLane } from "./auth-lane.ts"; import { buildCustomToolServers } from "./custom-tools.ts"; +import { sdkAssistantFailure, sdkResultFailure } from "./errors.ts"; import { defaultExecutableDeps, resolveClaudeCodeExecutable } from "./executable.ts"; import { buildClaudeSdkOauthQueryOptions } from "./options.ts"; import { buildPromptBlocks, buildPromptStream } from "./prompt-bridge.ts"; @@ -20,7 +21,6 @@ import { type ContinuityObservation, emitContinuityObservation } from "./session import { residentSessionMessages } from "./session-stream.ts"; import { loadClaudeSdkOauthProviderSettingsFromDisk } from "./settings.ts"; import { applyStreamEvent } from "./stream-events.ts"; -import { sdkAssistantFailure, sdkResultFailure } from "./errors.ts"; import { withAuthGuidance } from "./stream-guidance.ts"; import { emptyOutput, errorMessage, mapStopReason, type StreamBlock, updateUsage } from "./stream-protocol.ts"; import { toolWatch } from "./tool-watch.ts"; diff --git a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts index 9a401fa1cd..31db978e2f 100644 --- a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts +++ b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; +import { + overrideSdkBoundary, + resetSdkBoundary, + type SDKMessage, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; import { streamClaudeSdkOauth } from "../../../src/core/extensions/builtin/claude-sdk-oauth/stream.ts"; -import { overrideSdkBoundary, resetSdkBoundary, type SDKMessage } from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; function message(value: unknown): SDKMessage { return value as SDKMessage; From 559fc0c8ed6812007b5d8082458271e6e318e137 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:56:12 +0900 Subject: [PATCH 06/11] fix(claude-sdk-oauth): name the required Claude Code version in the floor guidance The version-floor hint interpolated a literal placeholder. Parse the required version out of the API error text ("version 2.1.251 or newer is required") and name it in the CLAUDE_CODE_EXECUTABLE guidance; the #1298 regression now pins the interpolated version. --- .../builtin/claude-sdk-oauth/guidance.ts | 9 ++++-- ...laude-sdk-oauth-version-floor-text.test.ts | 29 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts index 5de260deba..4bc3d6ae56 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts @@ -31,8 +31,13 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined) } export function claudeCodeVersionFloorGuidance(text: string): string | undefined { - if (/does not support this model; version (\S+?) or newer is required|claude_code_version_too_old/i.test(text)) { - return "The bundled Claude Code binary is too old for this model. Update senpi/omo (it ships a newer @anthropic-ai/claude-agent-sdk) or set CLAUDE_CODE_EXECUTABLE to a Claude Code or newer binary."; + const floor = /does not support this model; version (\S+?) or newer is required|claude_code_version_too_old/i.exec( + text, + ); + if (floor) { + const target = + floor[1] === undefined ? "a newer Claude Code" : `Claude Code ${floor[1].replace(/[.,;:]+$/, "")} or newer`; + return `The bundled Claude Code binary is too old for this model. Update senpi/omo (it ships a newer @anthropic-ai/claude-agent-sdk) or set CLAUDE_CODE_EXECUTABLE to ${target} binary.`; } if (/\bmodel_not_found\b|unrecognized_model|not found for provider/i.test(text)) { return "The bundled Claude Code binary does not know this model id; update senpi/omo or set CLAUDE_CODE_EXECUTABLE to a newer Claude Code binary."; diff --git a/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts b/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts index be9dc26ff9..594e64b192 100644 --- a/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts +++ b/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; +import { + overrideSdkBoundary, + resetSdkBoundary, + type SDKMessage, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; import { streamClaudeSdkOauth } from "../../../src/core/extensions/builtin/claude-sdk-oauth/stream.ts"; -import { overrideSdkBoundary, resetSdkBoundary, type SDKMessage } from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; function message(value: unknown): SDKMessage { return value as SDKMessage; @@ -14,7 +18,13 @@ describe("regression #1298: Claude SDK version-floor errors", () => { query: () => ({ async *[Symbol.asyncIterator]() { yield message({ type: "assistant", error: "unknown", message: { content: [{ type: "text", text }] } }); - yield message({ type: "result", subtype: "success", is_error: true, api_error_status: 400, result: text }); + yield message({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 400, + result: text, + }); }, async interrupt() {}, close() {}, @@ -22,12 +32,25 @@ describe("regression #1298: Claude SDK version-floor errors", () => { }); try { const result = await streamClaudeSdkOauth( - { id: "claude-test", name: "Claude", api: "claude-sdk-oauth", provider: "claude-sdk-oauth", baseUrl: "", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 8_192 }, + { + id: "claude-test", + name: "Claude", + api: "claude-sdk-oauth", + provider: "claude-sdk-oauth", + baseUrl: "", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }, { messages: [] }, ).result(); expect(result.stopReason).toBe("error"); expect(result.errorMessage).toContain("does not support this model"); expect(result.errorMessage).toContain("CLAUDE_CODE_EXECUTABLE"); + expect(result.errorMessage).toContain("Claude Code 2.1.251 or newer"); + expect(result.errorMessage).not.toContain(""); expect(result.errorMessage).not.toBe("unknown"); } finally { resetSdkBoundary(); From d98ecf02f6c67f5a853695e749894602488aea71 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 11:56:12 +0900 Subject: [PATCH 07/11] test(claude-sdk-oauth): pin pump failure on is_error results A claimed resident turn that receives a result with is_error true must reject with the API text plus HTTP status/terminal reason and close the session; an ordinary success result still settles on the idle-synced path. --- ...aude-sdk-oauth-pump-result-failure.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts diff --git a/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts new file mode 100644 index 0000000000..216da0112b --- /dev/null +++ b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts @@ -0,0 +1,131 @@ +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SdkQueryHandle } from "../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; +import { + ClaudeSdkOauthSessionRegistry, + overrideSessionRegistryBoundary, + resetSessionRegistryBoundary, +} from "../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; +import { submitSessionTurn } from "../src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts"; + +// Real shapes captured from the bundled Claude Code 2.1.241 binary on +// claude-fable-5-1 (evidence g1-binary-probe-red.json) and from issue #1169. +const VERSION_FLOOR_TEXT = + "API Error: 400 Claude Code 2.1.241 does not support this model; version 2.1.251 or newer is required."; + +class ScriptedQuery implements SdkQueryHandle, AsyncIterator { + closes = 0; + private done = false; + private readonly queued: SDKMessage[] = []; + private readonly readers: Array<(value: IteratorResult) => void> = []; + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + next(): Promise> { + const value = this.queued.shift(); + if (value) return Promise.resolve({ value, done: false }); + if (this.done) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve) => this.readers.push(resolve)); + } + + emit(message: SDKMessage): void { + const reader = this.readers.shift(); + if (reader) reader({ value: message, done: false }); + else this.queued.push(message); + } + + async interrupt(): Promise {} + + close(): void { + this.closes++; + this.done = true; + for (const reader of this.readers.splice(0)) reader({ value: undefined, done: true }); + } +} + +const userContent = { role: "user", content: "hello" } as const; + +function replay(uuid: string, sessionId: string): SDKMessage { + return { + type: "user", + message: userContent, + parent_tool_use_id: null, + uuid, + session_id: sessionId, + isReplay: true, + } as SDKMessage; +} + +function result(uuid: string | undefined, sessionId: string, overrides: Record): SDKMessage { + return { + type: "result", + subtype: "success", + user_message_uuid: uuid, + uuid: "result", + session_id: sessionId, + is_error: false, + result: "done", + ...overrides, + } as unknown as SDKMessage; +} + +function fixture() { + const query = new ScriptedQuery(); + overrideSessionRegistryBoundary({ queryFactory: () => query }); + const registry = new ClaudeSdkOauthSessionRegistry(); + const entry = registry.getOrCreate({ + senpiSessionId: "pump-result-failure", + accountName: "default", + modelId: "claude-test", + toolsetHash: "tools-v1", + systemPromptHash: "prompt-v1", + options: {}, + }); + return { query, registry, entry }; +} + +async function submittedMessage(entry: { inputController: AsyncIterable }): Promise { + const item = await entry.inputController[Symbol.asyncIterator]().next(); + if (item.done) throw new Error("Expected a submitted user message"); + return item.value; +} + +afterEach(() => { + resetSessionRegistryBoundary(); +}); + +describe("claude-sdk-oauth pump: is_error results (#1169 / #1298)", () => { + it("fails the claimed turn with the API text and closes the session on an is_error success result", async () => { + const { query, registry, entry } = fixture(); + const turn = submitSessionTurn(registry, entry, { message: userContent }); + const submitted = await submittedMessage(entry); + query.emit(replay(submitted.uuid!, entry.sdkSessionId)); + query.emit( + result(submitted.uuid, entry.sdkSessionId, { + is_error: true, + api_error_status: 400, + terminal_reason: "api_error", + result: VERSION_FLOOR_TEXT, + }), + ); + + await expect(turn).rejects.toThrow(/does not support this model.*\(HTTP 400, api_error\)/); + expect(registry.get("pump-result-failure")).toBeUndefined(); + expect(entry.activeTurn).toBeNull(); + }); + + it("keeps an ordinary success result on the idle-synced path", async () => { + const { query, registry, entry } = fixture(); + const turn = submitSessionTurn(registry, entry, { message: userContent }); + const submitted = await submittedMessage(entry); + query.emit(replay(submitted.uuid!, entry.sdkSessionId)); + query.emit(result(submitted.uuid, entry.sdkSessionId, {})); + + const settled = await turn; + expect(settled.messages.map((message) => message.type)).toEqual(["result"]); + expect(entry.state).toBe("IDLE_SYNCED"); + expect(registry.get("pump-result-failure")).toBe(entry); + }); +}); From 90fc71aaacf2d4a3dc98cfb7a08bb5b18131e8f7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 12:46:29 +0900 Subject: [PATCH 08/11] fix(claude-sdk-oauth): surface an is_error result that precedes the replay claim A result that fails before the SDK echoes the submitted user message (a 400 version floor, a 429 session limit) used to be reported as the attribution error "result arrived before replay claim", which classifies as a non-retryable other and hides the API text; failover therefore never rotated. Classify the result first and reserve the attribution error for a genuine success-before-claim. --- .../claude-sdk-oauth/session-registry-pump.ts | 9 ++++++++- ...laude-sdk-oauth-pump-result-failure.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts index 7c02fde057..a4ca5e3d8c 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts @@ -125,7 +125,14 @@ function handleMessage( if (isReplayFor(message, turn.uuid)) claimTurn(entry, turn); else if (message.type === "stream_event") bufferBeforeReplay(registry, entry, turn, message); else if (message.type === "result") { - throw new SessionTurnAttributionError("Claude SDK OAuth result arrived before replay claim"); + // A result that fails before the SDK ever echoed our user message (a + // 400 version floor, a session limit) must surface as that failure so + // failover can classify and rotate; only a genuine success-before-claim + // is an attribution error. + throw ( + sdkResultFailure(message) ?? + new SessionTurnAttributionError("Claude SDK OAuth result arrived before replay claim") + ); } return false; } diff --git a/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts index 216da0112b..1c8efbf0f4 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts @@ -116,6 +116,24 @@ describe("claude-sdk-oauth pump: is_error results (#1169 / #1298)", () => { expect(entry.activeTurn).toBeNull(); }); + it("surfaces an is_error result that arrives before the replay claim as the API failure", async () => { + const { query, registry, entry } = fixture(); + const turn = submitSessionTurn(registry, entry, { message: userContent }); + const submitted = await submittedMessage(entry); + // No replay echo: the SDK failed the request before it ever confirmed our user message. + query.emit( + result(submitted.uuid, entry.sdkSessionId, { + is_error: true, + api_error_status: 429, + terminal_reason: "blocking_limit", + result: "You've hit your session limit · resets 2:50pm (Asia/Seoul)", + }), + ); + + await expect(turn).rejects.toThrow(/session limit.*\(HTTP 429, blocking_limit\)/); + await expect(turn).rejects.not.toThrow(/before replay claim/); + }); + it("keeps an ordinary success result on the idle-synced path", async () => { const { query, registry, entry } = fixture(); const turn = submitSessionTurn(registry, entry, { message: userContent }); From 8cf5152635ddfa90053365c85414bbd5b69e7023 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 12:46:30 +0900 Subject: [PATCH 09/11] fix(claude-sdk-oauth): keep usage accounting for is_error results Failing an is_error result before the success branch dropped the tokens the SDK reported for it; record the usage before throwing. --- .../builtin/claude-sdk-oauth/stream.ts | 6 +++- ...1169-claude-sdk-oauth-result-error.test.ts | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts index 0da3e5f873..4eca87353c 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts @@ -162,7 +162,11 @@ export function streamClaudeSdkOauth( : message.type === "result" ? sdkResultFailure(message) : undefined; - if (failure) throw failure; + if (failure) { + // An error result still bills its tokens; account for them before failing. + if (message.type === "result" && message.usage) updateUsage(model, output, message.usage); + throw failure; + } if (!started) { stream.push({ type: "start", partial: output }); started = true; diff --git a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts index 31db978e2f..b3e23b473d 100644 --- a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts +++ b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts @@ -24,6 +24,37 @@ const model = { }; describe("regression #1169: Claude SDK is_error results", () => { + it("keeps the usage an is_error result reports", async () => { + overrideSdkBoundary({ + query: () => ({ + async *[Symbol.asyncIterator]() { + yield message({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 429, + result: "API failure", + usage: { + input_tokens: 7, + output_tokens: 3, + cache_read_input_tokens: 11, + cache_creation_input_tokens: 5, + }, + }); + }, + async interrupt() {}, + close() {}, + }), + }); + try { + const failure = await streamClaudeSdkOauth(model, { messages: [] }).result(); + expect(failure.stopReason).toBe("error"); + expect(failure.usage).toMatchObject({ input: 7, output: 3, cacheRead: 11, cacheWrite: 5, totalTokens: 26 }); + } finally { + resetSdkBoundary(); + } + }); + it("fails a success-subtype session limit and preserves ordinary success", async () => { overrideSdkBoundary({ query: () => ({ From 7eb8664037c3ab8fc840c9b4a2ca93a98f53e8ab Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 12:57:49 +0900 Subject: [PATCH 10/11] fix(claude-sdk-oauth): carry a failed result's usage across every lane The managed lane classifies an is_error result inside runFailover and the resident pump rejects the turn before the result reaches the stream loop, so accounting for usage only in that loop left both lanes at zero tokens. A failed result now travels as SdkResultFailure carrying the SDK usage, and the stream's error boundary accounts for it whether the failure arrived directly or wrapped in a ClassifiedSdkError. --- .../builtin/claude-sdk-oauth/errors.ts | 24 +++++++++++-- .../builtin/claude-sdk-oauth/stream.ts | 12 +++---- ...aude-sdk-oauth-pump-result-failure.test.ts | 6 ++++ ...1169-claude-sdk-oauth-result-error.test.ts | 35 +++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index 6b40da80dd..81e16fefcb 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -41,7 +41,27 @@ function errorText(error: unknown): string { return String(error); } -export function sdkResultFailure(message: Extract): Error | undefined { +export type SdkResultUsage = Extract["usage"]; + +/** A failed SDK result keeps the usage it billed so every lane can account for it. */ +export class SdkResultFailure extends Error { + readonly usage: SdkResultUsage | undefined; + + constructor(message: string, usage: SdkResultUsage | undefined) { + super(message); + this.name = "SdkResultFailure"; + this.usage = usage; + } +} + +/** The usage carried by a failed SDK result, looking through failover's classification wrapper. */ +export function sdkResultFailureUsage(error: unknown): SdkResultUsage | undefined { + if (error instanceof SdkResultFailure) return error.usage; + const wrapped = record(error)?.original; + return wrapped instanceof SdkResultFailure ? wrapped.usage : undefined; +} + +export function sdkResultFailure(message: Extract): SdkResultFailure | undefined { if (message.subtype === "success" && message.is_error !== true) return undefined; const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : []; const firstError = errors.find((error): error is string => typeof error === "string" && error.length > 0); @@ -55,7 +75,7 @@ export function sdkResultFailure(message: Extract): Error | undefined { diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts index 4eca87353c..97e9f4e050 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/stream.ts @@ -10,7 +10,7 @@ import { import { getSessionClaudeAccountPin } from "./account-command.ts"; import { queryWithAuthLane } from "./auth-lane.ts"; import { buildCustomToolServers } from "./custom-tools.ts"; -import { sdkAssistantFailure, sdkResultFailure } from "./errors.ts"; +import { sdkAssistantFailure, sdkResultFailure, sdkResultFailureUsage } from "./errors.ts"; import { defaultExecutableDeps, resolveClaudeCodeExecutable } from "./executable.ts"; import { buildClaudeSdkOauthQueryOptions } from "./options.ts"; import { buildPromptBlocks, buildPromptStream } from "./prompt-bridge.ts"; @@ -162,11 +162,7 @@ export function streamClaudeSdkOauth( : message.type === "result" ? sdkResultFailure(message) : undefined; - if (failure) { - // An error result still bills its tokens; account for them before failing. - if (message.type === "result" && message.usage) updateUsage(model, output, message.usage); - throw failure; - } + if (failure) throw failure; if (!started) { stream.push({ type: "start", partial: output }); started = true; @@ -225,6 +221,10 @@ export function streamClaudeSdkOauth( // no-excuse-ok: catch // Provider boundary converts every thrown SDK value into the stream error contract. output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + // A failed result still bills its tokens; managed and resident lanes + // throw before the result reaches this loop, so account for it here. + const billed = sdkResultFailureUsage(error); + if (billed) updateUsage(model, output, billed); output.errorMessage = withAuthGuidance(error, errorMessage(error)); stream.push({ type: "error", reason: output.stopReason, error: output }); } finally { diff --git a/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts index 1c8efbf0f4..85c3136d1c 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts @@ -1,5 +1,6 @@ import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { afterEach, describe, expect, it } from "vitest"; +import { SdkResultFailure } from "../src/core/extensions/builtin/claude-sdk-oauth/errors.ts"; import type { SdkQueryHandle } from "../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; import { ClaudeSdkOauthSessionRegistry, @@ -108,10 +109,15 @@ describe("claude-sdk-oauth pump: is_error results (#1169 / #1298)", () => { api_error_status: 400, terminal_reason: "api_error", result: VERSION_FLOOR_TEXT, + usage: { input_tokens: 7, output_tokens: 3, cache_read_input_tokens: 11, cache_creation_input_tokens: 5 }, }), ); await expect(turn).rejects.toThrow(/does not support this model.*\(HTTP 400, api_error\)/); + // The rejection carries the billed usage so the outer stream can account for it. + await expect(turn).rejects.toSatisfy( + (error: unknown) => error instanceof SdkResultFailure && error.usage?.input_tokens === 7, + ); expect(registry.get("pump-result-failure")).toBeUndefined(); expect(entry.activeTurn).toBeNull(); }); diff --git a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts index b3e23b473d..6749e05308 100644 --- a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts +++ b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { sdkResultFailure } from "../../../src/core/extensions/builtin/claude-sdk-oauth/errors.ts"; +import { ClassifiedSdkError } from "../../../src/core/extensions/builtin/claude-sdk-oauth/failover.ts"; import { overrideSdkBoundary, resetSdkBoundary, @@ -24,6 +26,39 @@ const model = { }; describe("regression #1169: Claude SDK is_error results", () => { + it("keeps the usage when the managed lane's failover wrapper throws the failed result", async () => { + // The managed (oauth-slots) lane classifies the is_error result inside + // runFailover and throws a ClassifiedSdkError before stream.ts ever sees + // the result message; the billed usage must survive that boundary. + const failed = sdkResultFailure( + message({ + type: "result", + subtype: "success", + is_error: true, + api_error_status: 429, + result: "API failure", + usage: { input_tokens: 7, output_tokens: 3, cache_read_input_tokens: 11, cache_creation_input_tokens: 5 }, + }) as Extract, + ); + overrideSdkBoundary({ + query: () => ({ + async *[Symbol.asyncIterator]() { + throw new ClassifiedSdkError({ kind: "rate_limit", retryable: true }, failed, false); + }, + async interrupt() {}, + close() {}, + }), + }); + try { + const failure = await streamClaudeSdkOauth(model, { messages: [] }).result(); + expect(failure.stopReason).toBe("error"); + expect(failure.errorMessage).toContain("API failure"); + expect(failure.usage).toMatchObject({ input: 7, output: 3, cacheRead: 11, cacheWrite: 5, totalTokens: 26 }); + } finally { + resetSdkBoundary(); + } + }); + it("keeps the usage an is_error result reports", async () => { overrideSdkBoundary({ query: () => ({ From 9a569146ec643625f74155aa8404e2bfaaebfd56 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 12:59:30 +0900 Subject: [PATCH 11/11] test(claude-sdk-oauth): reject from an explicit iterator in the managed-lane usage pin --- .../regressions/1169-claude-sdk-oauth-result-error.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts index 6749e05308..c559893671 100644 --- a/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts +++ b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts @@ -42,8 +42,11 @@ describe("regression #1169: Claude SDK is_error results", () => { ); overrideSdkBoundary({ query: () => ({ - async *[Symbol.asyncIterator]() { - throw new ClassifiedSdkError({ kind: "rate_limit", retryable: true }, failed, false); + [Symbol.asyncIterator]() { + return { + next: () => + Promise.reject(new ClassifiedSdkError({ kind: "rate_limit", retryable: true }, failed, false)), + }; }, async interrupt() {}, close() {},