| ... | ... | @@ -2367,6 +2367,139 @@ describe("ClaudeAdapterLive", () => { |
| 2367 | 2367 | ); |
| 2368 | 2368 | }); |
| 2369 | 2369 | |
| 2370 | const usageLimitMessage = |
| 2371 | "Claude usage limit reached. Send the message again once the limit resets."; |
| 2372 | const genericApiErrorMessage = "Claude gave up after repeated API errors."; |
| 2373 | const rateLimitAssistant = { |
| 2374 | type: "assistant", |
| 2375 | session_id: "sdk-session-limit", |
| 2376 | uuid: "assistant-limit", |
| 2377 | parent_tool_use_id: null, |
| 2378 | error: "rate_limit", |
| 2379 | message: { |
| 2380 | id: "assistant-message-limit", |
| 2381 | model: "<synthetic>", |
| 2382 | content: [{ type: "text", text: "You've hit your session limit" }], |
| 2383 | }, |
| 2384 | }; |
| 2385 | const rateLimitResult = { |
| 2386 | type: "result", |
| 2387 | subtype: "success", |
| 2388 | is_error: true, |
| 2389 | terminal_reason: "api_error", |
| 2390 | session_id: "sdk-session-limit", |
| 2391 | uuid: "result-limit", |
| 2392 | }; |
| 2393 | |
| 2394 | it.effect.each([ |
| 2395 | { |
| 2396 | name: "an assistant-only rate limit", |
| 2397 | messages: [rateLimitAssistant], |
| 2398 | expected: usageLimitMessage, |
| 2399 | }, |
| 2400 | { |
| 2401 | name: "a normal parent response after a rate limit", |
| 2402 | messages: [rateLimitAssistant, { ...rateLimitAssistant, error: undefined }], |
| 2403 | expected: genericApiErrorMessage, |
| 2404 | }, |
| 2405 | { |
| 2406 | name: "a server error after a rate limit", |
| 2407 | messages: [rateLimitAssistant, { ...rateLimitAssistant, error: "server_error" }], |
| 2408 | expected: genericApiErrorMessage, |
| 2409 | }, |
| 2410 | { |
| 2411 | name: "a subagent rate limit", |
| 2412 | messages: [{ ...rateLimitAssistant, parent_tool_use_id: "nested-tool" }], |
| 2413 | expected: genericApiErrorMessage, |
| 2414 | }, |
| 2415 | { |
| 2416 | name: "a subagent response after a parent rate limit", |
| 2417 | messages: [ |
| 2418 | rateLimitAssistant, |
| 2419 | { ...rateLimitAssistant, error: undefined, parent_tool_use_id: "nested-tool" }, |
| 2420 | ], |
| 2421 | expected: usageLimitMessage, |
| 2422 | }, |
| 2423 | ])("classifies the terminal API failure after $name", ({ messages, expected }) => { |
| 2424 | const harness = makeHarness(); |
| 2425 | return Effect.gen(function* () { |
| 2426 | const adapter = yield* ClaudeAdapter; |
| 2427 | const eventsFiber = yield* adapter.streamEvents.pipe( |
| 2428 | Stream.takeUntil((event) => event.type === "turn.completed"), |
| 2429 | Stream.runCollect, |
| 2430 | Effect.forkChild, |
| 2431 | ); |
| 2432 | const session = yield* adapter.startSession({ |
| 2433 | threadId: THREAD_ID, |
| 2434 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2435 | runtimeMode: "full-access", |
| 2436 | }); |
| 2437 | yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); |
| 2438 | for (const [index, message] of messages.entries()) { |
| 2439 | harness.query.emit({ ...message, uuid: `assistant-${index}` } as unknown as SDKMessage); |
| 2440 | } |
| 2441 | harness.query.emit(rateLimitResult as unknown as SDKMessage); |
| 2442 | |
| 2443 | const events = Array.from(yield* Fiber.join(eventsFiber)); |
| 2444 | const errors = events.filter((event) => event.type === "runtime.error"); |
| 2445 | assert.equal(errors.length, 1); |
| 2446 | assert.equal(errors[0]?.payload.message, expected); |
| 2447 | assert.equal(completedTurn(events).state, "failed"); |
| 2448 | assert.equal(completedTurn(events).errorMessage, expected); |
| 2449 | }).pipe( |
| 2450 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 2451 | Effect.provide(harness.layer), |
| 2452 | ); |
| 2453 | }); |
| 2454 | |
| 2455 | it.effect("names repeated usage limits without carrying them into a later turn", () => { |
| 2456 | const harness = makeHarness(); |
| 2457 | return Effect.gen(function* () { |
| 2458 | const adapter = yield* ClaudeAdapter; |
| 2459 | const session = yield* adapter.startSession({ |
| 2460 | threadId: THREAD_ID, |
| 2461 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2462 | runtimeMode: "full-access", |
| 2463 | }); |
| 2464 | for (const [index, expected] of [ |
| 2465 | usageLimitMessage, |
| 2466 | usageLimitMessage, |
| 2467 | genericApiErrorMessage, |
| 2468 | ].entries()) { |
| 2469 | const eventsFiber = yield* adapter.streamEvents.pipe( |
| 2470 | Stream.takeUntil((event) => event.type === "turn.completed"), |
| 2471 | Stream.runCollect, |
| 2472 | Effect.forkChild, |
| 2473 | ); |
| 2474 | yield* adapter.sendTurn({ threadId: session.threadId, input: "again", attachments: [] }); |
| 2475 | if (index === 0) { |
| 2476 | harness.query.emit({ |
| 2477 | type: "rate_limit_event", |
| 2478 | rate_limit_info: { status: "rejected", rateLimitType: "five_hour" }, |
| 2479 | session_id: "sdk-session-limit", |
| 2480 | uuid: "limit-rejected", |
| 2481 | } as unknown as SDKMessage); |
| 2482 | } |
| 2483 | if (index < 2) { |
| 2484 | harness.query.emit({ |
| 2485 | ...rateLimitAssistant, |
| 2486 | uuid: `assistant-limit-${index}`, |
| 2487 | } as unknown as SDKMessage); |
| 2488 | } |
| 2489 | harness.query.emit({ |
| 2490 | ...rateLimitResult, |
| 2491 | uuid: `result-limit-${index}`, |
| 2492 | } as unknown as SDKMessage); |
| 2493 | const payload = completedTurn(Array.from(yield* Fiber.join(eventsFiber))); |
| 2494 | assert.equal(payload.state, "failed"); |
| 2495 | assert.equal(payload.errorMessage, expected); |
| 2496 | } |
| 2497 | }).pipe( |
| 2498 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 2499 | Effect.provide(harness.layer), |
| 2500 | ); |
| 2501 | }); |
| 2502 | |
| 2370 | 2503 | it.effect.each([ |
| 2371 | 2504 | { |
| 2372 | 2505 | name: "listed error with api_error", |
| ... | ... | @@ -2418,6 +2551,43 @@ describe("ClaudeAdapterLive", () => { |
| 2418 | 2551 | expected: /repeated API errors/, |
| 2419 | 2552 | expectedState: "failed", |
| 2420 | 2553 | }, |
| 2554 | { |
| 2555 | name: "assistant rate limit followed by overload", |
| 2556 | evidence: "assistant-rate-limit", |
| 2557 | result: { |
| 2558 | subtype: "success", |
| 2559 | is_error: true, |
| 2560 | terminal_reason: "api_error", |
| 2561 | api_error_status: 529, |
| 2562 | errors: [], |
| 2563 | }, |
| 2564 | expected: /overloaded \(529\)/, |
| 2565 | expectedState: "failed", |
| 2566 | }, |
| 2567 | { |
| 2568 | name: "assistant rate limit followed by a listed error", |
| 2569 | evidence: "assistant-rate-limit", |
| 2570 | result: { |
| 2571 | subtype: "success", |
| 2572 | is_error: true, |
| 2573 | terminal_reason: "api_error", |
| 2574 | errors: ["Tool execution failed: EACCES"], |
| 2575 | }, |
| 2576 | expected: /EACCES/, |
| 2577 | expectedState: "failed", |
| 2578 | }, |
| 2579 | { |
| 2580 | name: "assistant rate limit followed by an interrupt", |
| 2581 | evidence: "assistant-rate-limit", |
| 2582 | result: { |
| 2583 | subtype: "error_during_execution", |
| 2584 | is_error: true, |
| 2585 | terminal_reason: "aborted_tools", |
| 2586 | errors: [], |
| 2587 | }, |
| 2588 | expected: undefined, |
| 2589 | expectedState: "interrupted", |
| 2590 | }, |
| 2421 | 2591 | ...[ |
| 2422 | 2592 | "recovered-missing-reset", |
| 2423 | 2593 | "recovered-next-reset", |
| ... | ... | @@ -2465,7 +2635,9 @@ describe("ClaudeAdapterLive", () => { |
| 2465 | 2635 | input: "synthetic hello", |
| 2466 | 2636 | attachments: [], |
| 2467 | 2637 | }); |
| 2468 | | if (evidence === "auth" || evidence === "nested-auth") { |
| 2638 | if (evidence === "assistant-rate-limit") { |
| 2639 | harness.query.emit(rateLimitAssistant as unknown as SDKMessage); |
| 2640 | } else if (evidence === "auth" || evidence === "nested-auth") { |
| 2469 | 2641 | harness.query.emit({ |
| 2470 | 2642 | type: "assistant", |
| 2471 | 2643 | session_id: "sdk-audit", |