diff --git a/apps/cli/src/agent/lody-acp-extension.test.ts b/apps/cli/src/agent/lody-acp-extension.test.ts new file mode 100644 index 000000000..a8b30f0c1 --- /dev/null +++ b/apps/cli/src/agent/lody-acp-extension.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { LODY_EXTENSION_METHODS } from 'acp-extension-core'; +import { parseRateLimitsSnapshot, parseLodyExtensionMessage } from './lody-acp-extension'; + +describe('rate-limit window labels', () => { + const window = { usedPercent: 0, windowDurationSeconds: 604_800, resetsAtEpochSeconds: null }; + const snapshot = { + rateLimits: [ + { + limitId: 'claude', + scope: { providerId: 'claude' }, + windows: [window, { ...window, label: 'Fable' }], + }, + ], + }; + + it('preserves same-duration labeled windows in query responses', () => { + expect(parseRateLimitsSnapshot(snapshot)).toEqual(snapshot); + }); + + it('preserves labels in proactive updates', () => { + expect( + parseLodyExtensionMessage({ + method: LODY_EXTENSION_METHODS.rateLimitsUpdate, + params: snapshot, + provider: 'claude', + sessionId: 'synthetic-session', + }) + ).toEqual({ type: 'rateLimits', snapshot }); + }); +}); diff --git a/apps/cli/src/agent/lody-acp-extension.ts b/apps/cli/src/agent/lody-acp-extension.ts index c98e1bbfd..97b88fd41 100644 --- a/apps/cli/src/agent/lody-acp-extension.ts +++ b/apps/cli/src/agent/lody-acp-extension.ts @@ -55,6 +55,7 @@ const SessionUsageUpdateSchema = z.object({ }); const RateLimitWindowSchema = z.object({ + label: z.string().optional(), usedPercent: z.number().min(0).max(100), windowDurationSeconds: z.number().nonnegative().nullable(), resetsAtEpochSeconds: z.number().int().positive().nullable(), diff --git a/locales/en.json b/locales/en.json index 8a35b7b50..d9d0ce135 100644 --- a/locales/en.json +++ b/locales/en.json @@ -846,6 +846,7 @@ "machines.rateLimits.resetUnknown": "Reset time unknown", "machines.rateLimits.resetsAt": "Resets {{time}}", "machines.rateLimits.sevenDay": "7 days usage", + "machines.rateLimits.namedWindow": "{{duration}} · {{label}}", "machines.rateLimits.unlabeled": "Unlabeled limit", "machines.rateLimits.usedPercent": "{{percent}}% used", "machines.resourceStatus": "Resource Status", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index e77a97fe8..730278567 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -846,6 +846,7 @@ "machines.rateLimits.resetUnknown": "重置时间未知", "machines.rateLimits.resetsAt": "重置于{{time}}", "machines.rateLimits.sevenDay": "7 天用量", + "machines.rateLimits.namedWindow": "{{duration}} · {{label}}", "machines.rateLimits.unlabeled": "未命名额度", "machines.rateLimits.usedPercent": "已使用 {{percent}}%", "machines.resourceStatus": "资源状态", diff --git a/packages/acp-extension-claude b/packages/acp-extension-claude index d395b3dc6..414718e52 160000 --- a/packages/acp-extension-claude +++ b/packages/acp-extension-claude @@ -1 +1 @@ -Subproject commit d395b3dc69832c6566eb0da84a08486d16ba1e69 +Subproject commit 414718e5238a7ed5da0ff23bec31bff4450ffa5f diff --git a/packages/acp-extension-core b/packages/acp-extension-core index 23c792b91..7bc6332d3 160000 --- a/packages/acp-extension-core +++ b/packages/acp-extension-core @@ -1 +1 @@ -Subproject commit 23c792b910a903b74601e346473827106f991715 +Subproject commit 7bc6332d3f007876895b4a3a827be0060f4d5318 diff --git a/packages/components/src/components/sessions/session-usage-popover.tsx b/packages/components/src/components/sessions/session-usage-popover.tsx index 60d3e4976..614dc4aa4 100644 --- a/packages/components/src/components/sessions/session-usage-popover.tsx +++ b/packages/components/src/components/sessions/session-usage-popover.tsx @@ -19,6 +19,7 @@ import { cn } from '@/lib/utils'; import { FIVE_HOUR_WINDOW_SECONDS, SEVEN_DAY_WINDOW_SECONDS, + formatAgentRateLimitWindowLabel, formatRateLimitWindowShortLabel, getAgentRateLimitWindows, getContextWindowUsageData, @@ -185,7 +186,11 @@ export const SessionUsagePopover = memo(function SessionUsagePopover({ rateLimitWindows.map((window, index) => ( diff --git a/packages/components/src/components/settings/machine-quota-compact.tsx b/packages/components/src/components/settings/machine-quota-compact.tsx index 4fa77e680..780573e6c 100644 --- a/packages/components/src/components/settings/machine-quota-compact.tsx +++ b/packages/components/src/components/settings/machine-quota-compact.tsx @@ -15,6 +15,7 @@ import { cn } from '@/lib/utils'; import { FIVE_HOUR_WINDOW_SECONDS, SEVEN_DAY_WINDOW_SECONDS, + formatAgentRateLimitWindowLabel, formatRateLimitWindowShortLabel, getAgentRateLimitWindows, } from '@/lib/session-usage'; @@ -121,8 +122,8 @@ export function MachineQuotaCompact({ raceLimits, filterCliType }: MachineQuotaC return ( ( ))} diff --git a/packages/components/src/lib/AGENTS.md b/packages/components/src/lib/AGENTS.md index 340ce1a54..996967679 100644 --- a/packages/components/src/lib/AGENTS.md +++ b/packages/components/src/lib/AGENTS.md @@ -51,6 +51,11 @@ the href parser. It needs a third kind that strips roots without decoding. ## ACP dispatch +Rate-limit window labels are provider-supplied scope names, displayed alongside +localized durations through `formatAgentRateLimitWindowLabel`. Preserve every +window even when durations, utilization, and resets match: a model weekly +sub-cap and the shared weekly pool are concurrent constraints, not alternatives. + Before creating a top-level or child session, call `filterAcpSessionConfigOptionValues()` so cached values outside the current selector schema are not dispatched or persisted again. diff --git a/packages/components/src/lib/session-usage.ts b/packages/components/src/lib/session-usage.ts index 497cc8078..e7ec1db2e 100644 --- a/packages/components/src/lib/session-usage.ts +++ b/packages/components/src/lib/session-usage.ts @@ -9,6 +9,7 @@ import { type SessionContextWindowUsage, } from '@lody/shared'; import { clamp } from './clamp'; +import type { TFunction } from 'i18next'; export const FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60; export const SEVEN_DAY_WINDOW_SECONDS = 7 * 24 * 60 * 60; @@ -32,6 +33,7 @@ export type ContextWindowUsageData = { }; export type AgentRateLimitWindow = { + label?: string; usedPercent: number; remainingPercent: number; windowDurationSeconds: number | null; @@ -56,6 +58,7 @@ export function getAgentRateLimitWindows(limits: MachineRateLimitUsage): AgentRa if (usedPercent === null) return []; return [ { + ...(window.label?.trim() ? { label: window.label.trim() } : {}), usedPercent, remainingPercent: clampPercentage(100 - usedPercent), windowDurationSeconds: @@ -79,6 +82,19 @@ export function formatRateLimitWindowShortLabel(windowDurationSeconds: number | return `${windowDurationSeconds / 60}m`; } +export function formatAgentRateLimitWindowLabel( + window: AgentRateLimitWindow, + durationLabel: string, + t: TFunction +): string { + return window.label + ? t('machines.rateLimits.namedWindow', '{{duration}} · {{label}}', { + duration: durationLabel, + label: window.label, + }) + : durationLabel; +} + export function getContextWindowUsageData( usage: SessionContextWindowUsage | null | undefined ): ContextWindowUsageData | null { diff --git a/packages/components/src/stories/ProviderRow.stories.tsx b/packages/components/src/stories/ProviderRow.stories.tsx index 91c18901e..e78ae6967 100644 --- a/packages/components/src/stories/ProviderRow.stories.tsx +++ b/packages/components/src/stories/ProviderRow.stories.tsx @@ -90,6 +90,12 @@ export const ClaudeWithRateLimit: Story = { windowDurationSeconds: 604_800, resetsAtEpochSeconds: resetIn(86_400), }, + { + label: 'Fable', + usedPercent: 67, + windowDurationSeconds: 604_800, + resetsAtEpochSeconds: resetIn(86_400), + }, ], }, }, diff --git a/packages/components/src/stories/SessionUsagePopover.stories.tsx b/packages/components/src/stories/SessionUsagePopover.stories.tsx index 10da70762..32ad444a7 100644 --- a/packages/components/src/stories/SessionUsagePopover.stories.tsx +++ b/packages/components/src/stories/SessionUsagePopover.stories.tsx @@ -116,3 +116,33 @@ export const Unavailable: Story = { rateLimits: {}, }, }; + +export const ClaudeFableWeekly: Story = { + args: { + agentType: 'claude', + modelId: 'claude-fable-5', + modelLabel: 'Fable', + showRateLimitWithoutContext: true, + rateLimits: { + [getRateLimitEntryKey('claude', 'claude')]: { + limitId: 'claude', + scope: { providerId: 'claude' }, + planName: 'Max', + windows: [ + { usedPercent: 12, windowDurationSeconds: 18_000, resetsAtEpochSeconds: resetIn(3600) }, + { + usedPercent: 30, + windowDurationSeconds: 604_800, + resetsAtEpochSeconds: resetIn(172800), + }, + { + label: 'Fable', + usedPercent: 67, + windowDurationSeconds: 604_800, + resetsAtEpochSeconds: resetIn(172800), + }, + ], + }, + }, + }, +}; diff --git a/packages/components/tests/session-usage-popover.test.tsx b/packages/components/tests/session-usage-popover.test.tsx index 38263035e..ef41e08f4 100644 --- a/packages/components/tests/session-usage-popover.test.tsx +++ b/packages/components/tests/session-usage-popover.test.tsx @@ -99,6 +99,39 @@ describe('SessionUsagePopover', () => { expect(container.querySelector('button')).toBeNull(); }); + it('shows Fable weekly as a distinct meter beside shared quotas', async () => { + await renderUsage({ + agentType: 'claude', + modelId: 'claude-fable-5', + showRateLimitWithoutContext: true, + rateLimits: { + [getRateLimitEntryKey('claude', 'claude')]: { + limitId: 'claude', + scope: { providerId: 'claude' }, + windows: [ + { usedPercent: 12, windowDurationSeconds: 18_000, resetsAtEpochSeconds: null }, + { usedPercent: 30, windowDurationSeconds: 604_800, resetsAtEpochSeconds: null }, + { + label: 'Fable', + usedPercent: 67, + windowDurationSeconds: 604_800, + resetsAtEpochSeconds: null, + }, + ], + }, + }, + }); + await act(async () => { + container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const popover = document.body.querySelector('[aria-label="Usage"]'); + expect( + Array.from(popover?.querySelectorAll('[role="progressbar"]') ?? []).map((meter) => + meter.getAttribute('aria-label') + ) + ).toEqual(['Weekly: 30% used', 'Weekly · Fable: 67% used', '5 hours: 12% used']); + }); + it('shows a truthful unavailable state when the provider omits utilization', async () => { const unavailableLimits: MachineRateLimits = { [getRateLimitEntryKey('grok', 'grok')]: { diff --git a/packages/components/tests/session-usage.test.ts b/packages/components/tests/session-usage.test.ts index bf0b440a5..c245baf5f 100644 --- a/packages/components/tests/session-usage.test.ts +++ b/packages/components/tests/session-usage.test.ts @@ -84,6 +84,16 @@ describe('session usage', () => { ]); }); + it('preserves scoped windows even when their values match the shared quota', () => { + const weekly = { usedPercent: 0, windowDurationSeconds: 604_800, resetsAtEpochSeconds: null }; + expect( + getAgentRateLimitWindows(usage({ windows: [weekly, { ...weekly, label: ' Fable ' }] })) + ).toEqual([ + { ...weekly, remainingPercent: 100 }, + { ...weekly, remainingPercent: 100, label: 'Fable' }, + ]); + }); + it('keeps sub-one-percent Grok usage on the percentage scale', () => { expect( getAgentRateLimitWindows( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80d3382ed..4190b87e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -611,7 +611,7 @@ importers: specifier: ^1.4.0 version: 1.4.0(zod@4.3.6) '@openai/codex': - specifier: ^0.153.3 + specifier: ^0.153.4 version: 0.153.4 acp-extension-core: specifier: workspace:*