Remove host-model warnings from selector hook
apps/web/components/task-create-dialog-options.tsx ↗The hook now refreshes capability health only and renders one label for both dropdown and trigger.
Before and after
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { t } from "@/lib/i18n";
import { IconAlertTriangle, IconGitBranch, IconTerminal2 } from "@tabler/icons-react";
import { IconGitBranch, IconTerminal2 } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { AvailableAgent } from "@/lib/types/http-agents";
import type { AgentProfileOption } from "@/lib/state/slices";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types";
import {
isSelectableAgentProfile,
refreshProfileCapabilities,
} from "@/lib/state/slices/settings/types";
import { formatUserHomePath, truncateRepoPath } from "@/lib/utils";
import { getExecutorIcon } from "@/lib/executor-icons";
import { AgentLogo } from "@/components/agent-logo";
import { getCapabilityWarning } from "@/lib/capability-warning";
import { findUniqueModelVariation } from "@/lib/model-variation";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
New hook body
export function useAgentProfileOptions(
agentProfiles: AgentProfileOption[],
context?: AgentProfileRecentUseContext,
): OptionItem[] {
const { t } = useTranslation();
// Keep capability discovery alive for every selector surface. The host
// catalog supplies health status only; it never participates in model-ID
// matching or selector eligibility.
const availableAgents = useAvailableAgents();
const dynamicRoutingEnabled = useFeature("dynamicAgentRouting");
const storeApi = useAppStoreApi();
const recentUseLoaded = useAppStore((state) => !context || state.agentProfileRecentUse.loaded);
const recentProfileIds = useAppStore((state) =>
context ? state.agentProfileRecentUse?.records[context]?.profileIds : undefined,
);
useEffect(() => {
if (!context || recentUseLoaded) return;
void ensureAgentProfileRecentUseLoaded(storeApi);
}, [context, recentUseLoaded, storeApi]);
return useMemo(() => {
const profilesWithCapabilities = refreshProfileCapabilities(
agentProfiles,
availableAgents.items,
);
const selectable = profilesWithCapabilities.filter((profile) =>
isSelectableAgentProfile(profile, dynamicRoutingEnabled),
);
const orderedProfiles = context
? orderAgentProfilesByRecentUse(selectable, recentProfileIds)
: selectable;
return orderedProfiles.map((profile: AgentProfileOption) => {
const parts = profile.label.split(" \u2022 ");
const agentLabel = parts[0] ?? profile.label;
const profileLabel = parts[1] ?? "";
const isPassthrough = profile.cli_passthrough === true;
const warning = getCapabilityWarning(profile.capability_status, profile.capability_error);
const renderProfileLabel = () => (
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex shrink-0 items-center justify-between gap-2">
<span className="flex shrink-0 items-center gap-1.5">
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
<span>{agentLabel}</span>
{warning && (
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && (
<IconTerminal2 className="size-3.5 text-muted-foreground" title={t("common:cliModeYourPromptWillBe")} />
)}
{profileLabel ? (
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
{profileLabel}
</ScrollOnOverflow>
) : null}
</span>
</span>
</span>
);
return {
value: profile.id,
label: profile.label,
disabled: undefined,
disabledReason: undefined,
renderLabel: renderProfileLabel,
renderTriggerLabel: renderProfileLabel,
};
});
}, [agentProfiles, availableAgents.items, context, dynamicRoutingEnabled, recentProfileIds, t]);
}
Model-independent selector tests
apps/web/components/task-create-dialog-options.test.tsx ↗Tests now assert that no host-model advisory appears for any catalog shape and that health indicators remain.
New parameterized coverage
describe("useAgentProfileOptions model-independent labels", () => {
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.1
it.each([
["exact", "gpt-5", ["gpt-5"]],
["missing", GONE_MODEL, ["gpt-5"]],
["unique variation", "opus", ["opus[1m]"]],
["multiple variations", "opus", ["opus[1m]", "opus[270k]"]],
["legacy effort IDs", "gpt-6-astra", ["gpt-6-astra[low]", "gpt-6-astra[high]"]],
["bracketed request", "opus[1m]", ["opus[270k]"]],
["empty catalog", GONE_MODEL, []],
["provider default", "", ["gpt-5"]],
])("does not show host model advisories in either label: %s", (_, model, models) => {
setAvailableAgents([{
...AGENT_WITH_GPT,
model_config: {
...AGENT_WITH_GPT.model_config,
available_models: (models as string[]).map((id) => ({ id, name: id })),
},
}]);
const profile = profileOption({ model: model as string });
const { result } = renderHook(() => useAgentProfileOptions([profile]));
const option = result.current[0]!;
expect(option.disabled).toBeUndefined();
expect(option.disabledReason).toBeUndefined();
render(
<TooltipProvider>
<div data-testid="option-label">{option.renderLabel()}</div>
<div data-testid="selected-label">{option.renderTriggerLabel?.()}</div>
</TooltipProvider>,
);
for (const label of ["option-label", "selected-label"]) {
const element = screen.getByTestId(label);
expect(element.textContent).toContain("hybrid");
expect(element.querySelector("button, .tabler-icon-alert-triangle")).toBeNull();
expect(element.querySelector('[title*="host probe"]')).toBeNull();
}
});
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.3
it.each(["auth_required", "not_installed", "failed"] as const)(
"preserves %s health indicators when the saved model is absent",
(capability_status) => {
setAvailableAgents([]);
const profile = profileOption({
model: GONE_MODEL,
capability_status,
capability_error: "Agent needs attention",
});
const { result } = renderHook(() => useAgentProfileOptions([profile]));
const option = result.current[0]!;
render(
<TooltipProvider>
<div>{option.renderLabel()}</div>
<div>{option.renderTriggerLabel?.()}</div>
</TooltipProvider>,
);
expect(screen.getAllByTitle("Agent needs attention")).toHaveLength(2);
expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull();
},
);
});
Stability check
it("keeps labels stable when a pending host catalog changes", () => {
setAvailableAgents([]);
const profile = profileOption({ model: GONE_MODEL });
const { rerender } = render(<OptionsProbe profiles={[profile]} />);
const initialLabel = screen.getByTestId("option-0").innerHTML;
setAvailableAgents([AGENT_WITH_GPT]);
rerender(<OptionsProbe profiles={[profile]} />);
expect(screen.getByTestId("option-0").innerHTML).toBe(initialLabel);
expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull();
});
it("refreshes capability health from the host snapshot without inspecting model IDs", () => {
setAvailableAgents([{
...AGENT_WITH_GPT,
available: false,
model_config: { ...AGENT_WITH_GPT.model_config, error: "Agent unavailable" },
}]);
const { result } = renderHook(() => useAgentProfileOptions([profileOption({ model: GONE_MODEL })]));
const option = result.current[0]!;
render(<TooltipProvider><div>{option.renderLabel()}</div><div>{option.renderTriggerLabel?.()}</div></TooltipProvider>);
expect(screen.getAllByTitle("Agent unavailable")).toHaveLength(2);
expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull();
});
Remove unused locale keys
apps/web/src/locales/en/settings.json ↗The two selector-only keys are deleted from en, pseudo, pt-pt, zh-cn, zh-hk, and zh-tw.
Locale diff
"profileStartModelNotAdvertisedOnHost": "The host probe did not advertise {{model}}. The selected executor will decide the model at launch.",
"profileStartModelUniqueVariationOnHost": "The host probe found one possible variation of {{model}}: {{variation}}. The selected executor will decide the model at launch.",
"modelVariationAdvisory": "The host catalog has one possible variation of {{model}}: {{variation}}. The saved model remains unchanged until launch.",
Kept key
{
"modelVariationAdvisory": "The host catalog has one possible variation of {{model}}: {{variation}}. The saved model remains unchanged until launch.",
"startModelUnavailable": "No longer available; select a different model",
"fallbackModelUnavailable": "Fallback model no longer available; select a different one"
}
E2E helper for executor-only launch
apps/web/e2e/tests/settings/profile-model-selection-helpers.ts ↗The new helper creates a profile whose model is absent on the host but present on the executor and verifies no warning appears.
New helper
export async function launchExecutorOnlyModelProfile(
page: Page,
apiClient: ApiClient,
profile: AgentProfile,
mobile: boolean,
) {
await expect.poll(async () => {
const { agents } = await apiClient.listAvailableAgents();
return agents.find((agent) => agent.name === "mock-agent")?.model_config.status;
}).toBe("ok");
const { agents } = await apiClient.listAvailableAgents();
const hostModels = agents.find((a) => a.name === "mock-agent")!.model_config.available_models.map((m) => m.id);
expect(hostModels.length).toBeGreaterThan(0);
expect(hostModels).not.toContain(profile.model);
const kanban = new KanbanPage(page);
await kanban.goto();
await page.reload();
if (mobile) await page.getByRole("button", { name: "Add task" }).tap();
else await kanban.createTaskButton.first().click();
const dialog = page.getByTestId("create-task-dialog");
const selector = dialog.getByTestId("agent-profile-selector");
await selector.click();
const option = page.getByRole("listbox").getByRole("option", { name: profile.name });
await expect(option).toBeEnabled();
await expect(option.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0);
if (mobile) await option.tap();
else {
const search = page.locator("[cmdk-input]");
await search.fill(profile.name);
await search.press("Enter");
}
await expect(page.getByRole("listbox")).not.toBeVisible();
await expect(selector).toContainText(profile.name);
await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0);
await dialog.getByTestId("task-title-input").fill("Use my saved model");
await dialog.getByTestId("task-description-input").fill("/e2e:simple-message");
await dialog.getByTestId("submit-start-agent").click();
if (mobile) await kanban.taskCardByTitle("Use my saved model").tap();
await expect(page).toHaveURL(/\/t\/[^/?]+/);
const taskId = new URL(page.url()).pathname.split("/")[2];
await expect.poll(async () => (await apiClient.listTaskSessions(taskId)).sessions.length).toBe(1);
const { sessions } = await apiClient.listTaskSessions(taskId);
const sessionId = sessions[0].id;
expect(sessions[0].agent_profile_id).toBe(profile.id);
await waitForSessionDone(apiClient, taskId, sessionId, "Waiting for requested executor model");
const session = new SessionPage(page);
await session.waitForLoad();
await assertRequestedModel(page, apiClient, taskId, sessionId, profile);
await page.reload();
await session.waitForLoad();
await assertRequestedModel(page, apiClient, taskId, sessionId, profile);
if (mobile) await assertNoDocumentHorizontalOverflow(page, "requested model after reload");
}
Updated desktop spec
test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient, prCapture }) => {
const profile = await createMismatchedProfile(apiClient, "Host mismatch selectable profile");
const kanban = new KanbanPage(testPage);
await kanban.goto();
await testPage.reload({ waitUntil: "networkidle" });
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
const selector = dialog.getByTestId("agent-profile-selector");
await selector.click();
const option = testPage.getByRole("listbox").getByRole("option", { name: profile.name, exact: false });
await expect(option).toBeVisible();
await expect(option).toBeEnabled();
await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0);
await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
await prCapture.screenshot("desktop-profile-options", { caption: "Saved profiles remain selectable without host-model warnings." });
const search = testPage.locator("[cmdk-input]");
await search.fill(profile.name);
await search.press("Enter");
await expect(testPage.getByRole("listbox")).not.toBeVisible();
await expect(selector).toContainText(profile.name);
});
Spec and public docs update
docs/specs/agents/requirements/no-silent-model-fallback.md ↗Requirement 003 now forbids host-model advisories in selectors and keeps editor and chat warnings.
Requirement 003
### REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003: Show model warnings at the point of action
**Intent:** Let users select a saved profile without treating a host discovery difference as evidence of an executor failure.
- **AC-003.1:** A host model-catalog difference shall not add an icon, message, tooltip, or help action to a profile selector. This applies to its options and its selected label, including missing models, one or several model variations, and empty or pending catalogs.
- **AC-003.2:** On desktop and mobile, an otherwise eligible profile shall remain selectable by keyboard or pointer. On touch devices, tapping its row shall select it without opening model help.
- **AC-003.3:** Existing authentication, installation, and capability-probe failure indicators shall remain visible. Their presence shall not depend on whether the saved model is advertised.
- **AC-003.4:** The profile editor shall retain its missing-model treatment, unique-variation advisory, and fallback controls.
- **AC-003.5:** When the executor applies the requested model successfully, a host catalog difference shall not cause a model-selection warning in task chat.
- **AC-003.6:** Selecting a profile shall not rewrite its saved model, reasoning configuration, or fallback settings.
Public docs
### Host probes and executor model catalogs
The model list shown while editing a profile comes from a host probe. It is an editing hint, not a launch gate. A profile remains selectable when its saved model is missing from that host list. Profile selectors do not show a model warning for this difference. Inspect the model list in profile settings for discovery details. Authentication, installation, and probe-failure indicators remain visible on profile selectors.
At task launch, the selected executor's ACP catalog is authoritative. For profiles without automatic fallback, Kandev follows the four-step order: exact model, explicit fallback, one unique bracketed variation, then agent default. Kandev stores one warning in task chat with the requested model and the effective model when known.