History

fix(claude): name the expired login or usage limit instead of a generic API error (#10321)

Vitaly Iegorov committed 2 months ago

Commit 3d00cfd1 parent 29c5ecd4 files · +504 −18Browse files

Unified · Split

4 files
  1. apps/server/src/provider/Drivers/ClaudeHome.test.ts +12 −0
  2. apps/server/src/provider/Drivers/ClaudeHome.ts +18 −0
  3. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +407 −0
  4. apps/server/src/provider/Layers/ClaudeAdapter.ts +67 −18
apps/server/src/provider/Drivers/ClaudeHome.test.ts+12−0
@@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; · 5 unmodified lines
66 import * as Path from "effect/Path";
77
88 import {
9+ claudeSignedOutMessage,
910 makeClaudeCapabilitiesCacheKey,
1011 makeClaudeContinuationGroupKey,
1112 makeClaudeEnvironment,
@@ -39,6 +40,17 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { · 27 unmodified lines
3940 }),
4041 );
4142
43+ it("points the signed-out hint at the configured Claude home", () => {
44+ expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain(
45+ "run `claude auth login`",
46+ );
47+ const configDir = "/synthetic/Claude work's $literal";
48+ const message = claudeSignedOutMessage({ configDir, cwd: "/synthetic/project" });
49+ expect(message).toContain(`CLAUDE_CONFIG_DIR set to "${configDir}"`);
50+ expect(message).not.toContain("CLAUDE_CONFIG_DIR=");
51+ expect(message).toContain("then start a new thread");
52+ });
53+
4254 it.effect("separates capability probes by cwd", () =>
4355 Effect.gen(function* () {
4456 const config = { binaryPath: "claude", homePath: "" };
apps/server/src/provider/Drivers/ClaudeHome.ts+18−0
@@ -3,9 +3,12 @@ import * as NodeOS from "node:os"; · 2 unmodified lines
33 import type { ClaudeSettings } from "@t3tools/contracts";
44 import * as Effect from "effect/Effect";
55 import * as Path from "effect/Path";
6+import * as Schema from "effect/Schema";
67
78 import { expandHomePath } from "../../pathExpansion.ts";
89
10+const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String));
11+
912 export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* (
1013 config: Pick<ClaudeSettings, "homePath">,
1114 ): Effect.fn.Return<string, never, Path.Path> {
@@ -50,3 +53,18 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC · 38 unmodified lines
5053 return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`;
5154 },
5255 );
56+
57+/**
58+ * Describe the spawned CLI's environment separately from the login command so
59+ * paths remain literal on every shell, including relative inherited values.
60+ */
61+export const claudeSignedOutMessage = (input: {
62+ readonly configDir: string | undefined;
63+ readonly cwd: string;
64+}): string => {
65+ const configuration =
66+ input.configDir !== undefined
67+ ? ` from ${quotePath(input.cwd)}, with CLAUDE_CONFIG_DIR set to ${quotePath(input.configDir)}`
68+ : "";
69+ return `Claude could not authenticate. For subscription login, run \`claude auth login\` on this environment's machine${configuration}, then start a new thread. For API-key authentication, check this instance's configured credentials.`;
70+};
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+407−0
@@ -168,6 +168,7 @@ function makeHarness(config?: { · 167 unmodified lines
168168 readonly claudeConfig?: Partial<ClaudeSettings>;
169169 readonly instanceId?: ProviderInstanceId;
170170 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
171+ readonly environment?: ClaudeAdapterLiveOptions["environment"];
171172 }) {
172173 const query = new FakeClaudeQuery();
173174 let createInput:
@@ -178,6 +179,7 @@ function makeHarness(config?: { · 4 unmodified lines
178179 | undefined;
179180
180181 const adapterOptions: ClaudeAdapterLiveOptions = {
182+ ...(config?.environment ? { environment: config.environment } : {}),
181183 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
182184 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
183185 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
@@ -2199,6 +2201,411 @@ describe("ClaudeAdapterLive", () => { · 2015 unmodified lines
21992201 );
22002202 });
22012203
2204+ const AUTH_FAILURE_ASSISTANT = {
2205+ type: "assistant",
2206+ session_id: "sdk-session-auth",
2207+ uuid: "assistant-auth",
2208+ parent_tool_use_id: null,
2209+ error: "authentication_failed",
2210+ is_api_error_message: true,
2211+ message: {
2212+ id: "assistant-message-auth",
2213+ model: "<synthetic>",
2214+ content: [{ type: "text", text: "Not logged in \u00b7 Please run /login" }],
2215+ },
2216+ } as unknown as SDKMessage;
2217+
2218+ const completedTurn = (runtimeEvents: ReadonlyArray<ProviderRuntimeEvent>) => {
2219+ const event = runtimeEvents[runtimeEvents.length - 1];
2220+ assert.equal(event?.type, "turn.completed");
2221+ assert(event?.type === "turn.completed");
2222+ return event.payload;
2223+ };
2224+
2225+ it.effect.each([
2226+ {
2227+ name: "an api_error terminal reason",
2228+ result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] },
2229+ state: "failed",
2230+ errorMessage: /claude auth login/,
2231+ },
2232+ {
2233+ name: "an is_error success with no terminal reason",
2234+ result: { subtype: "success", is_error: true, errors: [] },
2235+ state: "failed",
2236+ errorMessage: /claude auth login/,
2237+ },
2238+ // Every other outcome names its own cause, and the latch must not speak over it.
2239+ {
2240+ name: "a terminal reason of its own",
2241+ result: {
2242+ subtype: "success",
2243+ is_error: false,
2244+ terminal_reason: "prompt_too_long",
2245+ errors: [],
2246+ },
2247+ state: "failed",
2248+ errorMessage: /prompt exceeds the model's context window/,
2249+ },
2250+ {
2251+ name: "a listed tool failure",
2252+ result: {
2253+ subtype: "error_during_execution",
2254+ is_error: true,
2255+ errors: ["Tool execution failed: EACCES"],
2256+ },
2257+ state: "failed",
2258+ errorMessage: /EACCES/,
2259+ },
2260+ {
2261+ name: "a user interrupt",
2262+ result: {
2263+ subtype: "error_during_execution",
2264+ is_error: true,
2265+ terminal_reason: "aborted_tools",
2266+ errors: [],
2267+ },
2268+ state: "interrupted",
2269+ errorMessage: undefined,
2270+ },
2271+ {
2272+ name: "a cancellation",
2273+ result: { subtype: "error_during_execution", is_error: true, errors: ["cancelled"] },
2274+ state: "cancelled",
2275+ errorMessage: /cancelled/,
2276+ },
2277+ ])(
2278+ "reports the real cause when an expired login is followed by $name",
2279+ ({ result, state, errorMessage }) => {
2280+ const harness = makeHarness();
2281+ return Effect.gen(function* () {
2282+ const adapter = yield* ClaudeAdapter;
2283+
2284+ const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
2285+ Stream.takeUntil((event) => event.type === "turn.completed"),
2286+ Stream.runCollect,
2287+ Effect.forkChild,
2288+ );
2289+
2290+ const session = yield* adapter.startSession({
2291+ threadId: THREAD_ID,
2292+ provider: ProviderDriverKind.make("claudeAgent"),
2293+ runtimeMode: "full-access",
2294+ });
2295+ yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] });
2296+
2297+ harness.query.emit(AUTH_FAILURE_ASSISTANT);
2298+ harness.query.emit({
2299+ type: "result",
2300+ ...result,
2301+ session_id: "sdk-session-auth",
2302+ uuid: "result-auth",
2303+ } as unknown as SDKMessage);
2304+
2305+ const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber)));
2306+ assert.equal(payload.state, state);
2307+ if (errorMessage === undefined) {
2308+ assert.equal(payload.errorMessage, undefined);
2309+ } else {
2310+ assert.match(payload.errorMessage ?? "", errorMessage);
2311+ }
2312+ }).pipe(
2313+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
2314+ Effect.provide(harness.layer),
2315+ );
2316+ },
2317+ );
2318+
2319+ it.effect("fails a usage-limited turn with the limit it parked on", () => {
2320+ const harness = makeHarness();
2321+ return Effect.gen(function* () {
2322+ const adapter = yield* ClaudeAdapter;
2323+
2324+ const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
2325+ Stream.takeUntil((event) => event.type === "turn.completed"),
2326+ Stream.runCollect,
2327+ Effect.forkChild,
2328+ );
2329+
2330+ const session = yield* adapter.startSession({
2331+ threadId: THREAD_ID,
2332+ provider: ProviderDriverKind.make("claudeAgent"),
2333+ runtimeMode: "full-access",
2334+ });
2335+ yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] });
2336+
2337+ const nowMs = yield* Clock.currentTimeMillis;
2338+ harness.query.emit({
2339+ type: "rate_limit_event",
2340+ rate_limit_info: {
2341+ status: "rejected",
2342+ rateLimitType: "five_hour",
2343+ resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60,
2344+ },
2345+ session_id: "sdk-session-limit",
2346+ uuid: "rate-limit-rejected",
2347+ } as unknown as SDKMessage);
2348+ harness.query.emit({
2349+ type: "result",
2350+ subtype: "success",
2351+ is_error: false,
2352+ terminal_reason: "api_error",
2353+ errors: [],
2354+ session_id: "sdk-session-limit",
2355+ uuid: "result-limit",
2356+ } as unknown as SDKMessage);
2357+
2358+ const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber)));
2359+ assert.equal(payload.state, "failed");
2360+ assert.equal(
2361+ payload.errorMessage,
2362+ "Claude usage limit reached. Send the message again once the limit resets.",
2363+ );
2364+ }).pipe(
2365+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
2366+ Effect.provide(harness.layer),
2367+ );
2368+ });
2369+
2370+ it.effect.each([
2371+ {
2372+ name: "listed error with api_error",
2373+ evidence: "auth",
2374+ result: {
2375+ subtype: "error_during_execution",
2376+ is_error: true,
2377+ terminal_reason: "api_error",
2378+ errors: ["Tool execution failed: EACCES"],
2379+ },
2380+ expected: /EACCES/,
2381+ expectedState: "failed",
2382+ },
2383+ {
2384+ name: "overload with api_error",
2385+ evidence: "auth",
2386+ result: {
2387+ subtype: "success",
2388+ is_error: true,
2389+ terminal_reason: "api_error",
2390+ api_error_status: 529,
2391+ errors: [],
2392+ },
2393+ expected: /overloaded \(529\)/,
2394+ expectedState: "failed",
2395+ },
2396+ {
2397+ name: "listed error on an is_error success",
2398+ evidence: "auth",
2399+ result: {
2400+ subtype: "success",
2401+ is_error: true,
2402+ errors: ["Tool execution failed: EACCES"],
2403+ },
2404+ expected: /EACCES/,
2405+ expectedState: "failed",
2406+ },
2407+ {
2408+ name: "recovered same window",
2409+ evidence: "recovered",
2410+ result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] },
2411+ expected: /repeated API errors/,
2412+ expectedState: "failed",
2413+ },
2414+ {
2415+ name: "nested assistant does not poison parent",
2416+ evidence: "nested-auth",
2417+ result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] },
2418+ expected: /repeated API errors/,
2419+ expectedState: "failed",
2420+ },
2421+ ...[
2422+ "recovered-missing-reset",
2423+ "recovered-next-reset",
2424+ "recovered-warning",
2425+ "two-windows-recovered",
2426+ ].map((evidence) => ({
2427+ name: evidence,
2428+ evidence,
2429+ result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] },
2430+ expected: /repeated API errors/,
2431+ expectedState: "failed",
2432+ })),
2433+ ...["two-windows-one-recovered", "rejected-again"].map((evidence) => ({
2434+ name: evidence,
2435+ evidence,
2436+ result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] },
2437+ expected: /usage limit reached/,
2438+ expectedState: "failed",
2439+ })),
2440+ {
2441+ name: "successful turn stays successful",
2442+ evidence: "recovered",
2443+ result: { subtype: "success", is_error: false, errors: [] },
2444+ expected: undefined,
2445+ expectedState: "completed",
2446+ },
2447+ ])(
2448+ "preserves terminal failure evidence after $name",
2449+ ({ evidence, result, expected, expectedState }) => {
2450+ const harness = makeHarness();
2451+ return Effect.gen(function* () {
2452+ const adapter = yield* ClaudeAdapter;
2453+ const eventsFiber = yield* adapter.streamEvents.pipe(
2454+ Stream.takeUntil((event) => event.type === "turn.completed"),
2455+ Stream.runCollect,
2456+ Effect.forkChild,
2457+ );
2458+ const session = yield* adapter.startSession({
2459+ threadId: THREAD_ID,
2460+ provider: ProviderDriverKind.make("claudeAgent"),
2461+ runtimeMode: "full-access",
2462+ });
2463+ yield* adapter.sendTurn({
2464+ threadId: session.threadId,
2465+ input: "synthetic hello",
2466+ attachments: [],
2467+ });
2468+ if (evidence === "auth" || evidence === "nested-auth") {
2469+ harness.query.emit({
2470+ type: "assistant",
2471+ session_id: "sdk-audit",
2472+ uuid: "audit-auth",
2473+ parent_tool_use_id: evidence === "nested-auth" ? "synthetic-parent-tool" : null,
2474+ error: "authentication_failed",
2475+ is_api_error_message: true,
2476+ message: {
2477+ id: "audit-message",
2478+ model: "synthetic-audit-model",
2479+ content: [{ type: "text", text: "Not logged in. Please run /login" }],
2480+ },
2481+ } as unknown as SDKMessage);
2482+ } else {
2483+ const nowMs = yield* Clock.currentTimeMillis;
2484+ const resetsAt = Math.floor(nowMs / 1000) + 7200;
2485+ harness.query.emit({
2486+ type: "rate_limit_event",
2487+ rate_limit_info: { status: "rejected", rateLimitType: "five_hour", resetsAt },
2488+ session_id: "sdk-audit",
2489+ uuid: "audit-limit-rejected",
2490+ } as unknown as SDKMessage);
2491+ if (evidence.startsWith("two-windows")) {
2492+ harness.query.emit({
2493+ type: "rate_limit_event",
2494+ rate_limit_info: { status: "rejected", rateLimitType: "seven_day", resetsAt },
2495+ session_id: "sdk-audit",
2496+ uuid: "audit-weekly-rejected",
2497+ } as unknown as SDKMessage);
2498+ }
2499+ harness.query.emit({
2500+ type: "rate_limit_event",
2501+ rate_limit_info: {
2502+ status: evidence === "recovered-warning" ? "allowed_warning" : "allowed",
2503+ rateLimitType: "five_hour",
2504+ ...(evidence === "recovered-missing-reset"
2505+ ? {}
2506+ : {
2507+ resetsAt: evidence === "recovered-next-reset" ? resetsAt + 18000 : resetsAt,
2508+ }),
2509+ },
2510+ session_id: "sdk-audit",
2511+ uuid: "audit-limit-allowed",
2512+ } as unknown as SDKMessage);
2513+ if (evidence === "two-windows-recovered" || evidence === "rejected-again") {
2514+ harness.query.emit({
2515+ type: "rate_limit_event",
2516+ rate_limit_info: {
2517+ status: evidence === "rejected-again" ? "rejected" : "allowed",
2518+ rateLimitType: evidence === "rejected-again" ? "five_hour" : "seven_day",
2519+ resetsAt,
2520+ },
2521+ session_id: "sdk-audit",
2522+ uuid: "audit-final-quota-update",
2523+ } as unknown as SDKMessage);
2524+ }
2525+ }
2526+ harness.query.emit({
2527+ type: "result",
2528+ ...result,
2529+ session_id: "sdk-audit",
2530+ uuid: "audit-result",
2531+ } as unknown as SDKMessage);
2532+ const events = Array.from(yield* Fiber.join(eventsFiber));
2533+ const complete = events.at(-1);
2534+ assert(complete?.type === "turn.completed");
2535+ assert.equal(complete.payload.state, expectedState);
2536+ if (expected) assert.match(complete.payload.errorMessage ?? "", expected);
2537+ else assert.equal(complete.payload.errorMessage, undefined);
2538+ if (evidence === "rejected-again")
2539+ assert.equal(events.filter((event) => event.type === "runtime.warning").length, 1);
2540+ }).pipe(
2541+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
2542+ Effect.provide(harness.layer),
2543+ );
2544+ },
2545+ );
2546+
2547+ it.effect.each([
2548+ { homePath: "./synthetic config's $literal", inherited: undefined },
2549+ { homePath: "", inherited: ".synthetic config's $literal" },
2550+ { homePath: "", inherited: " /synthetic/path with edge spaces " },
2551+ ])(
2552+ "reports the same Claude config and cwd used by the spawned query ($homePath, $inherited)",
2553+ ({ homePath, inherited }) => {
2554+ const harness = makeHarness({
2555+ claudeConfig: { homePath },
2556+ environment: { ...process.env, CLAUDE_CONFIG_DIR: inherited },
2557+ });
2558+ return Effect.gen(function* () {
2559+ const adapter = yield* ClaudeAdapter;
2560+ const eventsFiber = yield* adapter.streamEvents.pipe(
2561+ Stream.takeUntil((event) => event.type === "turn.completed"),
2562+ Stream.runCollect,
2563+ Effect.forkChild,
2564+ );
2565+ const cwd = NodePath.resolve("/tmp/synthetic-audit-project");
2566+ const session = yield* adapter.startSession({
2567+ threadId: THREAD_ID,
2568+ provider: ProviderDriverKind.make("claudeAgent"),
2569+ runtimeMode: "full-access",
2570+ cwd,
2571+ });
2572+ yield* adapter.sendTurn({
2573+ threadId: session.threadId,
2574+ input: "synthetic",
2575+ attachments: [],
2576+ });
2577+ harness.query.emit(AUTH_FAILURE_ASSISTANT);
2578+ harness.query.emit({
2579+ type: "result",
2580+ subtype: "success",
2581+ is_error: false,
2582+ terminal_reason: "api_error",
2583+ errors: [],
2584+ session_id: "sdk-session-auth",
2585+ uuid: "result-auth",
2586+ } as unknown as SDKMessage);
2587+ const events = Array.from(yield* Fiber.join(eventsFiber));
2588+ const completed = events.at(-1);
2589+ assert(completed?.type === "turn.completed");
2590+ const actualQuery = harness.getLastCreateQueryInput();
2591+ assert(actualQuery !== undefined);
2592+ const expectedConfigDir = homePath ? NodePath.resolve(homePath) : inherited;
2593+ assert.equal(actualQuery.options.env?.CLAUDE_CONFIG_DIR, expectedConfigDir);
2594+ assert.equal(actualQuery.options.cwd, cwd);
2595+ assert(
2596+ completed.payload.errorMessage?.includes(
2597+ `CLAUDE_CONFIG_DIR set to ${encodeUnknownJsonString(expectedConfigDir)}`,
2598+ ),
2599+ );
2600+ assert(completed.payload.errorMessage?.includes(`from ${encodeUnknownJsonString(cwd)}`));
2601+ assert(!completed.payload.errorMessage?.includes("CLAUDE_CONFIG_DIR="));
2602+ }).pipe(
2603+ Effect.provideService(Random.Random, makeDeterministicRandomService()),
2604+ Effect.provide(harness.layer),
2605+ );
2606+ },
2607+ );
2608+
22022609 it.effect("fails a turn for every dead-turn terminal_reason", () => {
22032610 const reasons = [
22042611 "blocking_limit",
apps/server/src/provider/Layers/ClaudeAdapter.ts+67−18
@@ -83,7 +83,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; · 82 unmodified lines
8383 import { ServerConfig } from "../../config.ts";
8484 import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
8585 import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
86−import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
86+import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
8787 import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
8888 import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
8989 import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
@@ -159,6 +159,8 @@ interface ClaudeTurnState { · 69 unmodified lines
159159 compactedSinceLatestAssistantUsage: boolean;
160160 hasSubagents: boolean;
161161 nextSyntheticAssistantBlockIndex: number;
162+ authenticationFailureMessage: string | undefined;
163+ rejectedRateLimitTypes: Set<string>;
162164 }
163165
164166 interface AssistantTextBlockState {
@@ -435,10 +437,13 @@ function resultErrorsText(result: SDKResultMessage): string { · 270 unmodified lines
435437 }
436438
437439 /** Failure text for structured terminal reasons, including success-tagged failures. */
438−function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined {
440+function terminalResultError(
441+ reason: SDKResultMessage["terminal_reason"],
442+ failureHint?: string,
443+): string | undefined {
439444 switch (reason) {
440445 case "api_error":
441− return "Claude gave up after repeated API errors.";
446+ return failureHint ?? "Claude gave up after repeated API errors.";
442447 case "malformed_tool_use_exhausted":
443448 return "Claude gave up after repeated malformed tool calls.";
444449 case "budget_exhausted":
@@ -1552,18 +1557,31 @@ function isOverloadedResult(result: SDKResultMessage): boolean { · 1107 unmodified lines
15521557 }
15531558
15541559 /** Derives turn status and its error from the same provider result. */
1555−function resultOutcome(result: SDKResultMessage): {
1560+function resultOutcome(
1561+ result: SDKResultMessage,
1562+ failureHint?: string,
1563+): {
15561564 status: ProviderRuntimeTurnStatus;
15571565 errorMessage: string | undefined;
15581566 } {
1567+ // A success result flagged is_error only fails when the turn already
1568+ // reported its cause (expired login, rejected usage window).
1569+ const successTaggedFailure = result.subtype === "success" && result.is_error === true;
15591570 const structuredError = isOverloadedResult(result)
15601571 ? "Claude API is overloaded (529). Try again shortly."
1561− : terminalResultError(result.terminal_reason);
1562− // CLI diagnostic entries must not become the error banner.
1572+ : (terminalResultError(result.terminal_reason, failureHint) ??
1573+ (successTaggedFailure ? failureHint : undefined));
1574+ // CLI diagnostic entries must not become the error banner. Success results
1575+ // carry no typed error list, but a success-tagged failure may still list one.
1576+ const listedErrors: ReadonlyArray<unknown> =
1577+ "errors" in result && Array.isArray(result.errors) ? result.errors : [];
15631578 const listedError =
1564− result.subtype === "success" || !Array.isArray(result.errors)
1579+ result.subtype === "success" && !successTaggedFailure
15651580 ? undefined
1566− : result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));
1581+ : listedErrors.find(
1582+ (error): error is string =>
1583+ typeof error === "string" && !error.startsWith("[ede_diagnostic]"),
1584+ );
15671585 const errorMessage = listedError || structuredError;
15681586 if (structuredError !== undefined) return { status: "failed", errorMessage };
15691587 if (result.subtype === "success") return { status: "completed", errorMessage };
@@ -3148,6 +3166,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 1578 unmodified lines
31483166 compactedSinceLatestAssistantUsage: false,
31493167 hasSubagents: false,
31503168 nextSyntheticAssistantBlockIndex: -1,
3169+ authenticationFailureMessage: undefined,
3170+ rejectedRateLimitTypes: new Set(),
31513171 };
31523172 context.session = {
31533173 ...context.session,
@@ -3206,6 +3226,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 52 unmodified lines
32063226 }
32073227
32083228 if (context.turnState) {
3229+ // The CLI can report authentication failure before ending the turn as a
3230+ // generic API error, so retain that evidence for the result fallback.
3231+ if (message.error === "authentication_failed") {
3232+ context.turnState.authenticationFailureMessage = claudeSignedOutMessage({
3233+ configDir: claudeEnvironment.CLAUDE_CONFIG_DIR,
3234+ cwd: path.resolve(context.session.cwd ?? "."),
3235+ });
3236+ }
32093237 context.turnState.items.push(message.message);
32103238 if (
32113239 normalizeClaudeActiveTokenUsage(
@@ -3232,7 +3260,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 20 unmodified lines
32323260 return;
32333261 }
32343262
3235− const { status, errorMessage } = resultOutcome(message);
3263+ const turn = context.turnState;
3264+ const failureHint =
3265+ turn?.authenticationFailureMessage ??
3266+ (turn && turn.rejectedRateLimitTypes.size > 0
3267+ ? "Claude usage limit reached. Send the message again once the limit resets."
3268+ : undefined);
3269+ const { status, errorMessage } = resultOutcome(message, failureHint);
32363270
32373271 if (status === "failed") {
32383272 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
@@ -3835,14 +3869,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 596 unmodified lines
38353869 // Warnings (allowed_warning) still have headroom and stay quiet, an
38363870 // account spending provisioned overage keeps running despite the reject,
38373871 // and between turns there is no turn to report as paused.
3838− if (
3839− rateLimitInfo.status === "rejected" &&
3840− rateLimitInfo.overageStatus !== "allowed" &&
3841− rateLimitInfo.overageStatus !== "allowed_warning" &&
3842− rateLimitInfo.isUsingOverage !== true &&
3843− rateLimitInfo.overageInUse !== true &&
3844− context.turnState !== undefined
3845− ) {
3872+ const overageAllowed =
3873+ rateLimitInfo.overageStatus === "allowed" ||
3874+ rateLimitInfo.overageStatus === "allowed_warning" ||
3875+ rateLimitInfo.isUsingOverage === true ||
3876+ rateLimitInfo.overageInUse === true;
3877+ const blocked = rateLimitInfo.status === "rejected" && !overageAllowed;
3878+ const limitType = rateLimitInfo.rateLimitType ?? "unknown";
3879+ const limitKey = `${limitType}:${rateLimitInfo.resetsAt ?? "unknown"}`;
3880+ if (context.turnState) {
3881+ // Current blocking evidence is independent of whether its warning has
3882+ // already been shown. A recovery can omit or advance the reset time;
3883+ // its window type remains stable without clearing another window.
3884+ if (blocked) context.turnState.rejectedRateLimitTypes.add(limitType);
3885+ else if (
3886+ rateLimitInfo.status === "allowed" ||
3887+ rateLimitInfo.status === "allowed_warning" ||
3888+ overageAllowed
3889+ ) {
3890+ context.turnState.rejectedRateLimitTypes.delete(limitType);
3891+ }
3892+ }
3893+ if (blocked && context.turnState !== undefined) {
38463894 // Tracked per turn as a set of limit identities, not as the rendered
38473895 // row: a parked window re-fires while the remaining wait shrinks, and a
38483896 // turn can park on more than one window, so a single slot would let an
@@ -3852,7 +3900,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 3 unmodified lines
38523900 if (context.announcedUsageLimits?.turnId !== turnId) {
38533901 context.announcedUsageLimits = { turnId, keys: new Set() };
38543902 }
3855− const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`;
38563903 if (!context.announcedUsageLimits.keys.has(limitKey)) {
38573904 context.announcedUsageLimits.keys.add(limitKey);
38583905 const notice = describeClaudeUsageLimit(
@@ -4900,6 +4947,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 1041 unmodified lines
49004947 compactedSinceLatestAssistantUsage: false,
49014948 hasSubagents: false,
49024949 nextSyntheticAssistantBlockIndex: -1,
4950+ authenticationFailureMessage: undefined,
4951+ rejectedRateLimitTypes: new Set(),
49034952 };
49044953
49054954 const updatedAt = yield* nowIso;