| ... | ... | @@ -23,6 +23,7 @@ import { |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | import { createModelSelection } from "@t3tools/shared/model"; |
| 25 | 25 | import { assert, describe, it } from "@effect/vitest"; |
| 26 | import * as Clock from "effect/Clock"; |
| 26 | 27 | import * as Context from "effect/Context"; |
| 27 | 28 | import * as Deferred from "effect/Deferred"; |
| 28 | 29 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -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 | 4189 | it.effect("consumes Claude command lifecycle notifications silently", () => { |
| 3666 | 4190 | const harness = makeHarness(); |
| 3667 | 4191 | return Effect.gen(function* () { |