From 3e675e61a43c9a49f1db8abbfbcf72bf92521687 Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:27:16 +0200 Subject: [PATCH] fix(usage): keep cached usage-limits commands local --- .../features/threads/NewTaskDraftScreen.tsx | 8 +-- .../src/features/threads/ThreadComposer.tsx | 9 ++-- .../threads/use-composer-command-menu.test.ts | 25 ++++++++++ apps/server/src/server.test.ts | 7 ++- apps/web/src/components/ChatView.tsx | 7 ++- packages/contracts/src/server.test.ts | 22 +++++++++ packages/contracts/src/server.ts | 2 + packages/shared/src/usageLimits.test.ts | 49 ++++++++++++++++++- packages/shared/src/usageLimits.ts | 14 ++++++ 9 files changed, 128 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 5c05d7482cc4..9472298d7538 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -46,7 +46,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; +import { hasLocalUsageLimitsCommand, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; import { useComposerCommandMenu } from "./use-composer-command-menu"; @@ -319,11 +319,11 @@ export function NewTaskDraftScreen(props: { const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. - // T3 owns /usage-limits only where Limits has data for the selected provider. + // A cached local command stays local while its limits data reconnects. const offersUsageLimits = flow.selectedProviderStatus !== null && - hasProviderUsageLimits( - flow.selectedProviderStatus.driver, + hasLocalUsageLimitsCommand( + flow.selectedProviderStatus, selectedEnvironmentServerConfig?.providers ?? [], selectedEnvironmentServerConfig?.usageLimitSources ?? [], ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 684b69c843a5..a1dd147c4f19 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -11,7 +11,7 @@ import type { } from "@t3tools/contracts"; import { collectProviderUsageLimits, - hasProviderUsageLimits, + hasLocalUsageLimitsCommand, isUsageLimitsCommand, } from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; @@ -281,12 +281,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; - // T3 owns /usage-limits only where Limits has data for the selected provider; - // elsewhere the name stays the provider's own and is sent through untouched. + // A cached local command stays local while its limits data reconnects. const usageLimitsOffered = selectedProviderStatus !== null && - hasProviderUsageLimits( - selectedProviderStatus.driver, + hasLocalUsageLimitsCommand( + selectedProviderStatus, props.serverConfig?.providers ?? [], props.serverConfig?.usageLimitSources ?? [], ); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 4c92325f5fbd..75dfae6e832e 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { hasLocalUsageLimitsCommand, USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; vi.mock("../../state/queries", () => ({ useComposerPathSearch: () => ({ entries: [], isPending: false }), @@ -17,6 +18,30 @@ import { } from "./use-composer-command-menu"; describe("mobile slash commands", () => { + it.each([true, false])( + "keeps cached local=%s usage commands out of New Task without hiding provider commands", + (local) => { + const selected = { + driver: ProviderDriverKind.make("codex"), + slashCommands: [ + local + ? USAGE_LIMITS_COMMAND + : { name: USAGE_LIMITS_COMMAND.name, description: USAGE_LIMITS_COMMAND.description }, + ], + }; + const items = buildComposerSlashCommandItems({ + query: "usage-limits", + atMessageStart: true, + hasThread: false, + allowInteractionMode: true, + offersUsageLimits: hasLocalUsageLimitsCommand(selected, [], []), + selectedProviderStatus: selected, + }); + + expect(items.map((item) => item.label)).toEqual(local ? [] : ["/usage-limits"]); + }, + ); + const antigravity = { driver: ProviderDriverKind.make("antigravity"), showInteractionModeToggle: false, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2f32b6524d7b..2e3a74dd4f5a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6449,6 +6449,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { { name: "usage-limits", description: "Show this provider's usage limits", + source: "t3", }, ], }, @@ -6591,7 +6592,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { { ...codex, slashCommands: [ - { name: "usage-limits", description: "Show this provider's usage limits" }, + { + name: "usage-limits", + description: "Show this provider's usage limits", + source: "t3", + }, ], }, ], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..e9daab294bc6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,7 +2,7 @@ import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment" import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { collectProviderUsageLimits, - hasProviderUsageLimits, + hasLocalUsageLimitsCommand, isUsageLimitsCommand, } from "@t3tools/shared/usageLimits"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; @@ -2864,11 +2864,10 @@ export default function ChatView(props: ChatViewProps) { : null, [environmentId, usageLimitsPanel, usageLimitsReport], ); - // T3 owns /usage-limits only where Limits has data for the selected provider; - // elsewhere the name stays the provider's own and is sent through untouched. + // A cached local command stays local while its limits data reconnects. const usageLimitsOffered = activeProviderStatus !== null && - hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources); + hasLocalUsageLimitsCommand(activeProviderStatus, providerStatuses, usageLimitSources); // Answered locally from the last Limits snapshot; the agent never sees it. const openUsageLimits = useCallback(() => { const now = Date.now(); diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 00bb5daa5b86..8bd0e286b1bc 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -29,6 +29,28 @@ const baseProviderSnapshot = { }; describe("ServerProvider", () => { + it("preserves local command ownership when decoding a saved provider catalog", () => { + const slashCommands = [ + { name: "usage-limits", description: "Show limits", source: "t3" }, + { name: "usage", description: "Provider command" }, + ]; + const parsed = decodeServerProvider({ + ...baseProviderSnapshot, + slashCommands, + workspaceSnapshots: [ + { + cwd: "/tmp/project", + checkedAt: baseProviderSnapshot.checkedAt, + slashCommands, + skills: [], + }, + ], + }); + + expect(parsed.slashCommands).toEqual(slashCommands); + expect(parsed.workspaceSnapshots?.[0]?.slashCommands).toEqual(slashCommands); + }); + it("defaults capability arrays when decoding provider snapshots", () => { const parsed = decodeServerProvider({ instanceId: "codex", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 2f171d37df85..259d29d9a0e3 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -89,6 +89,8 @@ export const ServerProviderSlashCommand = Schema.Struct({ name: TrimmedNonEmptyString, description: Schema.optional(TrimmedNonEmptyString), input: Schema.optional(ServerProviderSlashCommandInput), + /** T3 handles this command locally, even before its data is available. */ + source: Schema.optional(Schema.Literal("t3")), }); export type ServerProviderSlashCommand = typeof ServerProviderSlashCommand.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b814e66da459..f4df0a08ebbe 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -2,14 +2,17 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, - type ServerProvider, + ServerProvider, type UsageLimitSourceAccount, UsageLimitSourceId, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { type LimitAccount, + hasLocalUsageLimitsCommand, + hasProviderUsageLimits, isUsageLimitsCommand, collectProviderUsageLimits, sameUsageLimitCommandCoverage, @@ -27,6 +30,7 @@ import { remainingPercent, } from "./usageLimits.ts"; +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); const now = Date.parse("2026-09-03T12:00:00.000Z"); const window = { @@ -1196,3 +1200,46 @@ describe("isUsageLimitsCommand", () => { expect(isUsageLimitsCommand("/usage")).toBe(false); }); }); + +describe("hasLocalUsageLimitsCommand", () => { + const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }; + const sources = [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-09-03T11:00:00.000Z", + accounts: [ + { + id: "account", + driver: ProviderDriverKind.make("codex"), + usageLimits: limits, + }, + ], + }, + ]; + + it("keeps a saved source-only command local without inventing a limits report", () => { + const [advertised] = withUsageLimitsCommands([provider({})], sources); + const cached = decodeServerProvider(JSON.parse(JSON.stringify(advertised))); + + expect(hasProviderUsageLimits(cached.driver, [cached], [])).toBe(false); + expect(hasLocalUsageLimitsCommand(cached, [cached], [])).toBe(true); + expect(collectProviderUsageLimits(cached.instanceId, [cached], [], now)).toBeNull(); + }); + + it("does not claim a provider command with the same name and description", () => { + const native = provider({ + slashCommands: [{ name: "usage-limits", description: "Show this provider's usage limits" }], + }); + expect(hasLocalUsageLimitsCommand(native, [native], [])).toBe(false); + }); + + it("keeps data-based ownership for older servers without command origins", () => { + const legacy = provider({ slashCommands: [{ name: "usage-limits" }] }); + expect(hasLocalUsageLimitsCommand(legacy, [legacy], sources)).toBe(true); + expect(hasLocalUsageLimitsCommand(legacy, [legacy], [])).toBe(false); + const native = provider({ usageLimits: limits }); + expect(hasLocalUsageLimitsCommand(native, [native], [])).toBe(true); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5c32cc0343b7..d059a423bd85 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -560,6 +560,7 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): export const USAGE_LIMITS_COMMAND = { name: "usage-limits", description: "Show this provider's usage limits", + source: "t3", } satisfies ServerProviderSlashCommand; /** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ @@ -567,6 +568,19 @@ export function isUsageLimitsCommand(prompt: string): boolean { return prompt.trim().toLowerCase() === "/usage-limits"; } +/** Cached catalogs retain ownership while source data replays. Older servers omit the source. */ +export function hasLocalUsageLimitsCommand( + provider: Pick, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): boolean { + return ( + provider.slashCommands.some( + (command) => command.name === USAGE_LIMITS_COMMAND.name && command.source === "t3", + ) || hasProviderUsageLimits(provider.driver, providers, sources) + ); +} + /** * Whether Limits has anything to say about this driver. A source that failed to * read keeps no accounts, so its error counts for every driver rather than