diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 232b8cc02d00..4a7fa9ebc3e4 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -211,7 +211,7 @@ it.effect("preserves initialized capabilities when optional usage times out", () decodeClaudeSettings({ binaryPath: "claude" }), ).pipe(Effect.forkChild); yield* Deferred.await(usageStarted); - yield* TestClock.adjust("4 seconds"); + yield* TestClock.adjust("15 seconds"); const capabilities = yield* Fiber.join(probe); assert.equal(capabilities?.email, "dev@example.com"); assert.equal(capabilities?.subscriptionType, "pro"); @@ -223,3 +223,40 @@ it.effect("preserves initialized capabilities when optional usage times out", () assert.equal(abortSignal?.aborted, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("keeps usage that answers after the shared 4 second budget", () => + Effect.gen(function* () { + const usageStarted = yield* Deferred.make(); + let answerUsage: (() => void) | undefined; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(() => { + return { + initializationResult: async () => ({ + account: { email: "dev@example.com", subscriptionType: "max", tokenSource: "oauth" }, + commands: [], + }), + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => { + Deferred.doneUnsafe(usageStarted, Effect.void); + return new Promise((resolve) => { + answerUsage = () => + resolve({ + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 22, resets_at: "2026-09-28T09:50:00Z" } }, + }); + }); + }, + } as unknown as ReturnType; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const probe = yield* probeClaudeCapabilities( + decodeClaudeSettings({ binaryPath: "claude" }), + ).pipe(Effect.forkChild); + yield* Deferred.await(usageStarted); + yield* TestClock.adjust("5 seconds"); + answerUsage?.(); + const capabilities = yield* Fiber.join(probe); + assert.deepEqual(capabilities?.usage, { + rate_limits_available: true, + rate_limits: { five_hour: { utilization: 22, resets_at: "2026-09-28T09:50:00Z" } }, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index db06557c7c8e..8be3eb3f14ef 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -172,6 +172,11 @@ function apiProviderAuthMetadata( // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +// `get_usage` makes the CLI fetch the account's usage from Anthropic, which +// takes 2.7–4.3s on a Max account with Claude Code 2.1.283. The shared 4s +// budget cut it off on most probes and showed "Could not read limits." +const CLAUDE_USAGE_TIMEOUT_MS = 15_000; + /** * Keep workspace-scoped command discovery intact while isolating the periodic * health check from configured MCP servers. @@ -366,7 +371,11 @@ const probeClaudeCapabilities = ( // Usage has its own deadline so a slow optional request cannot discard initialization. const usageResult = yield* Effect.tryPromise(() => q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), - ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result); + ).pipe( + Effect.timeout(CLAUDE_USAGE_TIMEOUT_MS), + Effect.tapError((cause) => Effect.logWarning("Claude usage read failed.", { cause })), + Effect.result, + ); const usage = Result.isSuccess(usageResult) ? { rate_limits_available: usageResult.success.rate_limits_available,