| ... | @@ -23,6 +23,7 @@ import { | ... | @@ -23,6 +23,7 @@ import { |
| 23 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 24 | import { createModelSelection } from "@t3tools/shared/model"; | 24 | import { createModelSelection } from "@t3tools/shared/model"; |
| 25 | import { assert, describe, it } from "@effect/vitest"; | 25 | import { assert, describe, it } from "@effect/vitest"; |
| | 26 | import * as Clock from "effect/Clock"; |
| 26 | import * as Context from "effect/Context"; | 27 | import * as Context from "effect/Context"; |
| 27 | import * as Deferred from "effect/Deferred"; | 28 | import * as Deferred from "effect/Deferred"; |
| 28 | import * as Effect from "effect/Effect"; | 29 | import * as Effect from "effect/Effect"; |
| ... | @@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => { |
| 3662 | ); | 3663 | ); |
| 3663 | }); | 3664 | }); |
| 3664 | | 3665 | |
| | 3666 | const observeUsageLimitEvents = (adapter: ClaudeAdapterShape, query: FakeClaudeQuery) => |
| | 3667 | Effect.gen(function* () { |
| | 3668 | const runtimeEvents: Array<ProviderRuntimeEvent> = []; |
| | 3669 | let receipt: Deferred.Deferred<void> | undefined; |
| | 3670 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => |
| | 3671 | Effect.gen(function* () { |
| | 3672 | runtimeEvents.push(event); |
| | 3673 | if ( |
| | 3674 | receipt && |
| | 3675 | event.type === "session.state.changed" && |
| | 3676 | event.payload.reason === "api_retry:1/1" |
| | 3677 | ) { |
| | 3678 | yield* Deferred.succeed(receipt, undefined); |
| | 3679 | } |
| | 3680 | }), |
| | 3681 | ).pipe(Effect.forkChild); |
| | 3682 | const drainSdkMessages = Effect.gen(function* () { |
| | 3683 | receipt = yield* Deferred.make<void>(); |
| | 3684 | // The heartbeat follows queued SDK messages without adding a warning. |
| | 3685 | query.emit({ |
| | 3686 | type: "system", |
| | 3687 | subtype: "api_retry", |
| | 3688 | attempt: 1, |
| | 3689 | max_retries: 1, |
| | 3690 | retry_delay_ms: 0, |
| | 3691 | error_status: 429, |
| | 3692 | error: { type: "rate_limit_error" }, |
| | 3693 | session_id: "sdk-session-limit", |
| | 3694 | uuid: "usage-limit-drain", |
| | 3695 | } as unknown as SDKMessage); |
| | 3696 | yield* Deferred.await(receipt); |
| | 3697 | }); |
| | 3698 | return { runtimeEvents, runtimeEventsFiber, drainSdkMessages }; |
| | 3699 | }); |
| | 3700 | |
| | 3701 | it.effect("surfaces a rejected Claude usage limit once per turn", () => { |
| | 3702 | const harness = makeHarness(); |
| | 3703 | return Effect.gen(function* () { |
| | 3704 | const adapter = yield* ClaudeAdapter; |
| | 3705 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 3706 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 3707 | |
| | 3708 | yield* adapter.startSession({ |
| | 3709 | threadId: THREAD_ID, |
| | 3710 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 3711 | runtimeMode: "full-access", |
| | 3712 | }); |
| | 3713 | |
| | 3714 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 3715 | |
| | 3716 | // resetsAt is epoch seconds, so the window reopens 4h 1m30s out. |
| | 3717 | const nowMs = yield* Clock.currentTimeMillis; |
| | 3718 | const rateLimitInfo = { |
| | 3719 | status: "rejected", |
| | 3720 | rateLimitType: "five_hour", |
| | 3721 | utilization: 1, |
| | 3722 | resetsAt: Math.floor(nowMs / 1000) + 4 * 60 * 60 + 90, |
| | 3723 | }; |
| | 3724 | const rejected = { |
| | 3725 | type: "rate_limit_event", |
| | 3726 | rate_limit_info: rateLimitInfo, |
| | 3727 | session_id: "sdk-session-limit", |
| | 3728 | uuid: "rate-limit-rejected", |
| | 3729 | }; |
| | 3730 | // Sibling fields drift while the window is parked, so the same rendered |
| | 3731 | // line can arrive more than once inside one turn. |
| | 3732 | harness.query.emit(rejected as unknown as SDKMessage); |
| | 3733 | yield* drainSdkMessages; |
| | 3734 | // The repeat lands minutes later, so the remaining wait has visibly |
| | 3735 | // shrunk. Deduping on the rendered row would let that drift through. |
| | 3736 | yield* TestClock.adjust("5 minutes"); |
| | 3737 | harness.query.emit(rejected as unknown as SDKMessage); |
| | 3738 | yield* drainSdkMessages; |
| | 3739 | |
| | 3740 | const usageLimitRows = () => |
| | 3741 | runtimeEvents |
| | 3742 | .filter((event) => event.type === "runtime.warning") |
| | 3743 | .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")); |
| | 3744 | assert.equal(usageLimitRows().length, 1); |
| | 3745 | // A wait, not a wall clock: the server renders this row but clients read |
| | 3746 | // it from other timezones. Reading resetsAt as milliseconds would put the |
| | 3747 | // window minutes out instead of hours, so the hour also pins the scale. |
| | 3748 | assert.match( |
| | 3749 | usageLimitRows()[0] ?? "", |
| | 3750 | /^Claude usage limit reached\. This turn is paused until the 5-hour limit resets in 4h( \d{1,2}m)?\.$/, |
| | 3751 | ); |
| | 3752 | // The exact instant still rides along for clients that want to render it. |
| | 3753 | assert.deepEqual( |
| | 3754 | runtimeEvents.find((event) => event.type === "runtime.warning")?.payload.detail, |
| | 3755 | rateLimitInfo, |
| | 3756 | ); |
| | 3757 | // The raw telemetry event still flows for every copy. |
| | 3758 | assert.equal( |
| | 3759 | runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, |
| | 3760 | 2, |
| | 3761 | ); |
| | 3762 | |
| | 3763 | // Same window, drifting siblings: still the one pause. |
| | 3764 | harness.query.emit({ |
| | 3765 | ...rejected, |
| | 3766 | rate_limit_info: { ...rateLimitInfo, utilization: 0.99 }, |
| | 3767 | uuid: "rate-limit-rejected-drift", |
| | 3768 | } as unknown as SDKMessage); |
| | 3769 | yield* drainSdkMessages; |
| | 3770 | assert.equal(usageLimitRows().length, 1); |
| | 3771 | |
| | 3772 | // Retrying inside the same window renders the identical line. Staying |
| | 3773 | // quiet there would put the new turn right back to a silent spin. |
| | 3774 | harness.query.emit({ |
| | 3775 | type: "result", |
| | 3776 | subtype: "success", |
| | 3777 | is_error: false, |
| | 3778 | errors: [], |
| | 3779 | session_id: "sdk-session-limit", |
| | 3780 | uuid: "result-limit", |
| | 3781 | } as unknown as SDKMessage); |
| | 3782 | yield* drainSdkMessages; |
| | 3783 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "retry", attachments: [] }); |
| | 3784 | harness.query.emit(rejected as unknown as SDKMessage); |
| | 3785 | yield* drainSdkMessages; |
| | 3786 | |
| | 3787 | assert.equal(usageLimitRows().length, 2); |
| | 3788 | |
| | 3789 | runtimeEventsFiber.interruptUnsafe(); |
| | 3790 | }).pipe( |
| | 3791 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 3792 | Effect.provide(harness.layer), |
| | 3793 | ); |
| | 3794 | }); |
| | 3795 | |
| | 3796 | it.effect("keeps allowed and malformed Claude rate-limit events out of the work log", () => { |
| | 3797 | const harness = makeHarness(); |
| | 3798 | return Effect.gen(function* () { |
| | 3799 | const adapter = yield* ClaudeAdapter; |
| | 3800 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 3801 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 3802 | |
| | 3803 | yield* adapter.startSession({ |
| | 3804 | threadId: THREAD_ID, |
| | 3805 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 3806 | runtimeMode: "full-access", |
| | 3807 | }); |
| | 3808 | // A turn is in flight, so silence here is the status filter doing its job |
| | 3809 | // rather than the between-turns guard. |
| | 3810 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 3811 | |
| | 3812 | for (const rateLimitInfo of [ |
| | 3813 | { status: "allowed", rateLimitType: "five_hour", utilization: 0.4 }, |
| | 3814 | { status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.9 }, |
| | 3815 | // Undeclared shape from an older/newer CLI must not take the session down. |
| | 3816 | undefined, |
| | 3817 | ]) { |
| | 3818 | harness.query.emit({ |
| | 3819 | type: "rate_limit_event", |
| | 3820 | ...(rateLimitInfo ? { rate_limit_info: rateLimitInfo } : {}), |
| | 3821 | session_id: "sdk-session-limit-ok", |
| | 3822 | uuid: `rate-limit-${rateLimitInfo?.status ?? "malformed"}`, |
| | 3823 | } as unknown as SDKMessage); |
| | 3824 | } |
| | 3825 | yield* drainSdkMessages; |
| | 3826 | |
| | 3827 | assert.deepEqual( |
| | 3828 | runtimeEvents.filter((event) => event.type === "runtime.warning"), |
| | 3829 | [], |
| | 3830 | ); |
| | 3831 | assert.equal( |
| | 3832 | runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, |
| | 3833 | 2, |
| | 3834 | ); |
| | 3835 | |
| | 3836 | runtimeEventsFiber.interruptUnsafe(); |
| | 3837 | }).pipe( |
| | 3838 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 3839 | Effect.provide(harness.layer), |
| | 3840 | ); |
| | 3841 | }); |
| | 3842 | |
| | 3843 | it.effect("stays quiet when no turn is parked by the Claude limit", () => { |
| | 3844 | const harness = makeHarness(); |
| | 3845 | return Effect.gen(function* () { |
| | 3846 | const adapter = yield* ClaudeAdapter; |
| | 3847 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 3848 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 3849 | |
| | 3850 | yield* adapter.startSession({ |
| | 3851 | threadId: THREAD_ID, |
| | 3852 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 3853 | runtimeMode: "full-access", |
| | 3854 | }); |
| | 3855 | |
| | 3856 | const nowMs = yield* Clock.currentTimeMillis; |
| | 3857 | const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; |
| | 3858 | // The stream stays live between turns, so a reject can land with nothing |
| | 3859 | // to pause; claiming "this turn is paused" there would be a lie. |
| | 3860 | harness.query.emit({ |
| | 3861 | type: "rate_limit_event", |
| | 3862 | rate_limit_info: { |
| | 3863 | status: "rejected", |
| | 3864 | rateLimitType: "five_hour", |
| | 3865 | utilization: 1, |
| | 3866 | resetsAt, |
| | 3867 | }, |
| | 3868 | session_id: "sdk-session-idle", |
| | 3869 | uuid: "rate-limit-idle", |
| | 3870 | } as unknown as SDKMessage); |
| | 3871 | yield* drainSdkMessages; |
| | 3872 | |
| | 3873 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 3874 | // Provisioned overage carries the request even though the base window |
| | 3875 | // rejected it, so the turn keeps running and needs no row. |
| | 3876 | for (const overage of [ |
| | 3877 | { overageStatus: "allowed" }, |
| | 3878 | { overageStatus: "allowed_warning" }, |
| | 3879 | { isUsingOverage: true }, |
| | 3880 | { overageInUse: true }, |
| | 3881 | ]) { |
| | 3882 | harness.query.emit({ |
| | 3883 | type: "rate_limit_event", |
| | 3884 | rate_limit_info: { |
| | 3885 | status: "rejected", |
| | 3886 | rateLimitType: "five_hour", |
| | 3887 | resetsAt, |
| | 3888 | utilization: 1, |
| | 3889 | ...overage, |
| | 3890 | }, |
| | 3891 | session_id: "sdk-session-idle", |
| | 3892 | uuid: "rate-limit-overage", |
| | 3893 | } as unknown as SDKMessage); |
| | 3894 | } |
| | 3895 | yield* drainSdkMessages; |
| | 3896 | |
| | 3897 | assert.deepEqual( |
| | 3898 | runtimeEvents.filter((event) => event.type === "runtime.warning"), |
| | 3899 | [], |
| | 3900 | ); |
| | 3901 | // Idle and overage-covered events still reach the account telemetry stream. |
| | 3902 | assert.equal( |
| | 3903 | runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, |
| | 3904 | 5, |
| | 3905 | ); |
| | 3906 | |
| | 3907 | runtimeEventsFiber.interruptUnsafe(); |
| | 3908 | }).pipe( |
| | 3909 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 3910 | Effect.provide(harness.layer), |
| | 3911 | ); |
| | 3912 | }); |
| | 3913 | |
| | 3914 | it.effect("still surfaces the pause when overage is exhausted too", () => { |
| | 3915 | const harness = makeHarness(); |
| | 3916 | return Effect.gen(function* () { |
| | 3917 | const adapter = yield* ClaudeAdapter; |
| | 3918 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 3919 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 3920 | |
| | 3921 | yield* adapter.startSession({ |
| | 3922 | threadId: THREAD_ID, |
| | 3923 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 3924 | runtimeMode: "full-access", |
| | 3925 | }); |
| | 3926 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 3927 | |
| | 3928 | const nowMs = yield* Clock.currentTimeMillis; |
| | 3929 | const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; |
| | 3930 | // The overage-exhausted / out-of-credits shape: the base window and the |
| | 3931 | // overage it would have spent both reject, with neither isUsingOverage |
| | 3932 | // nor overageInUse set to say anything is still covered. Nothing is |
| | 3933 | // carrying the turn here, so staying quiet would be the silent spin |
| | 3934 | // this row exists to prevent. |
| | 3935 | harness.query.emit({ |
| | 3936 | type: "rate_limit_event", |
| | 3937 | rate_limit_info: { |
| | 3938 | status: "rejected", |
| | 3939 | rateLimitType: "five_hour", |
| | 3940 | resetsAt, |
| | 3941 | overageStatus: "rejected", |
| | 3942 | }, |
| | 3943 | session_id: "sdk-session-dual-reject", |
| | 3944 | uuid: "rate-limit-dual-reject", |
| | 3945 | } as unknown as SDKMessage); |
| | 3946 | yield* drainSdkMessages; |
| | 3947 | |
| | 3948 | assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 1); |
| | 3949 | |
| | 3950 | runtimeEventsFiber.interruptUnsafe(); |
| | 3951 | }).pipe( |
| | 3952 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 3953 | Effect.provide(harness.layer), |
| | 3954 | ); |
| | 3955 | }); |
| | 3956 | |
| | 3957 | it.effect("keeps one row per window when two Claude limits interleave", () => { |
| | 3958 | const harness = makeHarness(); |
| | 3959 | return Effect.gen(function* () { |
| | 3960 | const adapter = yield* ClaudeAdapter; |
| | 3961 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 3962 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 3963 | |
| | 3964 | yield* adapter.startSession({ |
| | 3965 | threadId: THREAD_ID, |
| | 3966 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 3967 | runtimeMode: "full-access", |
| | 3968 | }); |
| | 3969 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 3970 | |
| | 3971 | const nowMs = yield* Clock.currentTimeMillis; |
| | 3972 | const nowSeconds = Math.floor(nowMs / 1000); |
| | 3973 | const rejection = (rateLimitType: string, resetsAt: number, uuid: string) => ({ |
| | 3974 | type: "rate_limit_event", |
| | 3975 | rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, |
| | 3976 | session_id: "sdk-session-interleaved", |
| | 3977 | uuid, |
| | 3978 | }); |
| | 3979 | |
| | 3980 | // One turn can park on more than one window; each deserves its own row, |
| | 3981 | // and a later repeat of an earlier window deserves none. |
| | 3982 | for (const message of [ |
| | 3983 | rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour"), |
| | 3984 | rejection("seven_day", nowSeconds + 48 * 60 * 60, "limit-seven-day"), |
| | 3985 | rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour-repeat"), |
| | 3986 | ]) { |
| | 3987 | harness.query.emit(message as unknown as SDKMessage); |
| | 3988 | yield* drainSdkMessages; |
| | 3989 | } |
| | 3990 | |
| | 3991 | assert.deepEqual( |
| | 3992 | runtimeEvents |
| | 3993 | .filter((event) => event.type === "runtime.warning") |
| | 3994 | .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")) |
| | 3995 | .map((message) => message.replace(/ in \d+h( \d{1,2}m)?/, "")), |
| | 3996 | [ |
| | 3997 | "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", |
| | 3998 | "Claude usage limit reached. This turn is paused until the 7-day limit resets.", |
| | 3999 | ], |
| | 4000 | ); |
| | 4001 | |
| | 4002 | runtimeEventsFiber.interruptUnsafe(); |
| | 4003 | }).pipe( |
| | 4004 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 4005 | Effect.provide(harness.layer), |
| | 4006 | ); |
| | 4007 | }); |
| | 4008 | |
| | 4009 | it.effect("re-announces a Claude limit for a synthetic turn", () => { |
| | 4010 | const harness = makeHarness(); |
| | 4011 | return Effect.gen(function* () { |
| | 4012 | const adapter = yield* ClaudeAdapter; |
| | 4013 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 4014 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 4015 | |
| | 4016 | yield* adapter.startSession({ |
| | 4017 | threadId: THREAD_ID, |
| | 4018 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 4019 | runtimeMode: "full-access", |
| | 4020 | }); |
| | 4021 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 4022 | |
| | 4023 | const nowMs = yield* Clock.currentTimeMillis; |
| | 4024 | const rejected = { |
| | 4025 | type: "rate_limit_event", |
| | 4026 | rate_limit_info: { |
| | 4027 | status: "rejected", |
| | 4028 | rateLimitType: "five_hour", |
| | 4029 | resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60, |
| | 4030 | }, |
| | 4031 | session_id: "sdk-session-synthetic", |
| | 4032 | uuid: "rate-limit-synthetic", |
| | 4033 | }; |
| | 4034 | harness.query.emit(rejected as unknown as SDKMessage); |
| | 4035 | yield* drainSdkMessages; |
| | 4036 | |
| | 4037 | harness.query.emit({ |
| | 4038 | type: "result", |
| | 4039 | subtype: "success", |
| | 4040 | is_error: false, |
| | 4041 | errors: [], |
| | 4042 | session_id: "sdk-session-synthetic", |
| | 4043 | uuid: "result-synthetic", |
| | 4044 | } as unknown as SDKMessage); |
| | 4045 | yield* drainSdkMessages; |
| | 4046 | |
| | 4047 | // A background agent answering between prompts auto-starts a synthetic |
| | 4048 | // turn, which parks on the same window and needs its own row. |
| | 4049 | harness.query.emit({ |
| | 4050 | type: "assistant", |
| | 4051 | session_id: "sdk-session-synthetic", |
| | 4052 | uuid: "assistant-synthetic", |
| | 4053 | parent_tool_use_id: null, |
| | 4054 | message: { |
| | 4055 | id: "assistant-message-synthetic", |
| | 4056 | content: [{ type: "text", text: "Following up" }], |
| | 4057 | }, |
| | 4058 | } as unknown as SDKMessage); |
| | 4059 | yield* drainSdkMessages; |
| | 4060 | harness.query.emit({ ...rejected, uuid: "rate-limit-synthetic-2" } as unknown as SDKMessage); |
| | 4061 | yield* drainSdkMessages; |
| | 4062 | |
| | 4063 | assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 2); |
| | 4064 | |
| | 4065 | runtimeEventsFiber.interruptUnsafe(); |
| | 4066 | }).pipe( |
| | 4067 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 4068 | Effect.provide(harness.layer), |
| | 4069 | ); |
| | 4070 | }); |
| | 4071 | |
| | 4072 | it.effect("drops an unusable Claude reset time, not the row or the session", () => { |
| | 4073 | const harness = makeHarness(); |
| | 4074 | return Effect.gen(function* () { |
| | 4075 | const adapter = yield* ClaudeAdapter; |
| | 4076 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 4077 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 4078 | |
| | 4079 | yield* adapter.startSession({ |
| | 4080 | threadId: THREAD_ID, |
| | 4081 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 4082 | runtimeMode: "full-access", |
| | 4083 | }); |
| | 4084 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 4085 | |
| | 4086 | for (const [rateLimitType, resetsAt] of [ |
| | 4087 | ["five_hour", undefined], |
| | 4088 | // Implausibly far out once scaled to milliseconds: no credible wait. |
| | 4089 | ["seven_day", 1e20], |
| | 4090 | ] as const) { |
| | 4091 | harness.query.emit({ |
| | 4092 | type: "rate_limit_event", |
| | 4093 | rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, |
| | 4094 | session_id: "sdk-session-limit-unusable", |
| | 4095 | uuid: `rate-limit-${rateLimitType}`, |
| | 4096 | } as unknown as SDKMessage); |
| | 4097 | } |
| | 4098 | yield* drainSdkMessages; |
| | 4099 | |
| | 4100 | assert.deepEqual( |
| | 4101 | runtimeEvents |
| | 4102 | .filter((event) => event.type === "runtime.warning") |
| | 4103 | .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")), |
| | 4104 | [ |
| | 4105 | "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", |
| | 4106 | "Claude usage limit reached. This turn is paused until the 7-day limit resets.", |
| | 4107 | ], |
| | 4108 | ); |
| | 4109 | // A throw inside the telemetry handler would tear the session down. |
| | 4110 | assert.deepEqual( |
| | 4111 | runtimeEvents |
| | 4112 | .filter((event) => event.type === "session.exited" || event.type === "runtime.error") |
| | 4113 | .map((event) => event.type), |
| | 4114 | [], |
| | 4115 | ); |
| | 4116 | // Still live enough to take the next turn. |
| | 4117 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "still here", attachments: [] }); |
| | 4118 | |
| | 4119 | runtimeEventsFiber.interruptUnsafe(); |
| | 4120 | }).pipe( |
| | 4121 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 4122 | Effect.provide(harness.layer), |
| | 4123 | ); |
| | 4124 | }); |
| | 4125 | |
| | 4126 | it.effect("warns for unmapped Claude limits and names the probed model bucket", () => { |
| | 4127 | const scopedLimitNames = Ref.makeUnsafe<ClaudeScopedLimitNames>({ overageIncluded: undefined }); |
| | 4128 | const harness = makeHarness({ scopedLimitNames }); |
| | 4129 | return Effect.gen(function* () { |
| | 4130 | const adapter = yield* ClaudeAdapter; |
| | 4131 | const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = |
| | 4132 | yield* observeUsageLimitEvents(adapter, harness.query); |
| | 4133 | yield* adapter.startSession({ |
| | 4134 | threadId: THREAD_ID, |
| | 4135 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 4136 | runtimeMode: "full-access", |
| | 4137 | }); |
| | 4138 | yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); |
| | 4139 | |
| | 4140 | for (const rateLimitType of ["seven_day_overage_included", "future_window"]) { |
| | 4141 | harness.query.emit({ |
| | 4142 | type: "rate_limit_event", |
| | 4143 | rate_limit_info: { status: "rejected", rateLimitType }, |
| | 4144 | session_id: "sdk-session-unmapped-limit", |
| | 4145 | uuid: `rejected-${rateLimitType}`, |
| | 4146 | } as unknown as SDKMessage); |
| | 4147 | } |
| | 4148 | yield* drainSdkMessages; |
| | 4149 | assert.deepEqual( |
| | 4150 | runtimeEvents.filter((event) => event.type === "account.rate-limits.updated"), |
| | 4151 | [], |
| | 4152 | ); |
| | 4153 | |
| | 4154 | yield* Ref.set(scopedLimitNames, { overageIncluded: "Model A" }); |
| | 4155 | const nowMs = yield* Clock.currentTimeMillis; |
| | 4156 | harness.query.emit({ |
| | 4157 | type: "rate_limit_event", |
| | 4158 | rate_limit_info: { |
| | 4159 | status: "rejected", |
| | 4160 | rateLimitType: "seven_day_overage_included", |
| | 4161 | utilization: 1, |
| | 4162 | resetsAt: Math.floor(nowMs / 1000) + 3600, |
| | 4163 | }, |
| | 4164 | session_id: "sdk-session-unmapped-limit", |
| | 4165 | uuid: "rejected-probed-bucket", |
| | 4166 | } as unknown as SDKMessage); |
| | 4167 | yield* drainSdkMessages; |
| | 4168 | assert.deepEqual( |
| | 4169 | runtimeEvents |
| | 4170 | .filter((event) => event.type === "runtime.warning") |
| | 4171 | .map((event) => event.payload.message), |
| | 4172 | [ |
| | 4173 | "Claude usage limit reached. This turn is paused until the 7-day model limit resets.", |
| | 4174 | "Claude usage limit reached. This turn is paused until the limit resets.", |
| | 4175 | "Claude usage limit reached. This turn is paused until the 7-day Model A limit resets in 1h.", |
| | 4176 | ], |
| | 4177 | ); |
| | 4178 | assert.equal( |
| | 4179 | runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, |
| | 4180 | 1, |
| | 4181 | ); |
| | 4182 | runtimeEventsFiber.interruptUnsafe(); |
| | 4183 | }).pipe( |
| | 4184 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| | 4185 | Effect.provide(harness.layer), |
| | 4186 | ); |
| | 4187 | }); |
| | 4188 | |
| 3665 | it.effect("consumes Claude command lifecycle notifications silently", () => { | 4189 | it.effect("consumes Claude command lifecycle notifications silently", () => { |
| 3666 | const harness = makeHarness(); | 4190 | const harness = makeHarness(); |
| 3667 | return Effect.gen(function* () { | 4191 | return Effect.gen(function* () { |