diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index afea9a605084..84cc03b75a6d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => { ); }); + const observeUsageLimitEvents = (adapter: ClaudeAdapterShape, query: FakeClaudeQuery) => + Effect.gen(function* () { + const runtimeEvents: Array = []; + let receipt: Deferred.Deferred | undefined; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if ( + receipt && + event.type === "session.state.changed" && + event.payload.reason === "api_retry:1/1" + ) { + yield* Deferred.succeed(receipt, undefined); + } + }), + ).pipe(Effect.forkChild); + const drainSdkMessages = Effect.gen(function* () { + receipt = yield* Deferred.make(); + // The heartbeat follows queued SDK messages without adding a warning. + query.emit({ + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 1, + retry_delay_ms: 0, + error_status: 429, + error: { type: "rate_limit_error" }, + session_id: "sdk-session-limit", + uuid: "usage-limit-drain", + } as unknown as SDKMessage); + yield* Deferred.await(receipt); + }); + return { runtimeEvents, runtimeEventsFiber, drainSdkMessages }; + }); + + it.effect("surfaces a rejected Claude usage limit once per turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + // resetsAt is epoch seconds, so the window reopens 4h 1m30s out. + const nowMs = yield* Clock.currentTimeMillis; + const rateLimitInfo = { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 4 * 60 * 60 + 90, + }; + const rejected = { + type: "rate_limit_event", + rate_limit_info: rateLimitInfo, + session_id: "sdk-session-limit", + uuid: "rate-limit-rejected", + }; + // Sibling fields drift while the window is parked, so the same rendered + // line can arrive more than once inside one turn. + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + // The repeat lands minutes later, so the remaining wait has visibly + // shrunk. Deduping on the rendered row would let that drift through. + yield* TestClock.adjust("5 minutes"); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + const usageLimitRows = () => + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")); + assert.equal(usageLimitRows().length, 1); + // A wait, not a wall clock: the server renders this row but clients read + // it from other timezones. Reading resetsAt as milliseconds would put the + // window minutes out instead of hours, so the hour also pins the scale. + assert.match( + usageLimitRows()[0] ?? "", + /^Claude usage limit reached\. This turn is paused until the 5-hour limit resets in 4h( \d{1,2}m)?\.$/, + ); + // The exact instant still rides along for clients that want to render it. + assert.deepEqual( + runtimeEvents.find((event) => event.type === "runtime.warning")?.payload.detail, + rateLimitInfo, + ); + // The raw telemetry event still flows for every copy. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + // Same window, drifting siblings: still the one pause. + harness.query.emit({ + ...rejected, + rate_limit_info: { ...rateLimitInfo, utilization: 0.99 }, + uuid: "rate-limit-rejected-drift", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.equal(usageLimitRows().length, 1); + + // Retrying inside the same window renders the identical line. Staying + // quiet there would put the new turn right back to a silent spin. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-limit", + uuid: "result-limit", + } as unknown as SDKMessage); + yield* drainSdkMessages; + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "retry", attachments: [] }); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(usageLimitRows().length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps allowed and malformed Claude rate-limit events out of the work log", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + // A turn is in flight, so silence here is the status filter doing its job + // rather than the between-turns guard. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitInfo of [ + { status: "allowed", rateLimitType: "five_hour", utilization: 0.4 }, + { status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.9 }, + // Undeclared shape from an older/newer CLI must not take the session down. + undefined, + ]) { + harness.query.emit({ + type: "rate_limit_event", + ...(rateLimitInfo ? { rate_limit_info: rateLimitInfo } : {}), + session_id: "sdk-session-limit-ok", + uuid: `rate-limit-${rateLimitInfo?.status ?? "malformed"}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stays quiet when no turn is parked by the Claude limit", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The stream stays live between turns, so a reject can land with nothing + // to pause; claiming "this turn is paused" there would be a lie. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-idle", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + // Provisioned overage carries the request even though the base window + // rejected it, so the turn keeps running and needs no row. + for (const overage of [ + { overageStatus: "allowed" }, + { overageStatus: "allowed_warning" }, + { isUsingOverage: true }, + { overageInUse: true }, + ]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + utilization: 1, + ...overage, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-overage", + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + // Idle and overage-covered events still reach the account telemetry stream. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 5, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("still surfaces the pause when overage is exhausted too", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The overage-exhausted / out-of-credits shape: the base window and the + // overage it would have spent both reject, with neither isUsingOverage + // nor overageInUse set to say anything is still covered. Nothing is + // carrying the turn here, so staying quiet would be the silent spin + // this row exists to prevent. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + overageStatus: "rejected", + }, + session_id: "sdk-session-dual-reject", + uuid: "rate-limit-dual-reject", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 1); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps one row per window when two Claude limits interleave", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const nowSeconds = Math.floor(nowMs / 1000); + const rejection = (rateLimitType: string, resetsAt: number, uuid: string) => ({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-interleaved", + uuid, + }); + + // One turn can park on more than one window; each deserves its own row, + // and a later repeat of an earlier window deserves none. + for (const message of [ + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour"), + rejection("seven_day", nowSeconds + 48 * 60 * 60, "limit-seven-day"), + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour-repeat"), + ]) { + harness.query.emit(message as unknown as SDKMessage); + yield* drainSdkMessages; + } + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")) + .map((message) => message.replace(/ in \d+h( \d{1,2}m)?/, "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("re-announces a Claude limit for a synthetic turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const rejected = { + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60, + }, + session_id: "sdk-session-synthetic", + uuid: "rate-limit-synthetic", + }; + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-synthetic", + uuid: "result-synthetic", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + // A background agent answering between prompts auto-starts a synthetic + // turn, which parks on the same window and needs its own row. + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-synthetic", + uuid: "assistant-synthetic", + parent_tool_use_id: null, + message: { + id: "assistant-message-synthetic", + content: [{ type: "text", text: "Following up" }], + }, + } as unknown as SDKMessage); + yield* drainSdkMessages; + harness.query.emit({ ...rejected, uuid: "rate-limit-synthetic-2" } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("drops an unusable Claude reset time, not the row or the session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const [rateLimitType, resetsAt] of [ + ["five_hour", undefined], + // Implausibly far out once scaled to milliseconds: no credible wait. + ["seven_day", 1e20], + ] as const) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-limit-unusable", + uuid: `rate-limit-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + // A throw inside the telemetry handler would tear the session down. + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "session.exited" || event.type === "runtime.error") + .map((event) => event.type), + [], + ); + // Still live enough to take the next turn. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "still here", attachments: [] }); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("warns for unmapped Claude limits and names the probed model bucket", () => { + const scopedLimitNames = Ref.makeUnsafe({ overageIncluded: undefined }); + const harness = makeHarness({ scopedLimitNames }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitType of ["seven_day_overage_included", "future_window"]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType }, + session_id: "sdk-session-unmapped-limit", + uuid: `rejected-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated"), + [], + ); + + yield* Ref.set(scopedLimitNames, { overageIncluded: "Model A" }); + const nowMs = yield* Clock.currentTimeMillis; + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "seven_day_overage_included", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 3600, + }, + session_id: "sdk-session-unmapped-limit", + uuid: "rejected-probed-bucket", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => event.payload.message), + [ + "Claude usage limit reached. This turn is paused until the 7-day model limit resets.", + "Claude usage limit reached. This turn is paused until the limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day Model A limit resets in 1h.", + ], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 1, + ); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("consumes Claude command lifecycle notifications silently", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d295821d8dfa..a005f583066f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -14,6 +14,7 @@ import { type PermissionResult, type PermissionUpdate, type SDKMessage, + type SDKRateLimitInfo, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -327,6 +328,8 @@ interface ClaudeSessionContext { lastKnownTotalProcessedTokens: number | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + /** Limits already announced for the running turn, keyed `window:resetsAt`. */ + announcedUsageLimits: { turnId: string; keys: Set } | undefined; stopped: boolean; } @@ -503,6 +506,55 @@ function isInterruptedResult(result: SDKResultMessage): boolean { ); } +const CLAUDE_USAGE_LIMIT_WINDOWS = { + five_hour: "5-hour", + seven_day: "7-day", + seven_day_opus: "7-day Opus", + seven_day_sonnet: "7-day Sonnet", + seven_day_overage_included: "7-day model", + overage: "overage", +} satisfies Record, string>; + +/** Beyond this the reset time is not credible, so the row ships without a wait. */ +const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * `resetsAt` is epoch seconds. The row states the remaining wait rather than a + * wall-clock time: this renders on the server, while the row is read on clients + * that may sit in another timezone and locale, and that carry their own + * timestamp preference. A wait reads the same everywhere. + */ +function describeClaudeUsageLimit( + info: SDKRateLimitInfo, + nowMs: number, + names: ClaudeScopedLimitNames, +): string { + const label = + info.rateLimitType === "seven_day_overage_included" && names.overageIncluded + ? `7-day ${names.overageIncluded}` + : info.rateLimitType + ? CLAUDE_USAGE_LIMIT_WINDOWS[info.rateLimitType] + : undefined; + const resetsAtMs = info.resetsAt === undefined ? undefined : info.resetsAt * 1000; + const waitMs = + resetsAtMs === undefined || !Number.isFinite(nowMs) ? undefined : resetsAtMs - nowMs; + const wait = + waitMs !== undefined && waitMs > 0 && waitMs <= CLAUDE_USAGE_LIMIT_MAX_WAIT_MS + ? formatClaudeUsageLimitWait(waitMs) + : undefined; + return `Claude usage limit reached. This turn is paused until the ${ + label ? `${label} ` : "" + }limit resets${wait ? ` in ${wait}` : ""}.`; +} + +function formatClaudeUsageLimitWait(waitMs: number): string { + const totalMinutes = Math.ceil(waitMs / 60_000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${totalMinutes}m`; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.make(value); } @@ -3802,16 +3854,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (message.type === "rate_limit_event") { + const rateLimitInfo = message.rate_limit_info; + if (!rateLimitInfo) return; const names = options?.scopedLimitNames ? yield* Ref.get(options.scopedLimitNames) : { overageIncluded: undefined }; - const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names); - if (!limits) return; - yield* offerRuntimeEvent({ - ...base, - type: "account.rate-limits.updated", - payload: { limits }, - }); + const limits = claudeRateLimitEventToUpdate(rateLimitInfo, names); + if (limits) { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { limits }, + }); + } + // A rejected window parks the turn inside the SDK: no further messages + // arrive and no result lands, so without a row the thread just spins. + // Warnings (allowed_warning) still have headroom and stay quiet, an + // account spending provisioned overage keeps running despite the reject, + // and between turns there is no turn to report as paused. + if ( + rateLimitInfo.status === "rejected" && + rateLimitInfo.overageStatus !== "allowed" && + rateLimitInfo.overageStatus !== "allowed_warning" && + rateLimitInfo.isUsingOverage !== true && + rateLimitInfo.overageInUse !== true && + context.turnState !== undefined + ) { + // Tracked per turn as a set of limit identities, not as the rendered + // row: a parked window re-fires while the remaining wait shrinks, and a + // turn can park on more than one window, so a single slot would let an + // interleaved repeat through. A new turn — including a synthetic one — + // starts a fresh set and announces its pause again. + const turnId = context.turnState.turnId; + if (context.announcedUsageLimits?.turnId !== turnId) { + context.announcedUsageLimits = { turnId, keys: new Set() }; + } + const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`; + if (!context.announcedUsageLimits.keys.has(limitKey)) { + context.announcedUsageLimits.keys.add(limitKey); + const notice = describeClaudeUsageLimit( + rateLimitInfo, + Date.parse(stamp.createdAt), + names, + ); + yield* emitRuntimeWarning(context, notice, rateLimitInfo); + } + } return; } }); @@ -4695,6 +4783,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + announcedUsageLimits: undefined, stopped: false, }; yield* Ref.set(contextRef, context); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 707cb5493225..b43b58e52627 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -49,6 +49,14 @@ You can also send `/compact` in an existing conversation. Web and desktop offer a large older thread. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. +## Usage limits + +If your Claude subscription runs out of usage mid-turn, the thread shows which +limit was reached and the remaining wait when Claude provides a reset time. +Claude Code holds the turn until that window reopens, so it can keep showing as +working. Wait for the reset, or stop the turn and continue later. The warning's +timestamp shows when the displayed wait started. + ## Skills Claude skills come from the config directory's `skills` folder and the project's