Skip to content
Merged
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
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
Expand Up @@ -183,6 +183,18 @@ describe("mobile model mentions", () => {
};
};

it("matches Claude models by their short name", () => {
const opus = option("claude:claude-opus", {
label: "Claude flagship",
shortName: "Opus",
providerDriver: "claudeAgent",
providerLabel: "Claude",
});
expect(buildComposerModelItems([opus], "opus", [provider("claude")])).toEqual([
expect.objectContaining({ option: opus }),
]);
});

it("replaces a model mention with prompt text and places the cursor after it", () => {
const modelOption = option("codex:gpt-5.4");
expect(
Expand Down Expand Up @@ -232,4 +244,17 @@ describe("mobile model mentions", () => {
] as const)("does not suggest a preserved selection whose provider is %s", (_, providers) => {
expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]);
});

it("suggests models from a provider whose health probe only warns", () => {
const opus = option("claude:claude-opus", {
label: "Claude Opus",
providerDriver: "claudeAgent",
providerLabel: "Claude",
});
expect(
buildComposerModelItems([opus], "opus", [
provider("claude", { status: "warning", auth: { status: "unknown" } }),
]),
).toEqual([expect.objectContaining({ option: opus })]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ export function buildComposerModelItems(
.filter(
(provider) =>
provider.installed &&
provider.status === "ready" &&
provider.status !== "error" &&
provider.status !== "disabled" &&
provider.auth.status !== "unauthenticated" &&
provider.availability !== "unavailable",
)
Expand All @@ -229,6 +230,7 @@ export function buildComposerModelItems(
query,
(option) => ({
name: option.label,
...(option.shortName ? { shortName: option.shortName } : {}),
...(option.subtitle ? { subProvider: option.subtitle } : {}),
driverKind: option.providerDriver,
providerDisplayName: option.providerLabel,
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,43 @@ describe("synchronized mobile model visibility", () => {
});
});

describe("mobile unavailable provider model options", () => {
it.each([
["signed out", { auth: { status: "unauthenticated" as const } }],
["not checked yet", { installed: false }],
])("keeps an enabled %s provider listed with unavailable models", (_state, probe) => {
const config = {
providers: [
{
instanceId: "claudeAgent",
driver: "claudeAgent",
enabled: true,
installed: true,
auth: { status: "authenticated" as const },
...probe,
models: [
{ slug: "claude-opus", name: "Claude Opus", isCustom: false, capabilities: null },
],
},
],
} as unknown as ServerConfig;

const options = buildModelOptions(config, null, undefined, null, "composer");

expect(options).toMatchObject([
{ providerKey: "claudeAgent", providerLabel: "Claude", isUnavailable: true },
]);
expect(
resolveNewTaskModelSelection({
draftSelection: null,
projectDefaultSelection: null,
stickySelection: null,
modelOptions: options,
}),
).toBeNull();
});
});

describe("mobile account pool model options", () => {
const memberId = ProviderInstanceId.make("claudeAgent_work");
const poolId = ProviderInstanceId.make("pool_claudeAgent");
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,12 @@ function modelOptionProviderContext(
(provider) =>
matchesLockedProvider(provider) &&
isProviderEnabledForProject(config, provider, projectId) &&
provider.installed &&
provider.auth.status !== "unauthenticated" &&
!(provider.driver === "antigravity" && provider.availability === "unavailable"),
// Antigravity's catalog only exists once it is set up; other providers stay
// listed as unavailable so a pending or failed probe cannot erase them.
(provider.driver !== "antigravity" ||
(provider.installed &&
provider.auth.status !== "unauthenticated" &&
provider.availability !== "unavailable")),
),
};
}
Expand Down Expand Up @@ -390,6 +393,7 @@ export function buildModelOptions(
continue;
}
const providerLabel = providerDisplayLabel(provider);
const providerUnavailable = !provider.installed || provider.auth.status === "unauthenticated";
const providerBadge = providerBadgeFor(provider.instanceId);
const updateRequired = provider.updateRequiredModels?.length
? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels }
Expand Down Expand Up @@ -420,6 +424,7 @@ export function buildModelOptions(
...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}),
isHidden: !model.isCustom && hiddenModels.has(model.slug),
canHide: !model.isCustom,
...(providerUnavailable ? { isUnavailable: true } : {}),
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
...(updateRequired ? { providerUpdateRequired: updateRequired } : {}),
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/chat/composerModelMentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ describe("matchComposerModelItems", () => {
match("gpt", { instanceEntries: [{ ...traex, status: "error" as const }, opencode] }),
).toEqual(["opencode:traex/gpt-6.1"]);
});

it("keeps providers whose health probe only warns", () => {
expect(match("opus", { instanceEntries: [{ ...claude, status: "warning" as const }] })).toEqual(
["claudeAgent:claude-opus-5-5"],
);
});
});
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/composerModelMentions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { rankComposerModelMentions } from "@t3tools/client-runtime/composerModelMentions";
import type { ProviderInstanceId } from "@t3tools/contracts";

import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances";
import type { ProviderInstanceEntry } from "../../providerInstances";
import type { ComposerCommandItem } from "./ComposerCommandMenu";
import { getDisplayModelName, getProviderRowLabel, type ModelEsque } from "./providerIconUtils";

Expand All @@ -26,7 +26,16 @@ export function matchComposerModelItems(input: {
}): ComposerModelItem[] {
const candidates: Array<{ entry: ComposerModelMentionProvider; option: ModelEsque }> = [];
for (const entry of input.instanceEntries) {
if (!isProviderInstancePickerReady(entry)) continue;
// Mentions only name a model for the agent; a warning probe (which the server
// still delegates through) must not hide the provider the way an error does.
if (
!entry.enabled ||
!entry.isAvailable ||
entry.status === "error" ||
entry.status === "disabled"
) {
continue;
}
for (const option of input.modelOptionsByInstance.get(entry.instanceId) ?? []) {
// The picker hides legacy models by default; skip them so they don't fill the result cap.
if (option.isLegacy || option.isUnavailable) continue;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/testing/providerEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
const PROVIDER_ENV_PREFIXES = ["ANTHROPIC_", "CLAUDE_", "CODEX_", "OPENAI_", "T3CODE_", "T3_"];

const KEPT_ENV = new Set([
// Selects the mobile test platform; the Vite config and platform-branched tests must agree on it.
"T3_MOBILE_TEST_PLATFORM",
// CI sets these so the transfer budget integration test writes its report for the job summary.
"T3CODE_TRANSFER_BUDGET_REPORT_PATH",
"T3CODE_TRANSFER_BUDGET_RESULT_PATH",
Expand Down
Loading