authorgravatar for vitalyiegorov@gmail.comVitaly Iegorov <vitalyiegorov@gmail.com> 2026-09-07 01:37:56+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 16:37:56-07:00
log3d00cfd5a710a58bf108de7a66fbe00a1420cc78
tree38a510a8ac44e848b1a96dc52f52f2c988831000
parent29c5ecd0ee288489d254905dc6aa9844be36db8e
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 504 insertions(+), 18 deletions(-)

apps/server/src/provider/Drivers/ClaudeHome.test.ts+12
...@@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";...@@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
77
8import {8import {
9 claudeSignedOutMessage,
9 makeClaudeCapabilitiesCacheKey,10 makeClaudeCapabilitiesCacheKey,
10 makeClaudeContinuationGroupKey,11 makeClaudeContinuationGroupKey,
11 makeClaudeEnvironment,12 makeClaudeEnvironment,
...@@ -39,6 +40,17 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {...@@ -39,6 +40,17 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
39 }),40 }),
40 );41 );
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
42 it.effect("separates capability probes by cwd", () =>54 it.effect("separates capability probes by cwd", () =>
43 Effect.gen(function* () {55 Effect.gen(function* () {
44 const config = { binaryPath: "claude", homePath: "" };56 const config = { binaryPath: "claude", homePath: "" };
apps/server/src/provider/Drivers/ClaudeHome.ts+18
...@@ -3,9 +3,12 @@ import * as NodeOS from "node:os";...@@ -3,9 +3,12 @@ import * as NodeOS from "node:os";
3import type { ClaudeSettings } from "@t3tools/contracts";3import type { ClaudeSettings } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
67
7import { expandHomePath } from "../../pathExpansion.ts";8import { expandHomePath } from "../../pathExpansion.ts";
89
10const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String));
11
9export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* (12export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* (
10 config: Pick<ClaudeSettings, "homePath">,13 config: Pick<ClaudeSettings, "homePath">,
11): Effect.fn.Return<string, never, Path.Path> {14): Effect.fn.Return<string, never, Path.Path> {
...@@ -50,3 +53,18 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC...@@ -50,3 +53,18 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC
50 return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`;53 return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`;
51 },54 },
52);55);
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 */
61export 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
...@@ -168,6 +168,7 @@ function makeHarness(config?: {...@@ -168,6 +168,7 @@ function makeHarness(config?: {
168 readonly claudeConfig?: Partial<ClaudeSettings>;168 readonly claudeConfig?: Partial<ClaudeSettings>;
169 readonly instanceId?: ProviderInstanceId;169 readonly instanceId?: ProviderInstanceId;
170 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];170 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
171 readonly environment?: ClaudeAdapterLiveOptions["environment"];
171}) {172}) {
172 const query = new FakeClaudeQuery();173 const query = new FakeClaudeQuery();
173 let createInput:174 let createInput:
...@@ -178,6 +179,7 @@ function makeHarness(config?: {...@@ -178,6 +179,7 @@ function makeHarness(config?: {
178 | undefined;179 | undefined;
179180
180 const adapterOptions: ClaudeAdapterLiveOptions = {181 const adapterOptions: ClaudeAdapterLiveOptions = {
182 ...(config?.environment ? { environment: config.environment } : {}),
181 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),183 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
182 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),184 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
183 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),185 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
...@@ -2199,6 +2201,411 @@ describe("ClaudeAdapterLive", () => {...@@ -2199,6 +2201,411 @@ describe("ClaudeAdapterLive", () => {
2199 );2201 );
2200 });2202 });
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
2202 it.effect("fails a turn for every dead-turn terminal_reason", () => {2609 it.effect("fails a turn for every dead-turn terminal_reason", () => {
2203 const reasons = [2610 const reasons = [
2204 "blocking_limit",2611 "blocking_limit",
apps/server/src/provider/Layers/ClaudeAdapter.ts+67-18
...@@ -83,7 +83,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";...@@ -83,7 +83,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
83import { ServerConfig } from "../../config.ts";83import { ServerConfig } from "../../config.ts";
84import * as McpProviderSession from "../../mcp/McpProviderSession.ts";84import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
85import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";85import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
86import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";86import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
87import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";87import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
88import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";88import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
89import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";89import { buildRuntimeInstructions } from "../RuntimeInstructions.ts";
...@@ -159,6 +159,8 @@ interface ClaudeTurnState {...@@ -159,6 +159,8 @@ interface ClaudeTurnState {
159 compactedSinceLatestAssistantUsage: boolean;159 compactedSinceLatestAssistantUsage: boolean;
160 hasSubagents: boolean;160 hasSubagents: boolean;
161 nextSyntheticAssistantBlockIndex: number;161 nextSyntheticAssistantBlockIndex: number;
162 authenticationFailureMessage: string | undefined;
163 rejectedRateLimitTypes: Set<string>;
162}164}
163165
164interface AssistantTextBlockState {166interface AssistantTextBlockState {
...@@ -435,10 +437,13 @@ function resultErrorsText(result: SDKResultMessage): string {...@@ -435,10 +437,13 @@ function resultErrorsText(result: SDKResultMessage): string {
435}437}
436438
437/** Failure text for structured terminal reasons, including success-tagged failures. */439/** Failure text for structured terminal reasons, including success-tagged failures. */
438function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined {440function terminalResultError(
441 reason: SDKResultMessage["terminal_reason"],
442 failureHint?: string,
443): string | undefined {
439 switch (reason) {444 switch (reason) {
440 case "api_error":445 case "api_error":
441 return "Claude gave up after repeated API errors.";446 return failureHint ?? "Claude gave up after repeated API errors.";
442 case "malformed_tool_use_exhausted":447 case "malformed_tool_use_exhausted":
443 return "Claude gave up after repeated malformed tool calls.";448 return "Claude gave up after repeated malformed tool calls.";
444 case "budget_exhausted":449 case "budget_exhausted":
...@@ -1552,18 +1557,31 @@ function isOverloadedResult(result: SDKResultMessage): boolean {...@@ -1552,18 +1557,31 @@ function isOverloadedResult(result: SDKResultMessage): boolean {
1552}1557}
15531558
1554/** Derives turn status and its error from the same provider result. */1559/** Derives turn status and its error from the same provider result. */
1555function resultOutcome(result: SDKResultMessage): {1560function resultOutcome(
1561 result: SDKResultMessage,
1562 failureHint?: string,
1563): {
1556 status: ProviderRuntimeTurnStatus;1564 status: ProviderRuntimeTurnStatus;
1557 errorMessage: string | undefined;1565 errorMessage: string | undefined;
1558} {1566} {
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;
1559 const structuredError = isOverloadedResult(result)1570 const structuredError = isOverloadedResult(result)
1560 ? "Claude API is overloaded (529). Try again shortly."1571 ? "Claude API is overloaded (529). Try again shortly."
1561 : terminalResultError(result.terminal_reason);1572 : (terminalResultError(result.terminal_reason, failureHint) ??
1562 // CLI diagnostic entries must not become the error banner.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 : [];
1563 const listedError =1578 const listedError =
1564 result.subtype === "success" || !Array.isArray(result.errors)1579 result.subtype === "success" && !successTaggedFailure
1565 ? undefined1580 ? 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 );
1567 const errorMessage = listedError || structuredError;1585 const errorMessage = listedError || structuredError;
1568 if (structuredError !== undefined) return { status: "failed", errorMessage };1586 if (structuredError !== undefined) return { status: "failed", errorMessage };
1569 if (result.subtype === "success") return { status: "completed", errorMessage };1587 if (result.subtype === "success") return { status: "completed", errorMessage };
...@@ -3148,6 +3166,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3148,6 +3166,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3148 compactedSinceLatestAssistantUsage: false,3166 compactedSinceLatestAssistantUsage: false,
3149 hasSubagents: false,3167 hasSubagents: false,
3150 nextSyntheticAssistantBlockIndex: -1,3168 nextSyntheticAssistantBlockIndex: -1,
3169 authenticationFailureMessage: undefined,
3170 rejectedRateLimitTypes: new Set(),
3151 };3171 };
3152 context.session = {3172 context.session = {
3153 ...context.session,3173 ...context.session,
...@@ -3206,6 +3226,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3206,6 +3226,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3206 }3226 }
32073227
3208 if (context.turnState) {3228 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 }
3209 context.turnState.items.push(message.message);3237 context.turnState.items.push(message.message);
3210 if (3238 if (
3211 normalizeClaudeActiveTokenUsage(3239 normalizeClaudeActiveTokenUsage(
...@@ -3232,7 +3260,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3232,7 +3260,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3232 return;3260 return;
3233 }3261 }
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
3237 if (status === "failed") {3271 if (status === "failed") {
3238 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");3272 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");
...@@ -3835,14 +3869,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3835,14 +3869,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3835 // Warnings (allowed_warning) still have headroom and stay quiet, an3869 // Warnings (allowed_warning) still have headroom and stay quiet, an
3836 // account spending provisioned overage keeps running despite the reject,3870 // account spending provisioned overage keeps running despite the reject,
3837 // and between turns there is no turn to report as paused.3871 // and between turns there is no turn to report as paused.
3838 if (3872 const overageAllowed =
3839 rateLimitInfo.status === "rejected" &&3873 rateLimitInfo.overageStatus === "allowed" ||
3840 rateLimitInfo.overageStatus !== "allowed" &&3874 rateLimitInfo.overageStatus === "allowed_warning" ||
3841 rateLimitInfo.overageStatus !== "allowed_warning" &&3875 rateLimitInfo.isUsingOverage === true ||
3842 rateLimitInfo.isUsingOverage !== true &&3876 rateLimitInfo.overageInUse === true;
3843 rateLimitInfo.overageInUse !== true &&3877 const blocked = rateLimitInfo.status === "rejected" && !overageAllowed;
3844 context.turnState !== undefined3878 const limitType = rateLimitInfo.rateLimitType ?? "unknown";
3845 ) {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) {
3846 // Tracked per turn as a set of limit identities, not as the rendered3894 // Tracked per turn as a set of limit identities, not as the rendered
3847 // row: a parked window re-fires while the remaining wait shrinks, and a3895 // row: a parked window re-fires while the remaining wait shrinks, and a
3848 // turn can park on more than one window, so a single slot would let an3896 // 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* (...@@ -3852,7 +3900,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3852 if (context.announcedUsageLimits?.turnId !== turnId) {3900 if (context.announcedUsageLimits?.turnId !== turnId) {
3853 context.announcedUsageLimits = { turnId, keys: new Set() };3901 context.announcedUsageLimits = { turnId, keys: new Set() };
3854 }3902 }
3855 const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`;
3856 if (!context.announcedUsageLimits.keys.has(limitKey)) {3903 if (!context.announcedUsageLimits.keys.has(limitKey)) {
3857 context.announcedUsageLimits.keys.add(limitKey);3904 context.announcedUsageLimits.keys.add(limitKey);
3858 const notice = describeClaudeUsageLimit(3905 const notice = describeClaudeUsageLimit(
...@@ -4900,6 +4947,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4900,6 +4947,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4900 compactedSinceLatestAssistantUsage: false,4947 compactedSinceLatestAssistantUsage: false,
4901 hasSubagents: false,4948 hasSubagents: false,
4902 nextSyntheticAssistantBlockIndex: -1,4949 nextSyntheticAssistantBlockIndex: -1,
4950 authenticationFailureMessage: undefined,
4951 rejectedRateLimitTypes: new Set(),
4903 };4952 };
49044953
4905 const updatedAt = yield* nowIso;4954 const updatedAt = yield* nowIso;