Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? [],
);
Expand Down
9 changes: 4 additions & 5 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? [],
);
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/features/threads/use-composer-command-menu.test.ts
Original file line number Diff line number Diff line change
@@ -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 }),
Expand All @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6449,6 +6449,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
{
name: "usage-limits",
description: "Show this provider's usage limits",
source: "t3",
},
],
},
Expand Down Expand Up @@ -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",
},
],
},
],
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions packages/contracts/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
49 changes: 48 additions & 1 deletion packages/shared/src/usageLimits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions packages/shared/src/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,13 +560,27 @@ 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. */
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<ServerProvider, "driver" | "slashCommands">,
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
Expand Down
Loading