authorgravatar for vitalyiegorov@gmail.comVitaly Iegorov <vitalyiegorov@gmail.com> 2026-09-05 03:42:17+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 18:42:17-07:00
log940e8233c227a186044078e99e45e1933eb525e4
treeb5f549a456c8739fc81dde6027e24a4597ce9fdb
parent89ee69e4430b21ee14565abf5c34dae43f38c1d8
signature Signed by PGP key B5690EEEBB952194

fix(claude): surface usage-limit pauses in the thread (#7165)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

3 files changed, 628 insertions(+), 7 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+524
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import { createModelSelection } from "@t3tools/shared/model";24import { createModelSelection } from "@t3tools/shared/model";
25import { assert, describe, it } from "@effect/vitest";25import { assert, describe, it } from "@effect/vitest";
26import * as Clock from "effect/Clock";
26import * as Context from "effect/Context";27import * as Context from "effect/Context";
27import * as Deferred from "effect/Deferred";28import * as Deferred from "effect/Deferred";
28import * as Effect from "effect/Effect";29import * as Effect from "effect/Effect";
...@@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => {...@@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => {
3662 );3663 );
3663 });3664 });
36643665
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* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+96-7
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 type PermissionResult,14 type PermissionResult,
15 type PermissionUpdate,15 type PermissionUpdate,
16 type SDKMessage,16 type SDKMessage,
17 type SDKRateLimitInfo,
17 type SDKResultMessage,18 type SDKResultMessage,
18 type SettingSource,19 type SettingSource,
19 type SDKUserMessage,20 type SDKUserMessage,
...@@ -327,6 +328,8 @@ interface ClaudeSessionContext {...@@ -327,6 +328,8 @@ interface ClaudeSessionContext {
327 lastKnownTotalProcessedTokens: number | undefined;328 lastKnownTotalProcessedTokens: number | undefined;
328 lastAssistantUuid: string | undefined;329 lastAssistantUuid: string | undefined;
329 lastThreadStartedId: string | undefined;330 lastThreadStartedId: string | undefined;
331 /** Limits already announced for the running turn, keyed `window:resetsAt`. */
332 announcedUsageLimits: { turnId: string; keys: Set<string> } | undefined;
330 stopped: boolean;333 stopped: boolean;
331}334}
332335
...@@ -503,6 +506,55 @@ function isInterruptedResult(result: SDKResultMessage): boolean {...@@ -503,6 +506,55 @@ function isInterruptedResult(result: SDKResultMessage): boolean {
503 );506 );
504}507}
505508
509const CLAUDE_USAGE_LIMIT_WINDOWS = {
510 five_hour: "5-hour",
511 seven_day: "7-day",
512 seven_day_opus: "7-day Opus",
513 seven_day_sonnet: "7-day Sonnet",
514 seven_day_overage_included: "7-day model",
515 overage: "overage",
516} satisfies Record<NonNullable<SDKRateLimitInfo["rateLimitType"]>, string>;
517
518/** Beyond this the reset time is not credible, so the row ships without a wait. */
519const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000;
520
521/**
522 * `resetsAt` is epoch seconds. The row states the remaining wait rather than a
523 * wall-clock time: this renders on the server, while the row is read on clients
524 * that may sit in another timezone and locale, and that carry their own
525 * timestamp preference. A wait reads the same everywhere.
526 */
527function describeClaudeUsageLimit(
528 info: SDKRateLimitInfo,
529 nowMs: number,
530 names: ClaudeScopedLimitNames,
531): string {
532 const label =
533 info.rateLimitType === "seven_day_overage_included" && names.overageIncluded
534 ? `7-day ${names.overageIncluded}`
535 : info.rateLimitType
536 ? CLAUDE_USAGE_LIMIT_WINDOWS[info.rateLimitType]
537 : undefined;
538 const resetsAtMs = info.resetsAt === undefined ? undefined : info.resetsAt * 1000;
539 const waitMs =
540 resetsAtMs === undefined || !Number.isFinite(nowMs) ? undefined : resetsAtMs - nowMs;
541 const wait =
542 waitMs !== undefined && waitMs > 0 && waitMs <= CLAUDE_USAGE_LIMIT_MAX_WAIT_MS
543 ? formatClaudeUsageLimitWait(waitMs)
544 : undefined;
545 return `Claude usage limit reached. This turn is paused until the ${
546 label ? `${label} ` : ""
547 }limit resets${wait ? ` in ${wait}` : ""}.`;
548}
549
550function formatClaudeUsageLimitWait(waitMs: number): string {
551 const totalMinutes = Math.ceil(waitMs / 60_000);
552 const hours = Math.floor(totalMinutes / 60);
553 const minutes = totalMinutes % 60;
554 if (hours === 0) return `${totalMinutes}m`;
555 return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
556}
557
506function asRuntimeItemId(value: string): RuntimeItemId {558function asRuntimeItemId(value: string): RuntimeItemId {
507 return RuntimeItemId.make(value);559 return RuntimeItemId.make(value);
508}560}
...@@ -3802,16 +3854,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3802,16 +3854,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3802 }3854 }
38033855
3804 if (message.type === "rate_limit_event") {3856 if (message.type === "rate_limit_event") {
3857 const rateLimitInfo = message.rate_limit_info;
3858 if (!rateLimitInfo) return;
3805 const names = options?.scopedLimitNames3859 const names = options?.scopedLimitNames
3806 ? yield* Ref.get(options.scopedLimitNames)3860 ? yield* Ref.get(options.scopedLimitNames)
3807 : { overageIncluded: undefined };3861 : { overageIncluded: undefined };
3808 const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names);3862 const limits = claudeRateLimitEventToUpdate(rateLimitInfo, names);
3809 if (!limits) return;3863 if (limits) {
3810 yield* offerRuntimeEvent({3864 yield* offerRuntimeEvent({
3811 ...base,3865 ...base,
3812 type: "account.rate-limits.updated",3866 type: "account.rate-limits.updated",
3813 payload: { limits },3867 payload: { limits },
3814 });3868 });
3869 }
3870 // A rejected window parks the turn inside the SDK: no further messages
3871 // arrive and no result lands, so without a row the thread just spins.
3872 // Warnings (allowed_warning) still have headroom and stay quiet, an
3873 // account spending provisioned overage keeps running despite the reject,
3874 // and between turns there is no turn to report as paused.
3875 if (
3876 rateLimitInfo.status === "rejected" &&
3877 rateLimitInfo.overageStatus !== "allowed" &&
3878 rateLimitInfo.overageStatus !== "allowed_warning" &&
3879 rateLimitInfo.isUsingOverage !== true &&
3880 rateLimitInfo.overageInUse !== true &&
3881 context.turnState !== undefined
3882 ) {
3883 // Tracked per turn as a set of limit identities, not as the rendered
3884 // row: a parked window re-fires while the remaining wait shrinks, and a
3885 // turn can park on more than one window, so a single slot would let an
3886 // interleaved repeat through. A new turn — including a synthetic one —
3887 // starts a fresh set and announces its pause again.
3888 const turnId = context.turnState.turnId;
3889 if (context.announcedUsageLimits?.turnId !== turnId) {
3890 context.announcedUsageLimits = { turnId, keys: new Set() };
3891 }
3892 const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`;
3893 if (!context.announcedUsageLimits.keys.has(limitKey)) {
3894 context.announcedUsageLimits.keys.add(limitKey);
3895 const notice = describeClaudeUsageLimit(
3896 rateLimitInfo,
3897 Date.parse(stamp.createdAt),
3898 names,
3899 );
3900 yield* emitRuntimeWarning(context, notice, rateLimitInfo);
3901 }
3902 }
3815 return;3903 return;
3816 }3904 }
3817 });3905 });
...@@ -4695,6 +4783,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4695,6 +4783,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4695 lastKnownTotalProcessedTokens: undefined,4783 lastKnownTotalProcessedTokens: undefined,
4696 lastAssistantUuid: resumeState?.resumeSessionAt,4784 lastAssistantUuid: resumeState?.resumeSessionAt,
4697 lastThreadStartedId: undefined,4785 lastThreadStartedId: undefined,
4786 announcedUsageLimits: undefined,
4698 stopped: false,4787 stopped: false,
4699 };4788 };
4700 yield* Ref.set(contextRef, context);4789 yield* Ref.set(contextRef, context);
docs/user/providers-claude.md+8
...@@ -49,6 +49,14 @@ You can also send `/compact` in an existing conversation. Web and desktop offer...@@ -49,6 +49,14 @@ You can also send `/compact` in an existing conversation. Web and desktop offer
49a large older thread. See [commands and skills](./composer.md#commands-and-skills)49a large older thread. See [commands and skills](./composer.md#commands-and-skills)
50for using composer commands.50for using composer commands.
5151
52## Usage limits
53
54If your Claude subscription runs out of usage mid-turn, the thread shows which
55limit was reached and the remaining wait when Claude provides a reset time.
56Claude Code holds the turn until that window reopens, so it can keep showing as
57working. Wait for the reset, or stop the turn and continue later. The warning's
58timestamp shows when the displayed wait started.
59
52## Skills60## Skills
5361
54Claude skills come from the config directory's `skills` folder and the project's62Claude skills come from the config directory's `skills` folder and the project's