diff --git a/.gitignore b/.gitignore index 2c29a270..9cda5e54 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.pi/ /downloads/ /.codexhost/ +/.claude/ # Environment and secrets .env diff --git a/openspec/changes/project-claude-code-session-usage/tasks.md b/openspec/changes/project-claude-code-session-usage/tasks.md index 84e18399..a373d1b1 100644 --- a/openspec/changes/project-claude-code-session-usage/tasks.md +++ b/openspec/changes/project-claude-code-session-usage/tasks.md @@ -1,28 +1,28 @@ ## 1. Contracts -- [ ] 1.1 Extend `HostUsage` / `parseHostUsage` with optional `planFiveHourUsedPercent`, `planFiveHourResetsAtUnix`, `planSevenDayUsedPercent`, and `planSevenDayResetsAtUnix` -- [ ] 1.2 Mirror those fields on `threadUsageSnapshotSchema` and reject reset-without-percent, out-of-range percent, and unknown keys -- [ ] 1.3 Add harness-adapter and shared-contracts tests for valid plan windows, CH 0–100, and incomplete snapshots +- [x] 1.1 Extend `HostUsage` / `parseHostUsage` with optional `planFiveHourUsedPercent`, `planFiveHourResetsAtUnix`, `planSevenDayUsedPercent`, and `planSevenDayResetsAtUnix` +- [x] 1.2 Mirror those fields on `threadUsageSnapshotSchema` and reject reset-without-percent, out-of-range percent, and unknown keys +- [x] 1.3 Add harness-adapter and shared-contracts tests for valid plan windows, CH 0–100, and incomplete snapshots ## 2. Claude Adapter -- [ ] 2.1 Parse Turn `result.total_cost_usd` and per-model `modelUsage` input/output into Session aggregate fields; do not map last-request `usage` onto Session I/O -- [ ] 2.2 Compute `cacheHitRatePercent` only from last-request or `getContextUsage().apiUsage` cache/input fields; omit Claude `cachedInputTokens`, `cacheWriteInputTokens`, and `reasoningOutputTokens` -- [ ] 2.3 Parse `rate_limit_event` for `five_hour` and `seven_day`, merge per-window, ignore other `rateLimitType` values -- [ ] 2.4 Keep Adapter-side latest snapshot merge so context refresh, Result totals, and plan events replace one `HostUsage` without dropping still-applicable fields -- [ ] 2.5 Preserve existing lazy Query, generation invalidation, and Usage failure isolation -- [ ] 2.6 Add Fake transport / Adapter tests covering OAuth-with-plan-window, API-key-without-plan-window, incomplete cache fields, stale context read, and malformed rate-limit events +- [x] 2.1 Parse Turn `result.total_cost_usd` and per-model `modelUsage` input/output into Session aggregate fields; do not map last-request `usage` onto Session I/O +- [x] 2.2 Compute `cacheHitRatePercent` only from last-request or `getContextUsage().apiUsage` cache/input fields; omit Claude `cachedInputTokens`, `cacheWriteInputTokens`, and `reasoningOutputTokens` +- [x] 2.3 Parse `rate_limit_event` for `five_hour` and `seven_day`, merge per-window, ignore other `rateLimitType` values +- [x] 2.4 Keep Adapter-side latest snapshot merge so context refresh, Result totals, and plan events replace one `HostUsage` without dropping still-applicable fields +- [x] 2.5 Preserve existing lazy Query, generation invalidation, and Usage failure isolation +- [x] 2.6 Add Fake transport / Adapter tests covering OAuth-with-plan-window, API-key-without-plan-window, incomplete cache fields, stale context read, and malformed rate-limit events ## 3. Renderer -- [ ] 3.1 Add five-hour and seven-day rows to the Usage details Popover; omit each row when its fields are absent -- [ ] 3.2 Keep the collapsed summary as `CH` and cost only, including when plan windows are present -- [ ] 3.3 Keep control visibility tied to CH, output speed, or cost -- [ ] 3.4 Extend Renderer Usage tests (and e2e coverage if present) for subscriber popover vs API-key omission vs summary text +- [x] 3.1 Add five-hour and seven-day rows to the Usage details Popover; omit each row when its fields are absent +- [x] 3.2 Keep the collapsed summary as `CH` and cost only, including when plan windows are present +- [x] 3.3 Keep control visibility tied to CH, output speed, or cost +- [x] 3.4 Extend Renderer Usage tests (and e2e coverage if present) for subscriber popover vs API-key omission vs summary text ## 4. Validation -- [ ] 4.1 Confirm Host inspection still round-trips the new `HostUsage` fields through `threadUsageInspectionSchema` without writing `accountCredits` -- [ ] 4.2 Confirm Protocol Core `thread/tokenUsage/updated` still uses only the context pair / existing aggregate carrier -- [ ] 4.3 Run focused package tests and typecheck for harness-adapter, shared-contracts, adapter-claude-code, renderer-extension, and host-runtime as needed -- [ ] 4.4 Do not add live Claude OAuth or `/api/oauth/usage` calls to ordinary checks +- [x] 4.1 Confirm Host inspection still round-trips the new `HostUsage` fields through `threadUsageInspectionSchema` without writing `accountCredits` +- [x] 4.2 Confirm Protocol Core `thread/tokenUsage/updated` still uses only the context pair / existing aggregate carrier +- [x] 4.3 Run focused package tests and typecheck for harness-adapter, shared-contracts, adapter-claude-code, renderer-extension, and host-runtime as needed +- [x] 4.4 Do not add live Claude OAuth or `/api/oauth/usage` calls to ordinary checks diff --git a/packages/adapters/claude-code/src/claude-code-adapter.ts b/packages/adapters/claude-code/src/claude-code-adapter.ts index 81f6a7ff..6f14263a 100644 --- a/packages/adapters/claude-code/src/claude-code-adapter.ts +++ b/packages/adapters/claude-code/src/claude-code-adapter.ts @@ -33,6 +33,7 @@ import { type HostItemOutcome, type HostQuestionInteraction, type HostReasoningItem, + type HostUsage, type InspectHarnessInput, type InteractionRespondAccepted, type InteractionRespondCommand, @@ -59,6 +60,7 @@ import { nativeCheckpointRefSchema, nativeSessionRefSchema, nativeTurnRefSchema, + type AccountCreditsSnapshot, type HarnessId, type HarnessThinkingOptionId, type HostInteractionId, @@ -99,6 +101,7 @@ import type { ClaudeInteractionRequest, ClaudeInteractionResponse, ClaudeModelInspector, + ClaudePlanLimitEvent, ClaudeQuestionRequest, ClaudeTransportFailureKind, ClaudeTransportTurnResult, @@ -307,6 +310,65 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +/** + * Cache hit rate for the latest request only, never a Session cumulative value. + * Every addend must be present; the denominator must be positive. + */ +function claudeCacheHitRatePercent(usage: { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +}): number | undefined { + const denominator = + usage.inputTokens + usage.cacheCreationInputTokens + usage.cacheReadInputTokens; + if (denominator <= 0) return undefined; + return Math.min(100, Math.max(0, (usage.cacheReadInputTokens / denominator) * 100)); +} + +/** + * Projects the Adapter's cached plan-limit observation into the generic + * `AccountCreditsSnapshot` shape the Renderer's credits pill/popover expect. + * The 5-hour window leads (it's the more actionable of the two — it resets + * soonest); the 7-day window, when known, rides along as a secondary entry + * rather than a fabricated "product". Falls back to the 7-day window alone + * if a 5-hour observation hasn't arrived yet. + */ +function isoFromUnix(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toISOString(); +} + +export function projectClaudePlanLimitToCredits( + planLimit: ClaudePlanLimitEvent | null, +): AccountCreditsSnapshot | null { + if (!planLimit) return null; + const { fiveHour, sevenDay } = planLimit; + if (!fiveHour && !sevenDay) return null; + + const primary = fiveHour ?? sevenDay; + if (!primary) return null; + const periodType: AccountCreditsSnapshot["periodType"] = fiveHour ? "five_hour" : "seven_day"; + const other = fiveHour && sevenDay ? sevenDay : undefined; + + return { + usedPercent: primary.utilizationPercent, + periodType, + ...(primary.resetsAtUnix !== undefined ? { resetsAt: isoFromUnix(primary.resetsAtUnix) } : {}), + ...(other + ? { + productUsage: [ + { + product: "7-day window", + usagePercent: other.utilizationPercent, + ...(other.resetsAtUnix !== undefined + ? { resetsAt: isoFromUnix(other.resetsAtUnix) } + : {}), + }, + ], + } + : {}), + }; +} + class ClaudeHarnessSession implements HarnessSession { readonly harnessId: HarnessId = claudeCodeHarnessId; readonly capabilities: HarnessSessionCapabilities = { @@ -328,6 +390,7 @@ class ClaudeHarnessSession implements HarnessSession { readonly #cwd: string; readonly #nativeRef: NativeSessionRef; readonly #onClosed: () => void; + readonly #onPlanLimitObserved: (planLimit: ClaudePlanLimitEvent) => void; readonly #openMode: "create" | "resume"; readonly #randomUUID: () => string; #requestedModel: HarnessModelRef | undefined; @@ -347,6 +410,7 @@ class ClaudeHarnessSession implements HarnessSession { #statePublished = false; #transport: ClaudeTurnTransport | null = null; #usageGeneration = 0; + #latestUsage: HostUsage | null = null; #autonomousOrdinal = 0; #occupancy = new ClaudeBackgroundOccupancy(); #continuationQuiescence: ReturnType | null = null; @@ -356,6 +420,7 @@ class ClaudeHarnessSession implements HarnessSession { dependencies: ClaudeAdapterDependencies, closeTimeoutMs: number, onClosed: () => void, + onPlanLimitObserved: (planLimit: ClaudePlanLimitEvent) => void, options: { openMode: "create" | "resume"; sessionId: string; @@ -372,6 +437,7 @@ class ClaudeHarnessSession implements HarnessSession { this.#readSessionMessages = dependencies.readSessionMessages; this.#closeTimeoutMs = closeTimeoutMs; this.#onClosed = onClosed; + this.#onPlanLimitObserved = onPlanLimitObserved; this.#openMode = options.openMode; this.#requestedModel = options.requestedModel; this.#requestedPermissionModeId = options.requestedPermissionModeId; @@ -1029,6 +1095,7 @@ class ClaudeHarnessSession implements HarnessSession { permissionMode, onPermissionModeChanged: (mode) => this.#handlePermissionModeChanged(mode), onFault: () => this.#fault(faultError()), + onPlanLimit: (planLimit) => this.#handlePlanLimit(planLimit), }); transport.setAutonomousTurnHandler((turn) => this.#handleAutonomousTurn(turn)); transport.setIdleTurnHandler({ @@ -1209,6 +1276,9 @@ class ClaudeHarnessSession implements HarnessSession { case "interaction.closed": this.#closeInteraction(active, event.requestId, event.reason); return; + case "usage.result": + this.#applyResultUsage(active, event); + return; } } @@ -1562,11 +1632,17 @@ class ClaudeHarnessSession implements HarnessSession { return; } if (context === null) continue; - const usage = parseHostUsage({ - contextUsedTokens: context.usedTokens, - contextWindowTokens: context.maxTokens, - }); - this.#event({ type: "session.usage.changed", observedForTurnId: turnId, usage }); + const cacheHitRatePercent = context.apiUsage + ? claudeCacheHitRatePercent(context.apiUsage) + : undefined; + this.#mergeAndPublishUsage( + { + contextUsedTokens: context.usedTokens, + contextWindowTokens: context.maxTokens, + ...(cacheHitRatePercent !== undefined ? { cacheHitRatePercent } : {}), + }, + turnId, + ); return; } catch { // Context Usage is an independent, best-effort projection. @@ -1574,6 +1650,96 @@ class ClaudeHarnessSession implements HarnessSession { } } + #applyResultUsage( + active: ActiveTurn, + event: Extract, + ): void { + const delta: Partial = {}; + if (event.totalCostUsd !== undefined) delta.totalCostUsd = event.totalCostUsd; + if (event.modelUsage !== undefined) { + let inputTokens = 0; + let outputTokens = 0; + for (const model of event.modelUsage) { + inputTokens += model.inputTokens; + outputTokens += model.outputTokens; + } + if (Number.isSafeInteger(inputTokens) && Number.isSafeInteger(outputTokens)) { + delta.inputTokens = inputTokens; + delta.outputTokens = outputTokens; + } + } + if (event.lastRequestUsage) { + const cacheHitRatePercent = claudeCacheHitRatePercent(event.lastRequestUsage); + if (cacheHitRatePercent !== undefined) delta.cacheHitRatePercent = cacheHitRatePercent; + } + this.#mergeAndPublishUsage(delta, active.command.turnId); + } + + /** + * On-demand counterpart to the passive `rate_limit_event` push: asks this + * Session's live Transport to pull plan usage right now (if it has one — + * a Session with no Turn yet has never opened a live connection to pull + * through). Routes any answer through the same `#handlePlanLimit` path as + * the push, so it updates both the Adapter's shared cache and this + * Thread's own Usage snapshot identically either way. + */ + async refreshPlanLimit(): Promise { + if (this.#phase !== "open" || !this.#transport) return null; + let planLimit: ClaudePlanLimitEvent | null; + try { + planLimit = await this.#transport.getPlanLimit(); + } catch { + return null; + } + if (planLimit) this.#handlePlanLimit(planLimit); + return planLimit; + } + + #handlePlanLimit(planLimit: ClaudePlanLimitEvent): void { + // Plan usage is account-wide, not Thread-scoped: forward every observation to the + // Adapter's shared cache regardless of this Session's own lifecycle phase. + this.#onPlanLimitObserved(planLimit); + if (this.#phase !== "open") return; + const delta: Partial = {}; + if (planLimit.fiveHour) { + delta.planFiveHourUsedPercent = planLimit.fiveHour.utilizationPercent; + if (planLimit.fiveHour.resetsAtUnix !== undefined) { + delta.planFiveHourResetsAtUnix = planLimit.fiveHour.resetsAtUnix; + } + } + if (planLimit.sevenDay) { + delta.planSevenDayUsedPercent = planLimit.sevenDay.utilizationPercent; + if (planLimit.sevenDay.resetsAtUnix !== undefined) { + delta.planSevenDayResetsAtUnix = planLimit.sevenDay.resetsAtUnix; + } + } + this.#mergeAndPublishUsage(delta, this.#active?.command.turnId); + } + + /** + * Every Usage observation replaces `#latestUsage` in full: unaffected fields + * from the prior snapshot are carried forward, never cleared by an + * incomplete new observation. + */ + #mergeAndPublishUsage( + delta: Partial, + turnId: TurnStartCommand["turnId"] | undefined, + ): void { + if (Object.keys(delta).length === 0) return; + let usage: HostUsage; + try { + usage = parseHostUsage({ ...(this.#latestUsage ?? {}), ...delta }); + } catch { + return; + } + this.#latestUsage = usage; + this.#event({ + type: "session.usage.changed", + ...(turnId !== undefined ? { observedForTurnId: turnId } : {}), + usage, + }); + } + #finishFailed(active: ActiveTurn, error: HarnessError): void { this.#finish(active, { status: "failed", error }); } @@ -1728,6 +1894,7 @@ export class ClaudeCodeAdapter implements HarnessAdapter { readonly #inspectors = new Set(); readonly #sessions = new Set(); #closePromise: Promise | null = null; + #latestPlanLimit: ClaudePlanLimitEvent | null = null; constructor(options: ClaudeCodeAdapterOptions = {}, dependencies?: ClaudeAdapterDependencies) { this.#closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; @@ -1986,6 +2153,7 @@ export class ClaudeCodeAdapter implements HarnessAdapter { this.#dependencies, this.#closeTimeoutMs, () => this.#sessions.delete(session), + (planLimit) => this.#recordPlanLimit(planLimit), { openMode: input.kind === "create" ? "create" : "resume", sessionId: forked?.ok @@ -2004,6 +2172,44 @@ export class ClaudeCodeAdapter implements HarnessAdapter { return { ok: true, value: session }; } + /** + * Plan usage (`credits()`) is scoped to the Adapter, not a single Thread: the + * Claude.ai 5-hour / 7-day windows are account-wide and shared by every + * concurrent Session, so an observation from any one of them updates the + * value every Thread reads. + */ + #recordPlanLimit(planLimit: ClaudePlanLimitEvent): void { + this.#latestPlanLimit = { + ...(this.#latestPlanLimit ?? {}), + ...(planLimit.fiveHour ? { fiveHour: planLimit.fiveHour } : {}), + ...(planLimit.sevenDay ? { sevenDay: planLimit.sevenDay } : {}), + }; + } + + credits(): AccountCreditsSnapshot | null { + return projectClaudePlanLimitToCredits(this.#latestPlanLimit); + } + + /** + * Unlike the passive `rate_limit_event` push, this asks an open Session's + * live Transport to pull plan usage on demand (Claude Code's `/usage` + * control channel) — so, like Grok's credits, a refresh can actually + * produce a fresher value instead of only replaying what was last + * observed. Plan usage is account-wide, so any one open Session's answer + * updates the value every Thread reads; the first Session that manages to + * answer wins and the rest are left untried. + */ + async refreshCredits(): Promise { + for (const session of this.#sessions) { + // `refreshPlanLimit` already records a non-null answer into `#latestPlanLimit` + // (it routes through `#handlePlanLimit`, same as the passive push) — this loop + // only needs to know when to stop trying further Sessions. + const planLimit = await session.refreshPlanLimit(); + if (planLimit) break; + } + return this.credits(); + } + close(): Promise { if (!this.#closePromise) { this.#inspectionCache.clear(); diff --git a/packages/adapters/claude-code/src/native-message.ts b/packages/adapters/claude-code/src/native-message.ts index ad49bd1e..1316eb5d 100644 --- a/packages/adapters/claude-code/src/native-message.ts +++ b/packages/adapters/claude-code/src/native-message.ts @@ -2,6 +2,7 @@ import { jsonValueSchema } from "@codexhost/shared-contracts"; import { parseClaudeNativeFileChange } from "./file-change.js"; import type { + ClaudePlanLimitEvent, ClaudeTransportFailureKind, ClaudeTransportTurnResult, ClaudeTurnEvent, @@ -133,6 +134,122 @@ function failure(kind: ClaudeTransportFailureKind): ClaudeTransportTurnResult { return { status: "failed", kind }; } +function safeNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function finiteNonNegativeNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function parseResultModelUsage( + value: unknown, +): Array<{ inputTokens: number; outputTokens: number }> | undefined { + if (!isRecord(value)) return undefined; + const usage: Array<{ inputTokens: number; outputTokens: number }> = []; + for (const entry of Object.values(value)) { + if ( + !isRecord(entry) || + !safeNonNegativeInteger(entry.inputTokens) || + !safeNonNegativeInteger(entry.outputTokens) + ) { + return undefined; + } + usage.push({ inputTokens: entry.inputTokens, outputTokens: entry.outputTokens }); + } + return usage; +} + +function parseLastRequestUsage(value: unknown): + | { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + } + | undefined { + if (!isRecord(value)) return undefined; + const inputTokens = value.input_tokens; + const cacheCreationInputTokens = value.cache_creation_input_tokens; + const cacheReadInputTokens = value.cache_read_input_tokens; + if ( + !safeNonNegativeInteger(inputTokens) || + !safeNonNegativeInteger(cacheCreationInputTokens) || + !safeNonNegativeInteger(cacheReadInputTokens) + ) { + return undefined; + } + return { inputTokens, cacheCreationInputTokens, cacheReadInputTokens }; +} + +function parseResultUsageEvent( + message: Record, +): Extract | null { + const totalCostUsd = finiteNonNegativeNumber(message.total_cost_usd) + ? message.total_cost_usd + : undefined; + const modelUsage = parseResultModelUsage(message.modelUsage); + const lastRequestUsage = parseLastRequestUsage(message.usage); + if (totalCostUsd === undefined && modelUsage === undefined && lastRequestUsage === undefined) { + return null; + } + return { + type: "usage.result", + ...(totalCostUsd !== undefined ? { totalCostUsd } : {}), + ...(modelUsage !== undefined ? { modelUsage } : {}), + ...(lastRequestUsage ? { lastRequestUsage } : {}), + }; +} + +/** + * Live Claude Code sends `utilization` as a 0–1 fraction (confirmed against a + * real `rate_limit_event` payload), not the 0–100 percent the SDK's `.d.ts` + * comment implies. Normalize and clamp defensively either way. + */ +function parsePlanLimitWindow( + value: unknown, +): { utilizationPercent: number; resetsAtUnix?: number } | undefined { + if (!isRecord(value)) return undefined; + const utilization = value.utilization; + if (typeof utilization !== "number" || !Number.isFinite(utilization) || utilization < 0) { + return undefined; + } + const utilizationPercent = Math.min(100, Math.max(0, Math.round(utilization * 10_000) / 100)); + const resetsAt = value.resetsAt; + const resetsAtUnix = safeNonNegativeInteger(resetsAt) ? resetsAt : undefined; + return { utilizationPercent, ...(resetsAtUnix !== undefined ? { resetsAtUnix } : {}) }; +} + +/** + * `rate_limit_event` is Session-level and can arrive with no Turn active on the + * transport, so it is parsed independently of the per-Turn accumulator. + * + * Claude Code reports both windows on one event via + * `rate_limit_info.unifiedWindows.{five_hour,seven_day}`. Per-model breakdowns + * (`seven_day_opus`, `seven_day_sonnet`, ...) and overage fields are ignored + * by construction — only these two keys are read. A flat top-level + * `rateLimitType` + `utilization` + `resetsAt` (the shape the SDK's `.d.ts` + * documents) is accepted as a fallback for a single primary window when + * `unifiedWindows` is absent. + */ +export function parseClaudePlanLimitEvent(message: unknown): ClaudePlanLimitEvent | null { + if (!isRecord(message) || message.type !== "rate_limit_event") return null; + const info = message.rate_limit_info; + if (!isRecord(info)) return null; + const windows = isRecord(info.unifiedWindows) ? info.unifiedWindows : undefined; + let fiveHour = parsePlanLimitWindow(windows?.five_hour); + let sevenDay = parsePlanLimitWindow(windows?.seven_day); + if (!fiveHour && !sevenDay) { + const flatWindow = parsePlanLimitWindow(info); + if (flatWindow && info.rateLimitType === "five_hour") fiveHour = flatWindow; + else if (flatWindow && info.rateLimitType === "seven_day") sevenDay = flatWindow; + } + if (!fiveHour && !sevenDay) return null; + return { + ...(fiveHour ? { fiveHour } : {}), + ...(sevenDay ? { sevenDay } : {}), + }; +} + function nativeSubagentId( nativeResult: unknown, outputText: string | undefined, @@ -299,6 +416,8 @@ export class ClaudeNativeTurnAccumulator { } if (message.type !== "result") return { events }; + const usageEvent = parseResultUsageEvent(message); + if (usageEvent) events.push(usageEvent); this.#completed = true; const terminalReason = typeof message.terminal_reason === "string" ? message.terminal_reason : "missing"; diff --git a/packages/adapters/claude-code/src/sdk-transport.ts b/packages/adapters/claude-code/src/sdk-transport.ts index 6ed21f9e..348f17a0 100644 --- a/packages/adapters/claude-code/src/sdk-transport.ts +++ b/packages/adapters/claude-code/src/sdk-transport.ts @@ -14,7 +14,7 @@ import type { HarnessThinkingOptionId } from "@codexhost/shared-contracts"; import { resolveClaudeCodeExecutable, withNodeRuntimeOnPath } from "./command.js"; import type { ClaudeModelInspectionSnapshot } from "./model-catalog.js"; -import { ClaudeNativeTurnAccumulator } from "./native-message.js"; +import { ClaudeNativeTurnAccumulator, parseClaudePlanLimitEvent } from "./native-message.js"; import { isClaudePermissionMode, type ClaudePermissionMode } from "./permission-modes.js"; import { claudeThinkingConfiguration, parseClaudeThinkingOptionId } from "./thinking-options.js"; import type { @@ -25,6 +25,8 @@ import type { ClaudeInteractionRequest, ClaudeInteractionResponse, ClaudeModelInspector, + ClaudePlanLimitEvent, + ClaudePlanLimitWindow, ClaudeQuestion, ClaudeTransportContextUsage, ClaudeTransportTurnResult, @@ -98,6 +100,7 @@ export interface ClaudeSdkTransportOptions { closeTimeoutMs: number; onPermissionModeChanged(permissionMode: ClaudePermissionMode): void; onFault(error: unknown): void; + onPlanLimit(planLimit: ClaudePlanLimitEvent): void; queryFactory?: typeof query; } @@ -140,6 +143,27 @@ function permissionModeFromMessage(value: unknown): ClaudePermissionMode | undef return isClaudePermissionMode(value.permissionMode) ? value.permissionMode : undefined; } +function safeNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function parseContextApiUsage(value: unknown): ClaudeTransportContextUsage["apiUsage"] { + if (!isRecord(value)) return undefined; + const inputTokens = value.input_tokens; + const outputTokens = value.output_tokens; + const cacheCreationInputTokens = value.cache_creation_input_tokens; + const cacheReadInputTokens = value.cache_read_input_tokens; + if ( + !safeNonNegativeInteger(inputTokens) || + !safeNonNegativeInteger(outputTokens) || + !safeNonNegativeInteger(cacheCreationInputTokens) || + !safeNonNegativeInteger(cacheReadInputTokens) + ) { + return undefined; + } + return { inputTokens, outputTokens, cacheCreationInputTokens, cacheReadInputTokens }; +} + function parseContextUsage(value: unknown): ClaudeTransportContextUsage { if (!isRecord(value)) throw new Error("Claude SDK context Usage is invalid"); const usedTokens = value.totalTokens; @@ -158,7 +182,37 @@ function parseContextUsage(value: unknown): ClaudeTransportContextUsage { ) { throw new Error("Claude SDK context Usage contains invalid values"); } - return { usedTokens, maxTokens, model }; + const apiUsage = parseContextApiUsage(value.apiUsage); + return { usedTokens, maxTokens, model, ...(apiUsage ? { apiUsage } : {}) }; +} + +function parseUsageQueryWindow(value: unknown): ClaudePlanLimitWindow | undefined { + if (!isRecord(value)) return undefined; + const utilization = value.utilization; + if (typeof utilization !== "number" || !Number.isFinite(utilization) || utilization < 0) { + return undefined; + } + const utilizationPercent = Math.min(100, Math.max(0, utilization)); + const resetsAt = value.resets_at; + const resetsAtMs = typeof resetsAt === "string" ? Date.parse(resetsAt) : Number.NaN; + const resetsAtUnix = Number.isFinite(resetsAtMs) ? Math.floor(resetsAtMs / 1000) : undefined; + return { utilizationPercent, ...(resetsAtUnix !== undefined ? { resetsAtUnix } : {}) }; +} + +/** + * Parses the response from `Query#usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` — + * the on-demand pull counterpart to the `rate_limit_event` push in native-message.ts. Utilization + * here already arrives as a 0-100 percent (unlike the 0-1 fraction on the native event), and + * `resets_at` is an ISO 8601 string rather than a Unix-seconds number. + */ +function parseUsageQueryPlanLimit(value: unknown): ClaudePlanLimitEvent | null { + if (!isRecord(value) || value.rate_limits_available !== true) return null; + const rateLimits = value.rate_limits; + if (!isRecord(rateLimits)) return null; + const fiveHour = parseUsageQueryWindow(rateLimits.five_hour); + const sevenDay = parseUsageQueryWindow(rateLimits.seven_day); + if (!fiveHour && !sevenDay) return null; + return { ...(fiveHour ? { fiveHour } : {}), ...(sevenDay ? { sevenDay } : {}) }; } function parseQuestions(input: Record): ClaudeQuestion[] | null { @@ -325,6 +379,7 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { readonly #model: string | undefined; readonly #onFault: (error: unknown) => void; readonly #onPermissionModeChanged: (permissionMode: ClaudePermissionMode) => void; + readonly #onPlanLimit: (planLimit: ClaudePlanLimitEvent) => void; readonly #openMode: "create" | "resume"; #permissionMode: ClaudePermissionMode; readonly #queryFactory: typeof query; @@ -355,6 +410,7 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { this.#model = options.model; this.#onFault = options.onFault; this.#onPermissionModeChanged = options.onPermissionModeChanged; + this.#onPlanLimit = options.onPlanLimit; this.#openMode = options.openMode; this.#permissionMode = options.permissionMode; this.#queryFactory = options.queryFactory ?? query; @@ -425,6 +481,14 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { return parseContextUsage(await activeQuery.getContextUsage()); } + async getPlanLimit(): Promise { + const activeQuery = this.#query; + if (!this.#started || !activeQuery) return null; + return parseUsageQueryPlanLimit( + await activeQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), + ); + } + getPermissionMode(): ClaudePermissionMode { if (!this.#started || !this.#query) throw new Error("Claude SDK transport is not started"); return this.#permissionMode; @@ -723,6 +787,8 @@ export class ClaudeSdkTransport implements ClaudeTurnTransport { this.#permissionMode = permissionMode; this.#onPermissionModeChanged(permissionMode); } + const planLimit = parseClaudePlanLimitEvent(message); + if (planLimit) this.#onPlanLimit(planLimit); const active = this.#active; if (active) { const interpreted = active.accumulator.consume(message); diff --git a/packages/adapters/claude-code/src/transport.ts b/packages/adapters/claude-code/src/transport.ts index 9269446d..8b8b44ab 100644 --- a/packages/adapters/claude-code/src/transport.ts +++ b/packages/adapters/claude-code/src/transport.ts @@ -110,12 +110,46 @@ export type ClaudeTurnEvent = type: "interaction.closed"; requestId: string; reason: "responded" | "cancelled" | "superseded"; + } + | { + type: "usage.result"; + totalCostUsd?: number; + modelUsage?: Array<{ inputTokens: number; outputTokens: number }>; + lastRequestUsage?: { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }; }; export interface ClaudeTransportContextUsage { usedTokens: number; maxTokens: number; model: string; + apiUsage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }; +} + +export interface ClaudePlanLimitWindow { + utilizationPercent: number; + resetsAtUnix?: number; +} + +/** + * Claude.ai subscription plan-window utilization. Arrives two ways: pushed via + * `rate_limit_event` whenever the CLI makes a real API call (regardless of + * whether a Turn is active on the transport), or pulled on demand through + * `getPlanLimit()`. Either source can report both windows at once (Claude Code + * groups them under `rate_limit_info.unifiedWindows` / `rate_limits`), so both + * are optional on the same event rather than modeled as one event per window. + */ +export interface ClaudePlanLimitEvent { + fiveHour?: ClaudePlanLimitWindow; + sevenDay?: ClaudePlanLimitWindow; } export interface ClaudeAutonomousTurn { @@ -136,6 +170,14 @@ export interface ClaudeTurnTransport { setIdleLive(live: boolean): void; start(): Promise; getContextUsage(): Promise; + /** + * Asks the CLI's `/usage` control channel for the plan-window utilization + * right now, instead of waiting for the next `rate_limit_event` to arrive on + * its own. Uses an Anthropic SDK API explicitly marked experimental — `null` + * on anything short of a clean, available answer (not started, API-key + * Session, control-channel error, or a future SDK that drops the method). + */ + getPlanLimit(): Promise; getPermissionMode(): ClaudePermissionMode; setModel(model?: string): Promise; setThinkingOption(thinkingOptionId: HarnessThinkingOptionId): Promise; @@ -172,6 +214,7 @@ export interface ClaudeTransportFactoryInput { permissionMode: ClaudePermissionMode; onPermissionModeChanged(permissionMode: ClaudePermissionMode): void; onFault(error: unknown): void; + onPlanLimit(planLimit: ClaudePlanLimitEvent): void; } export interface ClaudeModelInspector { diff --git a/packages/adapters/claude-code/test/claude-code-adapter.test.ts b/packages/adapters/claude-code/test/claude-code-adapter.test.ts index ecf582e6..6ef29833 100644 --- a/packages/adapters/claude-code/test/claude-code-adapter.test.ts +++ b/packages/adapters/claude-code/test/claude-code-adapter.test.ts @@ -11,6 +11,7 @@ import { import type { HarnessOutput, HarnessSession } from "@codexhost/harness-adapter"; import { ClaudeCodeAdapter, type ClaudeCodeAdapterOptions } from "../src/index.js"; +import { projectClaudePlanLimitToCredits } from "../src/claude-code-adapter.js"; import { ClaudeCodeExecutableError } from "../src/command.js"; import { CLAUDE_DEFAULT_MODEL_REF, encodeClaudeModelRef } from "../src/model-catalog.js"; import type { ClaudePermissionMode } from "../src/permission-modes.js"; @@ -20,6 +21,7 @@ import type { ClaudeAutonomousTurn, ClaudeIdleTurnHandler, ClaudeInteractionResponse, + ClaudePlanLimitEvent, ClaudeQuestionRequest, ClaudeTransportContextUsage, ClaudeTransportTurnResult, @@ -44,11 +46,15 @@ class FakeClaudeTransport implements ClaudeTurnTransport { readonly abort = vi.fn(async () => undefined); readonly close = vi.fn(async () => undefined); contextUsage: ClaudeTransportContextUsage | null = null; + planLimitOnDemand: ClaudePlanLimitEvent | null = null; permissionMode: ClaudePermissionMode; readonly #onPermissionModeChanged: (permissionMode: ClaudePermissionMode) => void; readonly getContextUsage = vi.fn( async (): Promise => this.contextUsage, ); + readonly getPlanLimit = vi.fn( + async (): Promise => this.planLimitOnDemand, + ); readonly setModel = vi.fn(async () => undefined); readonly setThinkingOption = vi.fn(async () => undefined); readonly getPermissionMode = vi.fn(() => this.permissionMode); @@ -77,14 +83,18 @@ class FakeClaudeTransport implements ClaudeTurnTransport { } | undefined; + readonly #onPlanLimit: (planLimit: ClaudePlanLimitEvent) => void; + constructor( sessionId: string, permissionMode: ClaudePermissionMode, onPermissionModeChanged: (permissionMode: ClaudePermissionMode) => void, + onPlanLimit: (planLimit: ClaudePlanLimitEvent) => void, ) { this.sessionId = sessionId; this.permissionMode = permissionMode; this.#onPermissionModeChanged = onPermissionModeChanged; + this.#onPlanLimit = onPlanLimit; } changePermissionMode(permissionMode: ClaudePermissionMode): void { @@ -92,6 +102,10 @@ class FakeClaudeTransport implements ClaudeTurnTransport { this.#onPermissionModeChanged(permissionMode); } + planLimit(planLimit: ClaudePlanLimitEvent): void { + this.#onPlanLimit(planLimit); + } + compact( userMessageId: string, customInstructions: string | undefined, @@ -241,6 +255,7 @@ function fixture(options: ClaudeCodeAdapterOptions = {}) { input.sessionId, input.permissionMode, input.onPermissionModeChanged, + input.onPlanLimit, ); transports.push(transport); return transport; @@ -289,6 +304,52 @@ async function nextInteraction(iterator: AsyncIterator) { return output.value.interaction; } +describe("projectClaudePlanLimitToCredits", () => { + it("returns null when nothing has been observed", () => { + expect(projectClaudePlanLimitToCredits(null)).toBeNull(); + expect(projectClaudePlanLimitToCredits({})).toBeNull(); + }); + + it("leads with the five-hour window and folds the seven-day window into productUsage", () => { + expect( + projectClaudePlanLimitToCredits({ + fiveHour: { utilizationPercent: 62, resetsAtUnix: 1_756_130_400 }, + sevenDay: { utilizationPercent: 18, resetsAtUnix: 1_756_648_800 }, + }), + ).toEqual({ + usedPercent: 62, + periodType: "five_hour", + resetsAt: new Date(1_756_130_400 * 1000).toISOString(), + productUsage: [ + { + product: "7-day window", + usagePercent: 18, + resetsAt: new Date(1_756_648_800 * 1000).toISOString(), + }, + ], + }); + }); + + it("omits resetsAt and productUsage when neither is available", () => { + expect(projectClaudePlanLimitToCredits({ fiveHour: { utilizationPercent: 8 } })).toEqual({ + usedPercent: 8, + periodType: "five_hour", + }); + }); + + it("falls back to the seven-day window alone", () => { + expect( + projectClaudePlanLimitToCredits({ + sevenDay: { utilizationPercent: 41, resetsAtUnix: 1_756_648_800 }, + }), + ).toEqual({ + usedPercent: 41, + periodType: "seven_day", + resetsAt: new Date(1_756_648_800 * 1000).toISOString(), + }); + }); +}); + describe("Claude Code HarnessAdapter", () => { it("opens and closes unused Sessions without creating a Transport", async () => { const { adapter, dependencies } = fixture(); @@ -3233,6 +3294,427 @@ describe("Claude Code HarnessAdapter", () => { await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }); }); + it("merges Session cost, token totals, and latest cache hit rate from the Turn Result", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("usage-result")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + transport.contextUsage = { usedTokens: 80, maxTokens: 200, model: "runtime-default" }; + transport.event({ + type: "usage.result", + totalCostUsd: 1.373, + modelUsage: [ + { inputTokens: 100, outputTokens: 40 }, + { inputTokens: 20, outputTokens: 5 }, + ], + lastRequestUsage: { + inputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 990, + }, + }); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "usage-result", + usage: { + totalCostUsd: 1.373, + inputTokens: 120, + outputTokens: 45, + cacheHitRatePercent: 99, + }, + }); + transport.finish({ status: "succeeded" }); + + expect((await nextEvent(iterator)).type).toBe("item.completed"); + expect((await nextEvent(iterator)).type).toBe("turn.completed"); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "usage-result", + usage: { + totalCostUsd: 1.373, + inputTokens: 120, + outputTokens: 45, + cacheHitRatePercent: 99, + contextUsedTokens: 80, + contextWindowTokens: 200, + }, + }); + await session.close(); + }); + + it("omits cache hit rate when last-request cache fields are incomplete", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("usage-incomplete-cache")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + transport.event({ + type: "usage.result", + totalCostUsd: 0.5, + modelUsage: [{ inputTokens: 10, outputTokens: 2 }], + }); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "usage-incomplete-cache", + usage: { totalCostUsd: 0.5, inputTokens: 10, outputTokens: 2 }, + }); + transport.finish({ status: "succeeded" }); + expect((await nextEvent(iterator)).type).toBe("item.completed"); + expect((await nextEvent(iterator)).type).toBe("turn.completed"); + await session.close(); + }); + + it("publishes a Claude.ai five-hour plan window and preserves it across a later seven-day window", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("plan-turn")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + + transport.planLimit({ + fiveHour: { utilizationPercent: 45, resetsAtUnix: 1_756_130_400 }, + }); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "plan-turn", + usage: { planFiveHourUsedPercent: 45, planFiveHourResetsAtUnix: 1_756_130_400 }, + }); + + transport.planLimit({ sevenDay: { utilizationPercent: 12 } }); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "plan-turn", + usage: { + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + planSevenDayUsedPercent: 12, + }, + }); + + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + await nextEvent(iterator); + await session.close(); + }); + + it("publishes both plan windows from a single rate-limit event", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("plan-both-windows")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + + transport.planLimit({ + fiveHour: { utilizationPercent: 28, resetsAtUnix: 1_787_674_200 }, + sevenDay: { utilizationPercent: 10, resetsAtUnix: 1_787_940_000 }, + }); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "plan-both-windows", + usage: { + planFiveHourUsedPercent: 28, + planFiveHourResetsAtUnix: 1_787_674_200, + planSevenDayUsedPercent: 10, + planSevenDayResetsAtUnix: 1_787_940_000, + }, + }); + + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + await nextEvent(iterator); + await session.close(); + }); + + it("never publishes plan-window fields for an API-key Session that receives no rate-limit event", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("api-key-turn")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + transport.contextUsage = { usedTokens: 40, maxTokens: 200, model: "runtime-default" }; + transport.event({ type: "usage.result", totalCostUsd: 0.2 }); + const resultUsage = await nextEvent(iterator); + transport.finish({ status: "succeeded" }); + + expect((await nextEvent(iterator)).type).toBe("item.completed"); + expect((await nextEvent(iterator)).type).toBe("turn.completed"); + const contextUsage = await nextEvent(iterator); + for (const event of [resultUsage, contextUsage]) { + if (event.type !== "session.usage.changed" || event.usage === null) { + throw new Error("Expected a Session Usage snapshot"); + } + expect(event.usage).not.toHaveProperty("planFiveHourUsedPercent"); + expect(event.usage).not.toHaveProperty("planSevenDayUsedPercent"); + } + await session.close(); + }); + + it("drops a malformed plan-limit observation without touching the latest still-applicable Usage", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("plan-malformed")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + + transport.planLimit({ fiveHour: { utilizationPercent: 30 } }); + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + observedForTurnId: "plan-malformed", + usage: { planFiveHourUsedPercent: 30 }, + }); + + transport.planLimit({ fiveHour: { utilizationPercent: Number.NaN } }); + transport.finish({ status: "succeeded" }); + expect((await nextEvent(iterator)).type).toBe("item.completed"); + expect((await nextEvent(iterator)).type).toBe("turn.completed"); + await session.close(); + }); + + it("has no plan usage on the Adapter before any rate-limit event is observed", () => { + const { adapter } = fixture(); + expect(adapter.credits()).toBeNull(); + }); + + it("projects the five-hour window as the primary credits pill, with the seven-day window riding along", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("credits-turn")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + + transport.planLimit({ + fiveHour: { utilizationPercent: 62, resetsAtUnix: 1_756_130_400 }, + sevenDay: { utilizationPercent: 18, resetsAtUnix: 1_756_648_800 }, + }); + await nextEvent(iterator); + + expect(adapter.credits()).toEqual({ + usedPercent: 62, + periodType: "five_hour", + resetsAt: new Date(1_756_130_400 * 1000).toISOString(), + productUsage: [ + { + product: "7-day window", + usagePercent: 18, + resetsAt: new Date(1_756_648_800 * 1000).toISOString(), + }, + ], + }); + + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + await nextEvent(iterator); + await session.close(); + }); + + it("falls back to the seven-day window alone when no five-hour observation has arrived", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("seven-day-only")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + + transport.planLimit({ sevenDay: { utilizationPercent: 41 } }); + await nextEvent(iterator); + + expect(adapter.credits()).toEqual({ usedPercent: 41, periodType: "seven_day" }); + + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + await nextEvent(iterator); + await session.close(); + }); + + it("shares one account-wide plan-limit cache across concurrent Sessions", async () => { + const { adapter, transports } = fixture(); + const sessionA = await openSession(adapter); + const iteratorA = sessionA.outputs[Symbol.asyncIterator](); + await sessionA.execute(textTurn("session-a")); + await nextEvent(iteratorA); + await nextEvent(iteratorA); + await nextEvent(iteratorA); + const transportA = transports[0]; + if (!transportA) throw new Error("Fake Claude transport was not created"); + + const sessionB = await openSession(adapter); + const iteratorB = sessionB.outputs[Symbol.asyncIterator](); + await sessionB.execute(textTurn("session-b")); + await nextEvent(iteratorB); + await nextEvent(iteratorB); + await nextEvent(iteratorB); + const transportB = transports[1]; + if (!transportB) throw new Error("Fake Claude transport was not created"); + + transportA.planLimit({ fiveHour: { utilizationPercent: 33 } }); + await nextEvent(iteratorA); + + // Session B never observed a rate-limit event itself, but reads the same account-wide value. + expect(adapter.credits()).toEqual({ usedPercent: 33, periodType: "five_hour" }); + + transportA.finish({ status: "succeeded" }); + await nextEvent(iteratorA); + await nextEvent(iteratorA); + transportB.finish({ status: "succeeded" }); + await nextEvent(iteratorB); + await nextEvent(iteratorB); + await sessionA.close(); + await sessionB.close(); + }); + + it("falls back to the cached value when the live Transport has no fresher answer", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("refresh-turn")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + + transport.planLimit({ fiveHour: { utilizationPercent: 55 } }); + await nextEvent(iterator); + + // The fake Transport's on-demand pull (`planLimitOnDemand`) is unset, so this still tries a + // real fetch through the open Session and only falls back once that comes back empty. + await expect(adapter.refreshCredits()).resolves.toEqual(adapter.credits()); + expect(transport.getPlanLimit).toHaveBeenCalledOnce(); + + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + await nextEvent(iterator); + await session.close(); + }); + + it("pulls fresh plan usage through an open Session's live Transport and republishes the Thread's own Usage too", async () => { + const { adapter, transports } = fixture(); + const session = await openSession(adapter); + const iterator = session.outputs[Symbol.asyncIterator](); + + await session.execute(textTurn("pull-turn")); + await nextEvent(iterator); + await nextEvent(iterator); + await nextEvent(iterator); + const transport = transports[0]; + if (!transport) throw new Error("Fake Claude transport was not created"); + transport.finish({ status: "succeeded" }); + await nextEvent(iterator); + await nextEvent(iterator); + + transport.planLimitOnDemand = { + fiveHour: { utilizationPercent: 71, resetsAtUnix: 1_787_674_200 }, + sevenDay: { utilizationPercent: 22 }, + }; + + await expect(adapter.refreshCredits()).resolves.toEqual({ + usedPercent: 71, + periodType: "five_hour", + resetsAt: new Date(1_787_674_200 * 1000).toISOString(), + productUsage: [{ product: "7-day window", usagePercent: 22 }], + }); + expect(transport.getPlanLimit).toHaveBeenCalledOnce(); + + // Same pull, routed through `#handlePlanLimit` like the passive push, also lands in this + // Thread's own Usage snapshot — not only the Adapter-wide credits() cache. + expect(await nextEvent(iterator)).toEqual({ + type: "session.usage.changed", + usage: { + planFiveHourUsedPercent: 71, + planFiveHourResetsAtUnix: 1_787_674_200, + planSevenDayUsedPercent: 22, + }, + }); + await session.close(); + }); + + it("returns the cached value without erroring when no Session is open to pull through", async () => { + const { adapter } = fixture(); + await expect(adapter.refreshCredits()).resolves.toBeNull(); + }); + + it("tries each open Session in turn until one answers", async () => { + const { adapter, transports } = fixture(); + const sessionA = await openSession(adapter); + const iteratorA = sessionA.outputs[Symbol.asyncIterator](); + await sessionA.execute(textTurn("session-a")); + await nextEvent(iteratorA); + await nextEvent(iteratorA); + await nextEvent(iteratorA); + const transportA = transports[0]; + if (!transportA) throw new Error("Fake Claude transport was not created"); + transportA.finish({ status: "succeeded" }); + await nextEvent(iteratorA); + await nextEvent(iteratorA); + + const sessionB = await openSession(adapter); + const iteratorB = sessionB.outputs[Symbol.asyncIterator](); + await sessionB.execute(textTurn("session-b")); + await nextEvent(iteratorB); + await nextEvent(iteratorB); + await nextEvent(iteratorB); + const transportB = transports[1]; + if (!transportB) throw new Error("Fake Claude transport was not created"); + transportB.finish({ status: "succeeded" }); + await nextEvent(iteratorB); + await nextEvent(iteratorB); + + // A has no answer (planLimitOnDemand unset); B does — the loop must not give up after A. + transportB.planLimitOnDemand = { fiveHour: { utilizationPercent: 12 } }; + + await expect(adapter.refreshCredits()).resolves.toEqual({ + usedPercent: 12, + periodType: "five_hour", + }); + expect(transportA.getPlanLimit).toHaveBeenCalledOnce(); + expect(transportB.getPlanLimit).toHaveBeenCalledOnce(); + + await nextEvent(iteratorB); // this Thread's own Usage, republished alongside the pull + await sessionA.close(); + await sessionB.close(); + }); + it("reuses one Transport and Native Session for sequential Turns", async () => { const { adapter, dependencies, transports } = fixture(); const session = await openSession(adapter); @@ -3784,6 +4266,7 @@ describe("Claude Code HarnessAdapter", () => { throw new ClaudeCodeExecutableError("Claude Code is not installed"); }, getContextUsage: async () => null, + getPlanLimit: async () => null, getPermissionMode: () => "default", setModel: async () => undefined, setThinkingOption: async () => undefined, diff --git a/packages/adapters/claude-code/test/native-message.test.ts b/packages/adapters/claude-code/test/native-message.test.ts index b8360b12..850de43f 100644 --- a/packages/adapters/claude-code/test/native-message.test.ts +++ b/packages/adapters/claude-code/test/native-message.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ClaudeNativeTurnAccumulator } from "../src/native-message.js"; +import { ClaudeNativeTurnAccumulator, parseClaudePlanLimitEvent } from "../src/native-message.js"; function partial(text: string, uuid = "assistant-1", parentToolUseId: string | null = null) { return { @@ -980,4 +980,155 @@ describe("Claude native Turn interpretation", () => { ]); expect(turn.consume(result()).terminal).toEqual({ status: "succeeded" }); }); + + it("extracts Session cost, per-model totals, and last-request cache Usage from the Result", () => { + const turn = new ClaudeNativeTurnAccumulator(); + const consumed = turn.consume( + result({ + total_cost_usd: 1.373, + modelUsage: { + "claude-opus": { inputTokens: 100, outputTokens: 40 }, + "claude-sonnet": { inputTokens: 20, outputTokens: 5 }, + }, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 990, + output_tokens: 45, + }, + }), + ); + expect(consumed.events).toEqual([ + { + type: "usage.result", + totalCostUsd: 1.373, + modelUsage: [ + { inputTokens: 100, outputTokens: 40 }, + { inputTokens: 20, outputTokens: 5 }, + ], + lastRequestUsage: { + inputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 990, + }, + }, + ]); + expect(consumed.terminal).toEqual({ status: "succeeded" }); + }); + + it("omits modelUsage entirely when any per-model entry is malformed", () => { + const turn = new ClaudeNativeTurnAccumulator(); + const consumed = turn.consume( + result({ + total_cost_usd: 0.2, + modelUsage: { "claude-opus": { inputTokens: 100, outputTokens: -1 } }, + }), + ); + expect(consumed.events).toEqual([{ type: "usage.result", totalCostUsd: 0.2 }]); + }); + + it("omits lastRequestUsage when any last-request cache field is missing", () => { + const turn = new ClaudeNativeTurnAccumulator(); + const consumed = turn.consume( + result({ + total_cost_usd: 0.2, + usage: { input_tokens: 10, cache_read_input_tokens: 990 }, + }), + ); + expect(consumed.events).toEqual([{ type: "usage.result", totalCostUsd: 0.2 }]); + }); + + it("does not emit a usage.result event when the Result carries no reliable Usage", () => { + const turn = new ClaudeNativeTurnAccumulator(); + expect(turn.consume(result()).events).toEqual([]); + }); + + it("parses both plan windows from a real Claude Code unifiedWindows payload", () => { + // Captured verbatim from a live `rate_limit_event` (Claude.ai OAuth session). + // `utilization` is a 0-1 fraction here, not the 0-100 percent the SDK's + // `.d.ts` comment implies, and both windows arrive on one event. + expect( + parseClaudePlanLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + resetsAt: 1_787_674_200, + rateLimitType: "five_hour", + overageStatus: "rejected", + overageDisabledReason: "org_level_disabled", + isUsingOverage: false, + unifiedWindows: { + five_hour: { utilization: 0.28, resetsAt: 1_787_674_200 }, + seven_day: { utilization: 0.1, resetsAt: 1_787_940_000 }, + }, + }, + uuid: "0c8be4f7-8525-42a3-ae6c-5393a4b6861e", + session_id: "121c08f6-2ddf-4250-9e51-bea9c39b554b", + }), + ).toEqual({ + fiveHour: { utilizationPercent: 28, resetsAtUnix: 1_787_674_200 }, + sevenDay: { utilizationPercent: 10, resetsAtUnix: 1_787_940_000 }, + }); + }); + + it("parses a single unifiedWindows entry when only one window is present", () => { + expect( + parseClaudePlanLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { unifiedWindows: { seven_day: { utilization: 0.125 } } }, + }), + ).toEqual({ sevenDay: { utilizationPercent: 12.5 } }); + }); + + it("ignores per-model unifiedWindows breakdowns and overage", () => { + expect( + parseClaudePlanLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { + unifiedWindows: { + seven_day_opus: { utilization: 0.9 }, + seven_day_sonnet: { utilization: 0.4 }, + overage: { utilization: 0.1 }, + }, + }, + }), + ).toBeNull(); + }); + + it("falls back to a flat top-level window when unifiedWindows is absent", () => { + expect( + parseClaudePlanLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { status: "allowed", rateLimitType: "five_hour", utilization: 0.452 }, + }), + ).toEqual({ fiveHour: { utilizationPercent: 45.2 } }); + }); + + it.each([ + { type: "rate_limit_event", rate_limit_info: { rateLimitType: "overage", utilization: 0.5 } }, + { type: "rate_limit_event", rate_limit_info: { rateLimitType: "five_hour" } }, + { + type: "rate_limit_event", + rate_limit_info: { rateLimitType: "five_hour", utilization: -0.1 }, + }, + { + type: "rate_limit_event", + rate_limit_info: { rateLimitType: "five_hour", utilization: Number.NaN }, + }, + { type: "rate_limit_event", rate_limit_info: null }, + { type: "assistant" }, + ])("ignores untracked or malformed plan-limit payloads %#", (message) => { + expect(parseClaudePlanLimitEvent(message)).toBeNull(); + }); + + it("drops a plan-window reset that is not a safe non-negative integer", () => { + expect( + parseClaudePlanLimitEvent({ + type: "rate_limit_event", + rate_limit_info: { + unifiedWindows: { five_hour: { utilization: 0.45, resetsAt: -1 } }, + }, + }), + ).toEqual({ fiveHour: { utilizationPercent: 45 } }); + }); }); diff --git a/packages/adapters/claude-code/test/sdk-transport.test.ts b/packages/adapters/claude-code/test/sdk-transport.test.ts index 2766ddcd..a22dd761 100644 --- a/packages/adapters/claude-code/test/sdk-transport.test.ts +++ b/packages/adapters/claude-code/test/sdk-transport.test.ts @@ -28,11 +28,21 @@ class FakeQuery { ], })); readonly interrupt = vi.fn(async () => undefined); - readonly getContextUsage = vi.fn(async () => ({ - totalTokens: 40, - maxTokens: 200, - model: "runtime-model", - })); + readonly getContextUsage = vi.fn( + async (): Promise<{ + totalTokens: number; + maxTokens: number; + model: string; + apiUsage?: unknown; + }> => ({ + totalTokens: 40, + maxTokens: 200, + model: "runtime-model", + }), + ); + readonly usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET = vi.fn( + async (): Promise => ({ rate_limits_available: false, rate_limits: null }), + ); readonly setModel = vi.fn(async () => undefined); readonly applyFlagSettings = vi.fn(async () => undefined); readonly setPermissionMode = vi.fn(async () => undefined); @@ -80,6 +90,7 @@ function fixture( }); const onFault = vi.fn(); const onPermissionModeChanged = vi.fn(); + const onPlanLimit = vi.fn(); const transport = new ClaudeSdkTransport({ command: process.execPath, ...(environment ? { environment } : {}), @@ -91,12 +102,14 @@ function fixture( closeTimeoutMs: 100, onPermissionModeChanged, onFault, + onPlanLimit, queryFactory, }); return { fakeQuery, onFault, onPermissionModeChanged, + onPlanLimit, queryFactory, queryInput: () => { if (!queryInput) throw new Error("SDK query was not created"); @@ -194,6 +207,179 @@ describe("ClaudeSdkTransport context Usage", () => { await expect(value.transport.getContextUsage()).rejects.toThrow("context unavailable"); await value.transport.close(); }); + + it("includes valid apiUsage and omits it when malformed", async () => { + const value = fixture(); + await value.transport.start(); + + value.fakeQuery.getContextUsage.mockResolvedValueOnce({ + totalTokens: 40, + maxTokens: 200, + model: "runtime-model", + apiUsage: { + input_tokens: 10, + output_tokens: 45, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 990, + }, + }); + await expect(value.transport.getContextUsage()).resolves.toEqual({ + usedTokens: 40, + maxTokens: 200, + model: "runtime-model", + apiUsage: { + inputTokens: 10, + outputTokens: 45, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 990, + }, + }); + + value.fakeQuery.getContextUsage.mockResolvedValueOnce({ + totalTokens: 40, + maxTokens: 200, + model: "runtime-model", + apiUsage: { input_tokens: -1, output_tokens: 45 }, + }); + await expect(value.transport.getContextUsage()).resolves.toEqual({ + usedTokens: 40, + maxTokens: 200, + model: "runtime-model", + }); + + value.fakeQuery.getContextUsage.mockResolvedValueOnce({ + totalTokens: 40, + maxTokens: 200, + model: "runtime-model", + apiUsage: null, + }); + await expect(value.transport.getContextUsage()).resolves.toEqual({ + usedTokens: 40, + maxTokens: 200, + model: "runtime-model", + }); + await value.transport.close(); + }); +}); + +describe("ClaudeSdkTransport plan-limit forwarding", () => { + it("forwards a tracked rate-limit event regardless of active Turn state", async () => { + const value = fixture(); + await value.transport.start(); + + value.fakeQuery.push({ + type: "rate_limit_event", + rate_limit_info: { + status: "allowed", + rateLimitType: "five_hour", + unifiedWindows: { + five_hour: { utilization: 0.45, resetsAt: 1_787_674_200 }, + seven_day: { utilization: 0.1, resetsAt: 1_787_940_000 }, + }, + }, + uuid: "00000000-0000-4000-8000-000000000099", + session_id: "00000000-0000-4000-8000-000000000001", + } as unknown as SDKMessage); + await vi.waitFor(() => expect(value.onPlanLimit).toHaveBeenCalledOnce()); + expect(value.onPlanLimit).toHaveBeenCalledWith({ + fiveHour: { utilizationPercent: 45, resetsAtUnix: 1_787_674_200 }, + sevenDay: { utilizationPercent: 10, resetsAtUnix: 1_787_940_000 }, + }); + await value.transport.close(); + }); + + it("does not forward an untracked rate-limit type", async () => { + const value = fixture(); + await value.transport.start(); + + const events: ClaudeTurnEvent[] = []; + const turn = value.transport.runTurn( + "synthetic", + "00000000-0000-4000-8000-000000000023", + (event) => events.push(event), + ); + value.fakeQuery.push({ + type: "rate_limit_event", + rate_limit_info: { status: "allowed", rateLimitType: "overage", utilization: 45 }, + uuid: "00000000-0000-4000-8000-000000000099", + session_id: "00000000-0000-4000-8000-000000000001", + } as unknown as SDKMessage); + completeTurn(value.fakeQuery); + await turn; + expect(value.onPlanLimit).not.toHaveBeenCalled(); + await value.transport.close(); + }); +}); + +describe("ClaudeSdkTransport plan-limit pull", () => { + it("returns null before the transport has started", async () => { + const value = fixture(); + await expect(value.transport.getPlanLimit()).resolves.toBeNull(); + expect( + value.fakeQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET, + ).not.toHaveBeenCalled(); + }); + + it("pulls both plan windows on demand from the /usage control channel", async () => { + const value = fixture(); + await value.transport.start(); + + value.fakeQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET.mockResolvedValueOnce( + { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: 62, resets_at: "2026-08-25T16:10:00.000Z" }, + seven_day: { utilization: 18, resets_at: "2026-08-28T18:00:00.000Z" }, + }, + }, + ); + await expect(value.transport.getPlanLimit()).resolves.toEqual({ + fiveHour: { utilizationPercent: 62, resetsAtUnix: 1_787_674_200 }, + sevenDay: { utilizationPercent: 18, resetsAtUnix: 1_787_940_000 }, + }); + await value.transport.close(); + }); + + it("returns null for an API-key Session where plan limits do not apply", async () => { + const value = fixture(); + await value.transport.start(); + + value.fakeQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET.mockResolvedValueOnce( + { rate_limits_available: false, rate_limits: null }, + ); + await expect(value.transport.getPlanLimit()).resolves.toBeNull(); + await value.transport.close(); + }); + + it("omits a window with an invalid utilization instead of failing the whole answer", async () => { + const value = fixture(); + await value.transport.start(); + + value.fakeQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET.mockResolvedValueOnce( + { + rate_limits_available: true, + rate_limits: { + five_hour: { utilization: null, resets_at: null }, + seven_day: { utilization: 18, resets_at: null }, + }, + }, + ); + await expect(value.transport.getPlanLimit()).resolves.toEqual({ + sevenDay: { utilizationPercent: 18 }, + }); + await value.transport.close(); + }); + + it("propagates a control-channel failure to the caller", async () => { + const value = fixture(); + await value.transport.start(); + + value.fakeQuery.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET.mockRejectedValueOnce( + new Error("usage query unavailable"), + ); + await expect(value.transport.getPlanLimit()).rejects.toThrow("usage query unavailable"); + await value.transport.close(); + }); }); describe("ClaudeSdkTransport text reconciliation", () => { @@ -902,6 +1088,7 @@ describe("ClaudeSdkTransport Model control", () => { closeTimeoutMs: 100, onPermissionModeChanged: value.onPermissionModeChanged, onFault: value.onFault, + onPlanLimit: value.onPlanLimit, queryFactory: value.queryFactory, }); diff --git a/packages/harness-adapter/src/usage.ts b/packages/harness-adapter/src/usage.ts index 043c24ea..c4c45abf 100644 --- a/packages/harness-adapter/src/usage.ts +++ b/packages/harness-adapter/src/usage.ts @@ -10,6 +10,10 @@ export interface HostUsage { cacheHitRatePercent?: number; contextWindowTokens?: number; contextUsedTokens?: number; + planFiveHourUsedPercent?: number; + planFiveHourResetsAtUnix?: number; + planSevenDayUsedPercent?: number; + planSevenDayResetsAtUnix?: number; } const tokenFields = [ @@ -24,10 +28,22 @@ const tokenFields = [ "contextUsedTokens", ] as const satisfies ReadonlyArray; +const safeIntegerFields = [ + "planFiveHourResetsAtUnix", + "planSevenDayResetsAtUnix", +] as const satisfies ReadonlyArray; + +const percentFields = [ + "cacheHitRatePercent", + "planFiveHourUsedPercent", + "planSevenDayUsedPercent", +] as const satisfies ReadonlyArray; + const usageFields = new Set([ ...tokenFields, + ...safeIntegerFields, + ...percentFields, "totalCostUsd", - "cacheHitRatePercent", ]); function isRecord(value: unknown): value is Record { @@ -52,6 +68,15 @@ export function parseHostUsage(value: unknown): HostUsage { throw new Error(`Harness Usage '${field}' must be a non-negative safe integer`); } } + for (const field of safeIntegerFields) { + const candidate = value[field]; + if ( + candidate !== undefined && + (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate < 0) + ) { + throw new Error(`Harness Usage '${field}' must be a non-negative safe integer`); + } + } if ( value.outputTokensPerSecond !== undefined && (typeof value.outputTokensPerSecond !== "number" || @@ -68,14 +93,17 @@ export function parseHostUsage(value: unknown): HostUsage { ) { throw new Error("Harness Usage 'totalCostUsd' must be a finite non-negative number"); } - if ( - value.cacheHitRatePercent !== undefined && - (typeof value.cacheHitRatePercent !== "number" || - !Number.isFinite(value.cacheHitRatePercent) || - value.cacheHitRatePercent < 0 || - value.cacheHitRatePercent > 100) - ) { - throw new Error("Harness Usage 'cacheHitRatePercent' must be between 0 and 100"); + for (const field of percentFields) { + const candidate = value[field]; + if ( + candidate !== undefined && + (typeof candidate !== "number" || + !Number.isFinite(candidate) || + candidate < 0 || + candidate > 100) + ) { + throw new Error(`Harness Usage '${field}' must be between 0 and 100`); + } } const hasContextUsed = value.contextUsedTokens !== undefined; const hasContextWindow = value.contextWindowTokens !== undefined; @@ -85,5 +113,21 @@ export function parseHostUsage(value: unknown): HostUsage { if (hasContextWindow && value.contextWindowTokens === 0) { throw new Error("Harness Usage 'contextWindowTokens' must be greater than zero"); } + if ( + value.planFiveHourResetsAtUnix !== undefined && + value.planFiveHourUsedPercent === undefined + ) { + throw new Error( + "Harness Usage 'planFiveHourResetsAtUnix' must be provided with 'planFiveHourUsedPercent'", + ); + } + if ( + value.planSevenDayResetsAtUnix !== undefined && + value.planSevenDayUsedPercent === undefined + ) { + throw new Error( + "Harness Usage 'planSevenDayResetsAtUnix' must be provided with 'planSevenDayUsedPercent'", + ); + } return { ...value } as HostUsage; } diff --git a/packages/harness-adapter/test/usage.test.ts b/packages/harness-adapter/test/usage.test.ts index 137312f4..326569ff 100644 --- a/packages/harness-adapter/test/usage.test.ts +++ b/packages/harness-adapter/test/usage.test.ts @@ -38,4 +38,33 @@ describe("Harness Usage", () => { ])("rejects invalid snapshots %#", (input) => { expect(() => parseHostUsage(input)).toThrow(); }); + + it("accepts optional Claude.ai plan windows alongside cache hit rate and cost", () => { + const input = { + cacheHitRatePercent: 99, + totalCostUsd: 1.373, + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + planSevenDayUsedPercent: 12.5, + }; + expect(parseHostUsage(input)).toEqual(input); + }); + + it("accepts a plan used percent with no reset", () => { + expect(parseHostUsage({ planFiveHourUsedPercent: 45 })).toEqual({ + planFiveHourUsedPercent: 45, + }); + }); + + it.each([ + { planFiveHourResetsAtUnix: 1_756_130_400 }, + { planSevenDayResetsAtUnix: 1_756_130_400 }, + { planFiveHourUsedPercent: -0.1 }, + { planFiveHourUsedPercent: 100.1 }, + { planSevenDayUsedPercent: Number.NaN }, + { planFiveHourResetsAtUnix: -1, planFiveHourUsedPercent: 45 }, + { planFiveHourResetsAtUnix: 1.5, planFiveHourUsedPercent: 45 }, + ])("rejects invalid plan-window snapshots %#", (input) => { + expect(() => parseHostUsage(input)).toThrow(); + }); }); diff --git a/packages/host-runtime/test/app-server-host.claude.real.test.ts b/packages/host-runtime/test/app-server-host.claude.real.test.ts index 8e05c954..c5a09ad6 100644 --- a/packages/host-runtime/test/app-server-host.claude.real.test.ts +++ b/packages/host-runtime/test/app-server-host.claude.real.test.ts @@ -162,6 +162,7 @@ describe("AppServerHost hermetic Claude projection", () => { maxTokens: 200, model: "hermetic-model", }), + getPlanLimit: async () => null, getPermissionMode: () => permissionMode, setModel: async () => undefined, setThinkingOption: async () => undefined, diff --git a/packages/host-runtime/test/app-server-host.test.ts b/packages/host-runtime/test/app-server-host.test.ts index 33f0dd65..ba8ca8e1 100644 --- a/packages/host-runtime/test/app-server-host.test.ts +++ b/packages/host-runtime/test/app-server-host.test.ts @@ -2168,6 +2168,65 @@ describe("AppServerHost HarnessAdapter projection", () => { await stopFixture(fixture); }); + it("round-trips Claude.ai plan-window fields through Thread Usage inspection without writing accountCredits", async () => { + const claudeAdapter = new FakeHarnessAdapter(harnessIdSchema.parse("claude-code")); + const fixture = createFixture({ + externalAdapters: new Map([ + ["claude-code", claudeAdapter], + ]), + }); + const claudeThreadId = await startExternalThread( + fixture, + CLAUDE_CODE_NATIVE_TRANSPORT_MODEL_ID, + 70, + ); + const claudeTurnId = await completePiTurn( + { ...fixture, adapter: claudeAdapter }, + claudeThreadId, + 71, + 0, + ); + claudeAdapter.sessions[0]?.publishUsage( + { + cacheHitRatePercent: 99, + totalCostUsd: 1.373, + contextUsedTokens: 50, + contextWindowTokens: 200, + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + }, + hostTurnIdSchema.parse(claudeTurnId), + ); + await fixture.collector.waitFor( + (message) => + method(message, "thread/tokenUsage/updated") && + messageParams(message).threadId === claudeThreadId, + ); + + writeRequest(fixture.desktopInput, { + id: 72, + method: "codexhost/thread/usage/inspect", + params: { threadId: claudeThreadId }, + }); + await expect( + fixture.collector.waitFor((message) => requestId(message, 72)), + ).resolves.toEqual({ + id: 72, + result: { + threadId: claudeThreadId, + usage: { + cacheHitRatePercent: 99, + totalCostUsd: 1.373, + contextUsedTokens: 50, + contextWindowTokens: 200, + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + }, + }, + }); + await stopFixture(fixture); + }); + it("forks external inclusive, exclusive, and tail boundaries without reusing Host Turn IDs", async () => { const fixture = createFixture(); const officialWrite = vi.fn(); diff --git a/packages/protocol-core/test/codex-usage.test.ts b/packages/protocol-core/test/codex-usage.test.ts index 21b66bac..438ce052 100644 --- a/packages/protocol-core/test/codex-usage.test.ts +++ b/packages/protocol-core/test/codex-usage.test.ts @@ -118,6 +118,46 @@ describe("Codex Thread Usage projection", () => { expect(usage).not.toHaveProperty("totalTokens"); }); + it("ignores Claude.ai plan-window fields in the native context carrier", () => { + expect( + projectCodexThreadUsage({ + threadId: "thread-plan-window", + turnId: hostTurnIdSchema.parse("turn-plan-window"), + usage: { + contextUsedTokens: 35, + contextWindowTokens: 200, + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + }, + }), + ).toEqual({ + method: "thread/tokenUsage/updated", + params: { + threadId: "thread-plan-window", + turnId: "turn-plan-window", + tokenUsage: { + total: { + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + }, + last: { + totalTokens: 35, + inputTokens: 35, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + }, + modelContextWindow: 200, + }, + }, + }); + }); + it("omits Usage without a reliable Host Turn", () => { expect( projectCodexThreadUsage({ diff --git a/packages/renderer-extension/src/renderer-binding-probe.ts b/packages/renderer-extension/src/renderer-binding-probe.ts index c0e52377..5940fac3 100644 --- a/packages/renderer-extension/src/renderer-binding-probe.ts +++ b/packages/renderer-extension/src/renderer-binding-probe.ts @@ -107,6 +107,15 @@ export function rendererUsageRefreshDelay(attempt: number): number { return rendererUsageRefreshDelays[index] ?? rendererUsageRefreshDelays[0]; } +/** + * Agents whose Harness Adapter implements `credits()`/`refreshCredits()` — the only ones where + * it's worth retrying purely to pick up Account Credits after Usage has already arrived. Every + * other Agent would retry forever at the backoff ceiling for a value it can never produce. + */ +function externalAgentHasAccountCredits(agent: RendererAgent): boolean { + return agent === "grok" || agent === "claude-code"; +} + export function shouldRetryExternalThreadUsage( agent: RendererAgent, usage: ThreadUsageSnapshot | null, @@ -114,7 +123,7 @@ export function shouldRetryExternalThreadUsage( ): boolean { if (agent === "codex") return false; if (usage === null) return true; - return agent === "grok" && accountCredits === null; + return externalAgentHasAccountCredits(agent) && accountCredits === null; } export function shouldReloadExternalCatalogAfterAvailabilityRefresh( @@ -644,7 +653,10 @@ export function installRendererBindingProbe( mounted.usage = result.usage; mounted.accountCredits = result.accountCredits ?? null; const agent = controller.get(mounted.composer).agent; - if (result.usage !== null && (agent !== "grok" || result.accountCredits)) { + if ( + result.usage !== null && + (!externalAgentHasAccountCredits(agent) || result.accountCredits) + ) { usageRefreshAttempts.delete(mounted.composer); } renderMounted(mounted); diff --git a/packages/renderer-extension/src/renderer-credits-control.ts b/packages/renderer-extension/src/renderer-credits-control.ts index 2065b28c..5d37e61d 100644 --- a/packages/renderer-extension/src/renderer-credits-control.ts +++ b/packages/renderer-extension/src/renderer-credits-control.ts @@ -1,7 +1,11 @@ import type { AccountCreditsSnapshot } from "@codexhost/shared-contracts"; import { RENDERER_MODEL_TRIGGER_FALLBACK_CLASSES } from "./renderer-model-picker.js"; -import { formatRendererCreditsPercent } from "./renderer-usage-control.js"; +import { + applyRendererPopoverChrome, + createRendererUsageRing, + formatRendererCreditsPercent, +} from "./renderer-usage-control.js"; export interface RendererCreditsControl { root: HTMLDivElement; @@ -21,11 +25,25 @@ export function rendererCreditsTone(usedPercent: number): RendererCreditsTone { return "ok"; } -export function formatRendererCreditsReset(value: string): string { +/** + * A same-day reset reads as a precise time ("4:12 PM today") — the moment is + * imminent and worth being exact about. Every other reset — tomorrow, or a + * full week out — still carries its exact time alongside the date ("Aug 28, + * 6:00 PM"): the source data is precise to the minute for both the 5-hour + * and 7-day windows, so the display never throws that away. + */ +export function formatRendererCreditsReset(value: string, now: Date = new Date()): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + if (isToday) { + return `${date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })} today`; + } return date.toLocaleString(undefined, { - month: "long", + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", @@ -35,6 +53,8 @@ export function formatRendererCreditsReset(value: string): string { export function creditsPeriodLabel(periodType: AccountCreditsSnapshot["periodType"]): string { if (periodType === "weekly") return "Weekly limit"; if (periodType === "monthly") return "Monthly limit"; + if (periodType === "five_hour") return "5-hour limit"; + if (periodType === "seven_day") return "7-day limit"; return "Account limit"; } @@ -52,41 +72,112 @@ function toneColor(tone: RendererCreditsTone): string { return "#3d9a64"; } -function addDetailRow(parent: HTMLElement, label: string, value: string): void { - const row = document.createElement("div"); - row.style.display = "grid"; - row.style.gridTemplateColumns = "minmax(0, 1fr) auto"; - row.style.gap = "20px"; - row.style.padding = "4px 0"; - const labelElement = document.createElement("span"); - labelElement.textContent = label; - labelElement.style.color = "color-mix(in srgb, currentColor 68%, transparent)"; - const valueElement = document.createElement("span"); - valueElement.textContent = value; - valueElement.style.fontVariantNumeric = "tabular-nums"; - valueElement.style.textAlign = "right"; - row.append(labelElement, valueElement); - parent.append(row); +function renderCreditsBar(usagePercent: number, color: string): HTMLDivElement { + const track = document.createElement("div"); + track.dataset.codexhostCreditsBar = ""; + track.style.height = "6px"; + track.style.borderRadius = "9999px"; + track.style.background = "color-mix(in srgb, currentColor 16%, transparent)"; + track.style.overflow = "hidden"; + const fill = document.createElement("span"); + fill.style.display = "block"; + fill.style.height = "100%"; + fill.style.borderRadius = "9999px"; + fill.style.width = `${Math.min(100, Math.max(0, usagePercent))}%`; + fill.style.background = color; + track.append(fill); + return track; } -function renderDetails(popover: HTMLDivElement, credits: AccountCreditsSnapshot): void { - popover.replaceChildren(); - const heading = document.createElement("div"); - heading.textContent = creditsPeriodLabel(credits.periodType); - heading.style.fontWeight = "600"; - heading.style.marginBottom = "6px"; - popover.append(heading); - addDetailRow(popover, "Used", formatRendererCreditsPercent(credits.usedPercent)); +function renderCreditsHeader(credits: AccountCreditsSnapshot): HTMLDivElement { + const wrapper = document.createElement("div"); + wrapper.style.marginBottom = "11px"; + + const top = document.createElement("div"); + top.style.display = "flex"; + top.style.alignItems = "flex-start"; + top.style.justifyContent = "space-between"; + top.style.gap = "12px"; + top.style.marginBottom = "5px"; + + // Same left-label / right-percent column order as each tile below, so the + // reset line always lands under its own label instead of zig-zagging sides. + const left = document.createElement("div"); + const label = document.createElement("div"); + label.textContent = creditsPeriodLabel(credits.periodType); + label.style.fontSize = "12.5px"; + label.style.fontWeight = "600"; + left.append(label); if (credits.resetsAt) { - addDetailRow(popover, "Resets", formatRendererCreditsReset(credits.resetsAt)); + const reset = document.createElement("div"); + reset.textContent = `resets ${formatRendererCreditsReset(credits.resetsAt)}`; + reset.style.fontSize = "11px"; + reset.style.color = "color-mix(in srgb, currentColor 62%, transparent)"; + left.append(reset); } - for (const product of credits.productUsage ?? []) { - addDetailRow( - popover, - productLabel(product.product), - formatRendererCreditsPercent(product.usagePercent), - ); + + const color = toneColor(rendererCreditsTone(credits.usedPercent)); + const percent = document.createElement("span"); + percent.textContent = formatRendererCreditsPercent(credits.usedPercent); + percent.style.fontSize = "26px"; + percent.style.fontWeight = "700"; + percent.style.fontVariantNumeric = "tabular-nums"; + percent.style.color = color; + + top.append(left, percent); + + wrapper.append(top, renderCreditsBar(credits.usedPercent, color)); + return wrapper; +} + +function renderCreditsTile(label: string, usagePercent: number, resetsAt?: string): HTMLDivElement { + const color = toneColor(rendererCreditsTone(usagePercent)); + + const tile = document.createElement("div"); + tile.style.marginBottom = "11px"; + + const top = document.createElement("div"); + top.style.display = "flex"; + top.style.alignItems = "flex-start"; + top.style.justifyContent = "space-between"; + top.style.gap = "12px"; + top.style.marginBottom = "5px"; + + const left = document.createElement("div"); + const name = document.createElement("span"); + name.textContent = label; + name.style.fontSize = "12px"; + left.append(name); + if (resetsAt) { + const reset = document.createElement("div"); + reset.textContent = `resets ${formatRendererCreditsReset(resetsAt)}`; + reset.style.fontSize = "10.5px"; + reset.style.color = "color-mix(in srgb, currentColor 62%, transparent)"; + left.append(reset); } + + const percent = document.createElement("span"); + percent.textContent = formatRendererCreditsPercent(usagePercent); + percent.style.fontSize = "12px"; + percent.style.fontVariantNumeric = "tabular-nums"; + percent.style.color = color; + top.append(left, percent); + + tile.append(top, renderCreditsBar(usagePercent, color)); + return tile; +} + +function renderDetails(popover: HTMLDivElement, credits: AccountCreditsSnapshot): void { + const glowColor = toneColor(rendererCreditsTone(credits.usedPercent)); + popover.style.backgroundImage = `radial-gradient(160px 100px at 18% -10%, color-mix(in srgb, ${glowColor} 20%, transparent), transparent 70%)`; + popover.replaceChildren(); + popover.append(renderCreditsHeader(credits)); + const tiles = (credits.productUsage ?? []).map((product) => + renderCreditsTile(productLabel(product.product), product.usagePercent, product.resetsAt), + ); + const lastTile = tiles.at(-1); + if (lastTile) lastTile.style.marginBottom = "0"; + popover.append(...tiles); } function popoverIsOpen(popover: HTMLDivElement): boolean { @@ -164,14 +255,10 @@ export function mountRendererCreditsControl( trigger.style.whiteSpace = "nowrap"; trigger.style.cursor = "pointer"; - const dot = document.createElement("span"); - dot.dataset.codexhostCreditsDot = ""; - dot.setAttribute("aria-hidden", "true"); - dot.style.display = "inline-block"; - dot.style.width = "7px"; - dot.style.height = "7px"; - dot.style.borderRadius = "9999px"; - dot.style.flex = "0 0 auto"; + const ringSlot = document.createElement("span"); + ringSlot.dataset.codexhostCreditsRing = ""; + ringSlot.style.display = "inline-flex"; + ringSlot.style.flex = "0 0 auto"; const label = document.createElement("span"); label.dataset.codexhostCreditsLabel = ""; @@ -180,7 +267,7 @@ export function mountRendererCreditsControl( label.style.overflow = "hidden"; label.style.textOverflow = "ellipsis"; label.style.whiteSpace = "nowrap"; - trigger.append(dot, label); + trigger.append(ringSlot, label); const popover = document.createElement("div"); popover.id = `${composerId}-credits-popover`; @@ -193,11 +280,7 @@ export function mountRendererCreditsControl( popover.style.width = "240px"; popover.style.maxWidth = "min(280px, calc(100vw - 24px))"; popover.style.padding = "10px 12px"; - popover.style.border = "1px solid rgba(127, 127, 127, 0.35)"; - popover.style.borderRadius = "6px"; - popover.style.background = "Canvas"; - popover.style.color = "CanvasText"; - popover.style.boxShadow = "0 8px 24px rgba(0, 0, 0, 0.28)"; + applyRendererPopoverChrome(popover); popover.style.font = "13px/1.35 system-ui, sans-serif"; popover.style.letterSpacing = "0"; popover.style.zIndex = "2147483647"; @@ -284,9 +367,17 @@ export function renderRendererCreditsControl( const percent = formatRendererCreditsPercent(accountCredits.usedPercent); const title = `${creditsPeriodLabel(accountCredits.periodType)} ${percent}`; const tone = rendererCreditsTone(accountCredits.usedPercent); - const dot = control.trigger.querySelector("[data-codexhost-credits-dot]"); + const ringSlot = control.trigger.querySelector("[data-codexhost-credits-ring]"); const label = control.trigger.querySelector("[data-codexhost-credits-label]"); - if (dot) dot.style.background = toneColor(tone); + if (ringSlot) { + ringSlot.replaceChildren( + createRendererUsageRing(accountCredits.usedPercent, { + size: 14, + strokeWidth: 2.4, + color: toneColor(tone), + }), + ); + } if (label) label.textContent = percent; control.root.style.display = "inline-flex"; control.trigger.setAttribute("aria-label", title); diff --git a/packages/renderer-extension/src/renderer-usage-control.ts b/packages/renderer-extension/src/renderer-usage-control.ts index 6e8caca7..2ec26ab0 100644 --- a/packages/renderer-extension/src/renderer-usage-control.ts +++ b/packages/renderer-extension/src/renderer-usage-control.ts @@ -40,10 +40,99 @@ export function formatRendererCreditsPercent(value: number): string { return `${decimal(value, 1)}%`; } +export interface RendererUsageRingOptions { + size: number; + strokeWidth: number; + color: string; + trackColor?: string; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** A small radial progress ring (0-100), used by the credits/plan-usage pills and popovers. */ +export function createRendererUsageRing( + percent: number, + options: RendererUsageRingOptions, +): SVGSVGElement { + const { size, strokeWidth, color } = options; + const trackColor = options.trackColor ?? "color-mix(in srgb, currentColor 18%, transparent)"; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const clamped = Math.min(100, Math.max(0, percent)); + const offset = circumference * (1 - clamped / 100); + const center = size / 2; + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("width", String(size)); + svg.setAttribute("height", String(size)); + svg.setAttribute("viewBox", `0 0 ${size} ${size}`); + svg.setAttribute("aria-hidden", "true"); + svg.style.display = "block"; + svg.style.flex = "0 0 auto"; + svg.style.transform = "rotate(-90deg)"; + + const track = document.createElementNS(SVG_NS, "circle"); + track.setAttribute("cx", String(center)); + track.setAttribute("cy", String(center)); + track.setAttribute("r", String(radius)); + track.setAttribute("fill", "none"); + track.setAttribute("stroke", trackColor); + track.setAttribute("stroke-width", String(strokeWidth)); + + const fill = document.createElementNS(SVG_NS, "circle"); + fill.setAttribute("cx", String(center)); + fill.setAttribute("cy", String(center)); + fill.setAttribute("r", String(radius)); + fill.setAttribute("fill", "none"); + fill.setAttribute("stroke", color); + fill.setAttribute("stroke-width", String(strokeWidth)); + fill.setAttribute("stroke-linecap", "round"); + fill.setAttribute("stroke-dasharray", String(circumference)); + fill.setAttribute("stroke-dashoffset", String(offset)); + + svg.append(track, fill); + return svg; +} + +export function formatRendererPlanReset(unixSeconds: number): string { + const date = new Date(unixSeconds * 1000); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString(undefined, { + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function formatRendererPlanWindow(usedPercent: number, resetsAtUnix?: number): string { + const percent = formatRendererCreditsPercent(usedPercent); + if (resetsAtUnix === undefined) return percent; + const reset = formatRendererPlanReset(resetsAtUnix); + return reset.length > 0 ? `${percent} · ${reset}` : percent; +} + export function rendererUsageTriggerMaxWidth(): string { return "min(180px, 30vw)"; } +/** + * Shared chrome for the Usage/Credits popovers: a raised card rather than a + * flat system-color panel. `Canvas`/`CanvasText` still anchor the palette (so + * this reads correctly regardless of the host page's own light/dark theme), + * but `color-mix` lifts the surface a touch off the background and the + * border, so the popover reads as an elevated card instead of the browser's + * bare default panel. + */ +export function applyRendererPopoverChrome(popover: HTMLElement): void { + popover.style.border = "1px solid color-mix(in srgb, CanvasText 14%, transparent)"; + popover.style.borderRadius = "14px"; + popover.style.backgroundColor = "color-mix(in srgb, Canvas 92%, CanvasText 8%)"; + popover.style.color = "CanvasText"; + popover.style.boxShadow = + "0 20px 45px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.22)"; +} + function addDetailRow(parent: HTMLElement, label: string, value: string): void { const row = document.createElement("div"); row.style.display = "grid"; @@ -108,6 +197,20 @@ function renderDetails(popover: HTMLDivElement, usage: ThreadUsageSnapshot | nul `${formatRendererTokenCount(usage.inputTokens ?? 0)} / ${formatRendererTokenCount(usage.outputTokens ?? 0)}`, ); } + if (usage?.planFiveHourUsedPercent !== undefined) { + addDetailRow( + popover, + "5-hour limit", + formatRendererPlanWindow(usage.planFiveHourUsedPercent, usage.planFiveHourResetsAtUnix), + ); + } + if (usage?.planSevenDayUsedPercent !== undefined) { + addDetailRow( + popover, + "7-day limit", + formatRendererPlanWindow(usage.planSevenDayUsedPercent, usage.planSevenDayResetsAtUnix), + ); + } if (usage?.totalCostUsd !== undefined) { addDetailRow(popover, "Session cost estimate", formatRendererCost(usage.totalCostUsd)); } @@ -206,11 +309,7 @@ export function mountRendererUsageControl( popover.style.width = "260px"; popover.style.maxWidth = "min(320px, calc(100vw - 24px))"; popover.style.padding = "10px 12px"; - popover.style.border = "1px solid rgba(127, 127, 127, 0.35)"; - popover.style.borderRadius = "6px"; - popover.style.background = "Canvas"; - popover.style.color = "CanvasText"; - popover.style.boxShadow = "0 8px 24px rgba(0, 0, 0, 0.28)"; + applyRendererPopoverChrome(popover); popover.style.font = "13px/1.35 system-ui, sans-serif"; popover.style.letterSpacing = "0"; popover.style.zIndex = "2147483647"; diff --git a/packages/renderer-extension/test/renderer-binding-probe.test.ts b/packages/renderer-extension/test/renderer-binding-probe.test.ts index 95146681..fc124dde 100644 --- a/packages/renderer-extension/test/renderer-binding-probe.test.ts +++ b/packages/renderer-extension/test/renderer-binding-probe.test.ts @@ -67,6 +67,17 @@ describe("Renderer Composer DOM behavior", () => { }, ), ).toBe(false); + // Same "keep retrying for Account Credits" carve-out Grok gets: without it, revisiting a + // Claude Code Thread whose Usage was already known (so the initial retry never engaged) + // would leave the credits pill permanently blank. + expect(shouldRetryExternalThreadUsage("claude-code", { totalCostUsd: 0.168 })).toBe(true); + expect( + shouldRetryExternalThreadUsage( + "claude-code", + { totalCostUsd: 0.168 }, + { usedPercent: 62, periodType: "five_hour" }, + ), + ).toBe(false); expect(rendererUsageRefreshDelay(0)).toBe(250); expect(rendererUsageRefreshDelay(1)).toBe(500); expect(rendererUsageRefreshDelay(99)).toBe(8000); diff --git a/packages/renderer-extension/test/renderer-credits-control.test.ts b/packages/renderer-extension/test/renderer-credits-control.test.ts index fd0972b1..717b2cbb 100644 --- a/packages/renderer-extension/test/renderer-credits-control.test.ts +++ b/packages/renderer-extension/test/renderer-credits-control.test.ts @@ -20,6 +20,43 @@ describe("Renderer credits control", () => { expect(formatRendererCreditsPercent(47)).toBe("47%"); expect(creditsPeriodLabel("weekly")).toBe("Weekly limit"); expect(creditsPeriodLabel("monthly")).toBe("Monthly limit"); + expect(creditsPeriodLabel("five_hour")).toBe("5-hour limit"); + expect(creditsPeriodLabel("seven_day")).toBe("7-day limit"); + expect(creditsPeriodLabel("unknown")).toBe("Account limit"); expect(formatRendererCreditsReset("not-a-date")).toBe("not-a-date"); }); + + it("formats a same-day reset as a precise time and every other reset as a dated time", () => { + // Built with the local Date constructor throughout (never a bare UTC ISO string against a + // separately-computed "now") so the "same calendar day" check holds regardless of the + // machine's own timezone. + const now = new Date(2026, 7, 25, 12, 0, 0); + const sameDayReset = new Date(2026, 7, 25, 16, 12, 0); + const nextWeekReset = new Date(2026, 8, 5, 18, 0, 0); + + expect(formatRendererCreditsReset(sameDayReset.toISOString(), now)).toBe( + `${sameDayReset.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })} today`, + ); + expect(formatRendererCreditsReset(nextWeekReset.toISOString(), now)).toBe( + nextWeekReset.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + ); + }); + + it("keeps the time when a near-term reset has crossed local midnight", () => { + const now = new Date(2026, 7, 25, 23, 0, 0); + const justAfterMidnight = new Date(2026, 7, 26, 0, 10, 0); + expect(formatRendererCreditsReset(justAfterMidnight.toISOString(), now)).toBe( + justAfterMidnight.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + ); + }); }); diff --git a/packages/renderer-extension/test/renderer-usage-control.test.ts b/packages/renderer-extension/test/renderer-usage-control.test.ts new file mode 100644 index 00000000..0292fdba --- /dev/null +++ b/packages/renderer-extension/test/renderer-usage-control.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { + formatRendererPlanReset, + formatRendererPlanWindow, +} from "../src/renderer-usage-control.js"; + +describe("Renderer Usage plan-window formatting", () => { + it("formats a used percent with no reset", () => { + expect(formatRendererPlanWindow(45)).toBe("45%"); + }); + + it("formats a used percent with a localized reset time", () => { + const formatted = formatRendererPlanWindow(45, 1_756_130_400); + expect(formatted.startsWith("45%")).toBe(true); + expect(formatted).toContain("·"); + }); + + it("formats an invalid reset timestamp as an empty string", () => { + expect(formatRendererPlanReset(Number.NaN)).toBe(""); + }); +}); diff --git a/packages/shared-contracts/src/thread-usage.ts b/packages/shared-contracts/src/thread-usage.ts index 55d7fbbd..7c825d26 100644 --- a/packages/shared-contracts/src/thread-usage.ts +++ b/packages/shared-contracts/src/thread-usage.ts @@ -19,6 +19,10 @@ export const threadUsageSnapshotSchema = z cacheHitRatePercent: cacheHitRatePercentSchema.optional(), contextWindowTokens: nonNegativeSafeIntegerSchema.optional(), contextUsedTokens: nonNegativeSafeIntegerSchema.optional(), + planFiveHourUsedPercent: cacheHitRatePercentSchema.optional(), + planFiveHourResetsAtUnix: nonNegativeSafeIntegerSchema.optional(), + planSevenDayUsedPercent: cacheHitRatePercentSchema.optional(), + planSevenDayResetsAtUnix: nonNegativeSafeIntegerSchema.optional(), }) .strict() .superRefine((usage, context) => { @@ -41,6 +45,20 @@ export const threadUsageSnapshotSchema = z path: ["contextWindowTokens"], }); } + if (usage.planFiveHourResetsAtUnix !== undefined && usage.planFiveHourUsedPercent === undefined) { + context.addIssue({ + code: "custom", + message: "Thread Usage planFiveHourResetsAtUnix must be provided with planFiveHourUsedPercent", + path: ["planFiveHourResetsAtUnix"], + }); + } + if (usage.planSevenDayResetsAtUnix !== undefined && usage.planSevenDayUsedPercent === undefined) { + context.addIssue({ + code: "custom", + message: "Thread Usage planSevenDayResetsAtUnix must be provided with planSevenDayUsedPercent", + path: ["planSevenDayResetsAtUnix"], + }); + } }); export type ThreadUsageSnapshot = z.infer; @@ -51,6 +69,7 @@ export const accountCreditsProductUsageSchema = z .object({ product: z.string().min(1), usagePercent: usagePercentSchema, + resetsAt: z.string().min(1).optional(), }) .strict(); @@ -58,7 +77,7 @@ export const accountCreditsSnapshotSchema = z .object({ usedPercent: usagePercentSchema, resetsAt: z.string().min(1).optional(), - periodType: z.enum(["weekly", "monthly", "unknown"]), + periodType: z.enum(["weekly", "monthly", "five_hour", "seven_day", "unknown"]), productUsage: z.array(accountCreditsProductUsageSchema).min(1).optional(), }) .strict(); diff --git a/packages/shared-contracts/test/thread-usage.test.ts b/packages/shared-contracts/test/thread-usage.test.ts index 9cac9478..1dd3187b 100644 --- a/packages/shared-contracts/test/thread-usage.test.ts +++ b/packages/shared-contracts/test/thread-usage.test.ts @@ -50,4 +50,32 @@ describe("Thread Usage contracts", () => { threadUsageSnapshotSchema.safeParse({ totalCostUsd: 0.1, nativeCost: 0.2 }).success, ).toBe(false); }); + + it("accepts optional Claude.ai plan windows and passes them through inspection", () => { + const usage = { + cacheHitRatePercent: 99, + totalCostUsd: 1.373, + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + }; + expect(threadUsageSnapshotSchema.parse(usage)).toEqual(usage); + expect(threadUsageInspectionSchema.parse({ threadId: "thread-usage", usage })).toEqual({ + threadId: "thread-usage", + usage, + }); + }); + + it("accepts a seven-day window without a five-hour window", () => { + const usage = { planSevenDayUsedPercent: 12.5 }; + expect(threadUsageSnapshotSchema.parse(usage)).toEqual(usage); + }); + + it.each([ + { planFiveHourResetsAtUnix: 1_756_130_400 }, + { planSevenDayResetsAtUnix: 1_756_130_400 }, + { planFiveHourUsedPercent: 100.1 }, + { planFiveHourResetsAtUnix: -1, planFiveHourUsedPercent: 45 }, + ])("rejects invalid plan-window snapshots: %#", (usage) => { + expect(threadUsageSnapshotSchema.safeParse(usage).success).toBe(false); + }); }); diff --git a/tests/e2e/renderer-usage.spec.ts b/tests/e2e/renderer-usage.spec.ts index 1a6f518d..a715eeb2 100644 --- a/tests/e2e/renderer-usage.spec.ts +++ b/tests/e2e/renderer-usage.spec.ts @@ -56,7 +56,23 @@ const { outputFiles } = await build({ }; globalThis.updateRendererCreditsUsage = () => { renderRendererUsageControl(usage, { cacheHitRatePercent: 99.3, totalCostUsd: 0.822 }); - renderRendererCreditsControl(credits, { usedPercent: 47, periodType: "weekly" }); + renderRendererCreditsControl(credits, { + usedPercent: 47, + periodType: "weekly", + resetsAt: "2026-08-31T16:00:00.000Z", + productUsage: [ + { product: "GrokBuild", usagePercent: 82, resetsAt: "2027-03-15T10:00:00.000Z" }, + { product: "GrokChat", usagePercent: 45 }, + ], + }); + }; + globalThis.updateRendererUsagePlanWindow = () => { + renderRendererUsageControl(usage, { + cacheHitRatePercent: 99, + totalCostUsd: 1.373, + planFiveHourUsedPercent: 45, + planFiveHourResetsAtUnix: 1_756_130_400, + }); }; }; `, @@ -168,7 +184,7 @@ test("keeps Usage in place and shows credits after the leading composer control" await expect(credits).toBeVisible(); await expect(credits).toHaveText("47%"); await expect(credits.locator("button")).toHaveAttribute("aria-label", "Weekly limit 47%"); - await expect(credits.locator("[data-codexhost-credits-dot]")).toHaveCount(1); + await expect(credits.locator("[data-codexhost-credits-ring] svg")).toHaveCount(1); await expect(trigger).toHaveCSS("max-width", "180px"); await expect(credits.locator("xpath=preceding-sibling::*[1]")).toHaveAttribute( "aria-label", @@ -192,4 +208,37 @@ test("keeps Usage in place and shows credits after the leading composer control" await expect(popover).toBeVisible(); await expect(popover).toContainText("Weekly limit"); await expect(popover).toContainText("47%"); + await expect(popover).toContainText("Build"); + await expect(popover).toContainText("82%"); + await expect(popover).toContainText("Chat"); + // The headline percent gets its own progress bar too, alongside each product's. + await expect(popover.locator("[data-codexhost-credits-bar]")).toHaveCount(3); + // One "resets" line under the headline, one under the Build tile (Chat has none). + await expect(popover.getByText("resets", { exact: false })).toHaveCount(2); +}); + +test("shows a Claude.ai five-hour plan window only in the Usage popover", async ({ page }) => { + await page.setContent(''); + await page.addScriptTag({ content: browserBundle }); + await page.evaluate(() => { + const setup = Reflect.get(globalThis, "setupRendererUsage"); + if (typeof setup !== "function") throw new Error("Usage setup is unavailable"); + setup(); + const update = Reflect.get(globalThis, "updateRendererUsagePlanWindow"); + if (typeof update !== "function") throw new Error("Plan window update is unavailable"); + update(); + }); + + const usage = page.locator('[data-codexhost-usage-control="usage-composer"]'); + await expect(usage).toBeVisible(); + await expect(usage).toHaveText("CH 99% · $1.373"); + await expect(usage).not.toContainText("5-hour"); + await expect(usage).not.toContainText("45%"); + + await usage.hover(); + const popover = page.locator('[role="dialog"][aria-label="Thread Usage details"]'); + await expect(popover).toBeVisible(); + await expect(popover).toContainText("5-hour limit"); + await expect(popover).toContainText("45%"); + await expect(popover).not.toContainText("7-day limit"); });