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/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/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 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..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 @@ -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["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); + 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 SdkResultFailure(suffix ? `${detail} (${suffix})` : detail, message.usage); +} + +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.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); +} + /** 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 +123,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..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 @@ -30,6 +30,21 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined) ].join("\n"); } +export function claudeCodeVersionFloorGuidance(text: string): string | undefined { + 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."; + } + 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..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 @@ -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"; @@ -124,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; } @@ -134,8 +142,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..fb292a76a5 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,10 @@ 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..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,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, sdkResultFailureUsage } from "./errors.ts"; import { defaultExecutableDeps, resolveClaudeCodeExecutable } from "./executable.ts"; import { buildClaudeSdkOauthQueryOptions } from "./options.ts"; import { buildPromptBlocks, buildPromptStream } from "./prompt-bridge.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); } } @@ -219,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-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/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 () => { 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..85c3136d1c --- /dev/null +++ b/packages/coding-agent/test/claude-sdk-oauth-pump-result-failure.test.ts @@ -0,0 +1,155 @@ +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, + 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, + 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(); + }); + + 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 }); + 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); + }); +}); 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..c559893671 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1169-claude-sdk-oauth-result-error.test.ts @@ -0,0 +1,138 @@ +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, + 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"; + +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("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: () => ({ + [Symbol.asyncIterator]() { + return { + next: () => + Promise.reject(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: () => ({ + 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: () => ({ + 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..594e64b192 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1298-claude-sdk-oauth-version-floor-text.test.ts @@ -0,0 +1,59 @@ +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"; + +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).toContain("Claude Code 2.1.251 or newer"); + expect(result.errorMessage).not.toContain(""); + expect(result.errorMessage).not.toBe("unknown"); + } finally { + resetSdkBoundary(); + } + }); +});