| author | |
| committer | |
| log | 3d00cfd5a710a58bf108de7a66fbe00a1420cc78 |
| tree | 38a510a8ac44e848b1a96dc52f52f2c988831000 |
| parent | 29c5ecd0ee288489d254905dc6aa9844be36db8e |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 6 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 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 | ); |
| 41 | 42 | ||
| 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"; |
| 3 | import type { ClaudeSettings } from "@t3tools/contracts"; | 3 | import type { ClaudeSettings } from "@t3tools/contracts"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Path from "effect/Path"; | 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; | ||
| 6 | 7 | ||
| 7 | import { expandHomePath } from "../../pathExpansion.ts"; | 8 | import { expandHomePath } from "../../pathExpansion.ts"; |
| 8 | 9 | ||
| 10 | const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); | ||
| 11 | |||
| 9 | export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( | 12 | export 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 | */ | ||
| 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| ... | @@ -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; |
| 179 | 180 | ||
| 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 | }); |
| 2201 | 2203 | ||
| 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"; |
| 83 | import { ServerConfig } from "../../config.ts"; | 83 | import { ServerConfig } from "../../config.ts"; |
| 84 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 84 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 85 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | 85 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; |
| 86 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | 86 | import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 87 | import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; | 87 | import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; |
| 88 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; | 88 | import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; |
| 89 | import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; | 89 | import { 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 | } |
| 163 | 165 | ||
| 164 | interface AssistantTextBlockState { | 166 | interface AssistantTextBlockState { |
| ... | @@ -435,10 +437,13 @@ function resultErrorsText(result: SDKResultMessage): string { | ... | @@ -435,10 +437,13 @@ function resultErrorsText(result: SDKResultMessage): string { |
| 435 | } | 437 | } |
| 436 | 438 | ||
| 437 | /** Failure text for structured terminal reasons, including success-tagged failures. */ | 439 | /** 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 { | ||
| 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 | } |
| 1553 | 1558 | ||
| 1554 | /** Derives turn status and its error from the same provider result. */ | 1559 | /** 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 | ): { | ||
| 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 | ? undefined | 1580 | ? 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 | } |
| 3207 | 3227 | ||
| 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 | } |
| 3234 | 3262 | ||
| 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); | ||
| 3236 | 3270 | ||
| 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, an | 3869 | // 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 !== undefined | 3878 | 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 rendered | 3894 | // 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 a | 3895 | // 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 an | 3896 | // 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 | }; |
| 4904 | 4953 | ||
| 4905 | const updatedAt = yield* nowIso; | 4954 | const updatedAt = yield* nowIso; |