fix: remove host model warnings from profile selectors (#3521)

This commit is contained in:
Carlos Florêncio
2026-09-09 07:48:29 +01:00
committed by GitHub
parent 401947fd82
commit 867b2da18d
19 changed files with 868 additions and 277 deletions
@@ -58,19 +58,6 @@ const AGENT_WITH_GPT: AvailableAgent = {
}, },
} as unknown as AvailableAgent; } as unknown as AvailableAgent;
const AGENT_WITH_OPUS_VARIATION: AvailableAgent = {
name: "omp-acp",
available: true,
model_config: {
default_model: "opus[1m]",
available_models: [{ id: "opus[1m]", name: "Opus (1m)" }],
current_model_id: "opus[1m]",
available_modes: [],
supports_dynamic_models: false,
status: "ok",
},
} as unknown as AvailableAgent;
const GONE_MODEL = "claude-gone"; const GONE_MODEL = "claude-gone";
const DATA_DISABLED = "data-disabled"; const DATA_DISABLED = "data-disabled";
const MODEL_PROBE_WARNING_TEST_ID = "agent-profile-model-probe-warning"; const MODEL_PROBE_WARNING_TEST_ID = "agent-profile-model-probe-warning";
@@ -118,14 +105,6 @@ function renderOptions(profiles: AgentProfileOption[]) {
return screen.getByTestId("option-0"); return screen.getByTestId("option-0");
} }
function getModelProbeWarning() {
return screen.getByTestId(MODEL_PROBE_WARNING_TEST_ID);
}
function getModelProbeWarningLabel() {
return getModelProbeWarning().getAttribute("aria-label");
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
setAvailableAgents([AGENT_WITH_GPT]); setAvailableAgents([AGENT_WITH_GPT]);
@@ -226,80 +205,122 @@ describe("useAgentProfileOptions recent-use ordering", () => {
}); });
}); });
describe("useAgentProfileOptions executor-authoritative model hint", () => { describe("useAgentProfileOptions model-independent labels", () => {
it("keeps a profile whose start model is absent from the host probe selectable", () => { // @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.1
const option = renderOptions([profileOption({ model: GONE_MODEL })]); it.each([
expect(option.getAttribute(DATA_DISABLED)).toBeNull(); ["exact", "gpt-5", ["gpt-5"]],
expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); ["missing", GONE_MODEL, ["gpt-5"]],
}); ["unique variation", "opus", ["opus[1m]"]],
["multiple variations", "opus", ["opus[1m]", "opus[270k]"]],
it("keeps a profile with an available start model selectable", () => { ["legacy effort IDs", "gpt-6-astra", ["gpt-6-astra[low]", "gpt-6-astra[high]"]],
const option = renderOptions([profileOption({ model: "gpt-5" })]); ["bracketed request", "opus[1m]", ["opus[270k]"]],
expect(option.getAttribute(DATA_DISABLED)).toBeNull(); ["empty catalog", GONE_MODEL, []],
}); ["provider default", "", ["gpt-5"]],
])("does not show host model advisories in either label: %s", (_, model, models) => {
it("names a unique advertised variation without disabling the profile", () => { setAvailableAgents([
setAvailableAgents([AGENT_WITH_OPUS_VARIATION]); {
const option = renderOptions([profileOption({ model: "opus" })]); ...AGENT_WITH_GPT,
model_config: {
expect(option.getAttribute(DATA_DISABLED)).toBeNull(); ...AGENT_WITH_GPT.model_config,
expect(getModelProbeWarningLabel()).toContain("opus[1m]"); available_models: (models as string[]).map((id) => ({ id, name: id })),
}); },
},
it("keeps a profile with an empty (agent default) model selectable", () => {
const option = renderOptions([profileOption({ model: "" })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
});
it("keeps a gone-model profile with a fallback selectable", () => {
const option = renderOptions([profileOption({ model: GONE_MODEL, fallback_model: "gpt-5" })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
expect(option.getAttribute("data-reason")).toBeNull();
expect(getModelProbeWarningLabel()).toContain(GONE_MODEL);
});
it("keeps a profile selectable when both saved models are absent from the host probe", () => {
const option = renderOptions([
profileOption({ model: GONE_MODEL, fallback_model: "other-gone" }),
]); ]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull(); const profile = profileOption({ model: model as string });
expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); const { result } = renderHook(() => useAgentProfileOptions([profile]));
}); const option = result.current[0]!;
expect(option.disabled).toBeUndefined();
it("keeps auto-fallback profiles selectable and shows the host probe hint", () => { expect(option.disabledReason).toBeUndefined();
const option = renderOptions([profileOption({ model: GONE_MODEL, auto_fallback: true })]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
expect(getModelProbeWarningLabel()).toContain(GONE_MODEL);
});
it("renders the host probe hint as one compact warning trigger", () => {
const option = renderOptions([profileOption({ model: GONE_MODEL })]);
expect(getModelProbeWarning()).toBeTruthy();
expect(option.textContent).not.toContain(
"The host probe did not advertise claude-gone. The selected executor will decide the model at launch.",
);
});
it("uses a non-interactive warning indicator in the selected trigger", () => {
const { result } = renderHook(() =>
useAgentProfileOptions([profileOption({ model: GONE_MODEL })]),
);
render( render(
<TooltipProvider> <TooltipProvider>
<div>{result.current[0]?.renderTriggerLabel?.()}</div> <div data-testid="option-label">{option.renderLabel()}</div>
<div data-testid="selected-label">{option.renderTriggerLabel?.()}</div>
</TooltipProvider>, </TooltipProvider>,
); );
for (const label of ["option-label", "selected-label"]) {
expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); const element = screen.getByTestId(label);
expect(screen.getByTitle(/claude-gone/)).toBeTruthy(); expect(element.textContent).toContain("hybrid");
expect(element.querySelector("button, .tabler-icon-alert-triangle")).toBeNull();
expect(element.querySelector('[title*="host probe"]')).toBeNull();
}
}); });
it("does not gate when the agent model list is unknown (probe not landed)", () => { // @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();
},
);
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6
it.each([false, true])(
"preserves saved profile settings with auto fallback %s",
(auto_fallback) => {
const profile = profileOption({
model: GONE_MODEL,
fallback_model: "other-gone",
auto_fallback,
cli_passthrough: true,
});
const saved = structuredClone(profile);
const option = renderOptions([profile]);
expect(option.getAttribute(DATA_DISABLED)).toBeNull();
expect(option.getAttribute("data-reason")).toBeNull();
expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull();
expect(option.querySelector(".tabler-icon-terminal-2")).not.toBeNull();
expect(profile).toEqual(saved);
},
);
it("keeps labels stable when a pending host catalog changes", () => {
setAvailableAgents([]); setAvailableAgents([]);
const option = renderOptions([profileOption({ model: GONE_MODEL })]); const profile = profileOption({ model: GONE_MODEL });
expect(option.getAttribute(DATA_DISABLED)).toBeNull(); 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();
});
@@ -1,21 +1,12 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { t } from "@/lib/i18n"; 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"; import { Badge } from "@kandev/ui/badge";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { import type {
LocalRepository, LocalRepository,
Repository, Repository,
@@ -23,17 +14,17 @@ import type {
Executor, Executor,
ExecutorProfile, ExecutorProfile,
} from "@/lib/types/http"; } from "@/lib/types/http";
import type { AvailableAgent } from "@/lib/types/http-agents";
import type { AgentProfileOption } from "@/lib/state/slices"; import type { AgentProfileOption } from "@/lib/state/slices";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { useFeature } from "@/hooks/domains/features/use-feature"; 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 { formatUserHomePath, truncateRepoPath } from "@/lib/utils";
import { getExecutorIcon } from "@/lib/executor-icons"; import { getExecutorIcon } from "@/lib/executor-icons";
import { AgentLogo } from "@/components/agent-logo"; import { AgentLogo } from "@/components/agent-logo";
import { getCapabilityWarning } from "@/lib/capability-warning"; import { getCapabilityWarning } from "@/lib/capability-warning";
import { findUniqueModelVariation } from "@/lib/model-variation";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { buildBranchKeywords } from "./branch-picker-options"; import { buildBranchKeywords } from "./branch-picker-options";
import { import {
ensureAgentProfileRecentUseLoaded, ensureAgentProfileRecentUseLoaded,
@@ -50,50 +41,6 @@ type OptionItem = {
disabledReason?: string; disabledReason?: string;
}; };
function ModelProbeWarning({ note }: { note: string }) {
const usesTouchDrawer = useTouchDrawer();
const [drawerOpen, setDrawerOpen] = useState(false);
const trigger = (
<button
type="button"
className="inline-flex min-h-11 min-w-8 shrink-0 cursor-help items-center justify-center rounded-sm border-0 bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={note}
aria-expanded={usesTouchDrawer ? drawerOpen : undefined}
aria-haspopup={usesTouchDrawer ? "dialog" : undefined}
data-testid="agent-profile-model-probe-warning"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<IconAlertTriangle className="size-3.5 text-amber-500" aria-hidden />
</button>
);
if (usesTouchDrawer) {
return (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="sr-only">{note}</DrawerTitle>
<DrawerDescription>{note}</DrawerDescription>
</DrawerHeader>
</DrawerContent>
</Drawer>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="top">{note}</TooltipContent>
</Tooltip>
);
}
function ModelProbeWarningIndicator({ note }: { note: string }) {
return <IconAlertTriangle className="size-3.5 text-amber-500" title={note} aria-hidden />;
}
export function useRepositoryOptions( export function useRepositoryOptions(
repositories: Repository[], repositories: Repository[],
discoveredRepositories: LocalRepository[], discoveredRepositories: LocalRepository[],
@@ -185,19 +132,15 @@ export function useBranchOptions(branchOptionsRaw: Branch[]) {
}, [branchOptionsRaw]); }, [branchOptionsRaw]);
} }
// advertisedModelIDs returns the currently advertised model IDs for an agent
// from the host-utility probe cache (empty when the probe has not landed).
function advertisedModelIDs(availableAgents: AvailableAgent[], agentName: string): string[] {
const agent = availableAgents.find((a) => a.name === agentName);
return agent?.model_config?.available_models?.map((m) => m.id) ?? [];
}
export function useAgentProfileOptions( export function useAgentProfileOptions(
agentProfiles: AgentProfileOption[], agentProfiles: AgentProfileOption[],
context?: AgentProfileRecentUseContext, context?: AgentProfileRecentUseContext,
): OptionItem[] { ): OptionItem[] {
const { t } = useTranslation(); const { t } = useTranslation();
const { items: availableAgents } = useAvailableAgents(); // 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 dynamicRoutingEnabled = useFeature("dynamicAgentRouting");
const storeApi = useAppStoreApi(); const storeApi = useAppStoreApi();
const recentUseLoaded = useAppStore((state) => !context || state.agentProfileRecentUse.loaded); const recentUseLoaded = useAppStore((state) => !context || state.agentProfileRecentUse.loaded);
@@ -209,9 +152,13 @@ export function useAgentProfileOptions(
void ensureAgentProfileRecentUseLoaded(storeApi); void ensureAgentProfileRecentUseLoaded(storeApi);
}, [context, recentUseLoaded, storeApi]); }, [context, recentUseLoaded, storeApi]);
return useMemo(() => { return useMemo(() => {
const profilesWithCapabilities = refreshProfileCapabilities(
agentProfiles,
availableAgents.items,
);
// Disabled profiles stay in the store (existing sessions keep their // Disabled profiles stay in the store (existing sessions keep their
// labels) but are never offered as a choice for new work. // labels) but are never offered as a choice for new work.
const selectable = agentProfiles.filter((profile) => const selectable = profilesWithCapabilities.filter((profile) =>
isSelectableAgentProfile(profile, dynamicRoutingEnabled), isSelectableAgentProfile(profile, dynamicRoutingEnabled),
); );
const orderedProfiles = context const orderedProfiles = context
@@ -223,26 +170,7 @@ export function useAgentProfileOptions(
const profileLabel = parts[1] ?? ""; const profileLabel = parts[1] ?? "";
const isPassthrough = profile.cli_passthrough === true; const isPassthrough = profile.cli_passthrough === true;
const warning = getCapabilityWarning(profile.capability_status, profile.capability_error); const warning = getCapabilityWarning(profile.capability_status, profile.capability_error);
// The host-utility probe is an editing hint only. The selected const renderProfileLabel = () => (
// executor owns the launch-time model catalog, so a host-only mismatch
// must never remove a profile from the task selector.
const advertised = advertisedModelIDs(availableAgents, profile.agent_name);
const startModelGone = Boolean(
profile.model && advertised.length > 0 && !advertised.includes(profile.model),
);
const uniqueVariation = startModelGone
? findUniqueModelVariation(profile.model ?? "", advertised)
: null;
let modelProbeNote: string | undefined;
if (startModelGone) {
modelProbeNote = uniqueVariation
? t("settings:profileStartModelUniqueVariationOnHost", {
model: profile.model,
variation: uniqueVariation,
})
: t("settings:profileStartModelNotAdvertisedOnHost", { model: profile.model });
}
const renderProfileLabel = (modelProbeWarning: React.ReactNode) => (
<span className="flex min-w-0 flex-1 flex-col gap-1"> <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 justify-between gap-2">
<span className="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
@@ -251,7 +179,6 @@ export function useAgentProfileOptions(
{warning && ( {warning && (
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} /> <warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
)} )}
{modelProbeWarning}
</span> </span>
<span className="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && ( {isPassthrough && (
@@ -274,15 +201,11 @@ export function useAgentProfileOptions(
label: profile.label, label: profile.label,
disabled: undefined, disabled: undefined,
disabledReason: undefined, disabledReason: undefined,
renderLabel: () => renderLabel: renderProfileLabel,
renderProfileLabel(modelProbeNote ? <ModelProbeWarning note={modelProbeNote} /> : null), renderTriggerLabel: renderProfileLabel,
renderTriggerLabel: () =>
renderProfileLabel(
modelProbeNote ? <ModelProbeWarningIndicator note={modelProbeNote} /> : null,
),
}; };
}); });
}, [agentProfiles, availableAgents, context, dynamicRoutingEnabled, recentProfileIds, t]); }, [agentProfiles, availableAgents.items, context, dynamicRoutingEnabled, recentProfileIds, t]);
} }
export function useExecutorOptions(executors: Executor[]): OptionItem[] { export function useExecutorOptions(executors: Executor[]): OptionItem[] {
@@ -43,3 +43,16 @@ export async function readModelSelectionWarnings(
const { messages } = await apiClient.listSessionMessages(sessionId); const { messages } = await apiClient.listSessionMessages(sessionId);
return messages.filter((message) => message.metadata?.kind === "model_selection_warning"); return messages.filter((message) => message.metadata?.kind === "model_selection_warning");
} }
export async function createExecutorOnlyModelProfile(apiClient: ApiClient): Promise<AgentProfile> {
const { agents } = await apiClient.listAgents();
const agent = agents.find((item) => item.name === "mock-agent");
if (!agent) throw new Error("The E2E fixture must provide a mock agent");
return apiClient.createAgentProfile(agent.id, "Opus High", {
model: AMBIGUOUS_MODEL_VARIATIONS[0],
config_options: { effort: "high" },
fallback_model: "mock-smart",
auto_fallback: false,
env_vars: [{ key: MODEL_CATALOG_ENV, value: "ambiguous" }],
});
}
@@ -2,15 +2,18 @@ import { expect, test } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page"; import { KanbanPage } from "../../pages/kanban-page";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { import {
createExecutorOnlyModelProfile,
createModelVariationProfile, createModelVariationProfile,
createMismatchedProfile, createMismatchedProfile,
MODEL_VARIATION_BASE,
UNIQUE_MODEL_VARIATION,
UNADVERTISED_MODEL,
} from "../session/model-mismatch-warning-helpers"; } from "../session/model-mismatch-warning-helpers";
import { launchExecutorOnlyModelProfile } from "./profile-model-selection-helpers";
test.describe("executor-authoritative model selection on mobile", () => { test.describe("executor-authoritative model selection on mobile", () => {
test("keeps the host-mismatched profile reachable by touch", async ({ testPage, apiClient }) => { test("keeps the host-mismatched profile reachable by touch", async ({
testPage,
apiClient,
prCapture,
}) => {
const profile = await createMismatchedProfile(apiClient, "Mobile host mismatch profile"); const profile = await createMismatchedProfile(apiClient, "Mobile host mismatch profile");
try { try {
const kanban = new KanbanPage(testPage); const kanban = new KanbanPage(testPage);
@@ -29,23 +32,25 @@ test.describe("executor-authoritative model selection on mobile", () => {
.getByRole("option", { name: profile.name, exact: false }); .getByRole("option", { name: profile.name, exact: false });
await expect(option).toBeVisible(); await expect(option).toBeVisible();
await expect(option).toBeEnabled(); await expect(option).toBeEnabled();
const warning = option.getByTestId("agent-profile-model-probe-warning"); await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0);
await expect(warning).toBeVisible(); await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; await prCapture.screenshot("mobile-profile-options", {
await expect(dialog).not.toContainText(warningText); caption: "Saved profiles remain selectable without host-model warnings.",
await warning.tap(); });
await expect( await option.tap();
testPage await expect(testPage.getByRole("listbox")).not.toBeVisible();
.locator('[data-slot="drawer-content"][data-state="open"]') await expect(selector).toContainText(profile.name);
.filter({ hasText: warningText }), await prCapture.screenshot("mobile-selected-profile", {
).toBeVisible(); caption: "The selected profile keeps its name without a model advisory.",
await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch selector"); });
await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage, "mobile profile selection");
} finally { } finally {
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
} }
}); });
test("opens the unique variation advisory by touch without overflow", async ({ test("selects a unique-variation profile by touch without model help", async ({
testPage, testPage,
apiClient, apiClient,
}) => { }) => {
@@ -70,18 +75,28 @@ test.describe("executor-authoritative model selection on mobile", () => {
.getByRole("option", { name: profile.name, exact: false }); .getByRole("option", { name: profile.name, exact: false });
await expect(option).toBeVisible(); await expect(option).toBeVisible();
await expect(option).toBeEnabled(); await expect(option).toBeEnabled();
const warning = option.getByTestId("agent-profile-model-probe-warning"); await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0);
await expect(warning).toBeVisible(); await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
const warningText = `The host probe found one possible variation of ${MODEL_VARIATION_BASE}: ${UNIQUE_MODEL_VARIATION}. The selected executor will decide the model at launch.`; await option.tap();
await warning.tap(); await expect(testPage.getByRole("listbox")).not.toBeVisible();
await expect( await expect(selector).toContainText(profile.name);
testPage await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0);
.locator('[data-slot="drawer-content"][data-state="open"]') await assertNoDocumentHorizontalOverflow(testPage, "mobile profile selection");
.filter({ hasText: warningText }),
).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "mobile unique variation advisory");
} finally { } finally {
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
} }
}); });
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6
test("launches a host-mismatched profile on its requested executor model without a warning", async ({
testPage,
apiClient,
}) => {
const profile = await createExecutorOnlyModelProfile(apiClient);
try {
await launchExecutorOnlyModelProfile(testPage, apiClient, profile, true);
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
}); });
@@ -1,15 +1,14 @@
import { expect, test } from "../../fixtures/test-base"; import { expect, test } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page"; import { KanbanPage } from "../../pages/kanban-page";
import { import {
createExecutorOnlyModelProfile,
createModelVariationProfile, createModelVariationProfile,
createMismatchedProfile, createMismatchedProfile,
MODEL_VARIATION_BASE,
UNIQUE_MODEL_VARIATION,
UNADVERTISED_MODEL,
} from "../session/model-mismatch-warning-helpers"; } from "../session/model-mismatch-warning-helpers";
import { launchExecutorOnlyModelProfile } from "./profile-model-selection-helpers";
test.describe("executor-authoritative model selection", () => { test.describe("executor-authoritative model selection", () => {
test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient }) => { test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient, prCapture }) => {
const profile = await createMismatchedProfile(apiClient, "Host mismatch selectable profile"); const profile = await createMismatchedProfile(apiClient, "Host mismatch selectable profile");
try { try {
const kanban = new KanbanPage(testPage); const kanban = new KanbanPage(testPage);
@@ -28,24 +27,27 @@ test.describe("executor-authoritative model selection", () => {
.getByRole("option", { name: profile.name, exact: false }); .getByRole("option", { name: profile.name, exact: false });
await expect(option).toBeVisible(); await expect(option).toBeVisible();
await expect(option).toBeEnabled(); await expect(option).toBeEnabled();
const warning = option.getByTestId("agent-profile-model-probe-warning"); await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0);
await expect(warning).toBeVisible(); await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; await prCapture.screenshot("desktop-profile-options", {
await expect(dialog).not.toContainText(warningText); caption: "Saved profiles remain selectable without host-model warnings.",
await warning.hover(); });
await expect( const search = testPage.locator("[cmdk-input]");
testPage await search.fill(profile.name);
.locator('[data-slot="tooltip-content"]:not([data-state="closed"])') await search.press("Enter");
.filter({ hasText: warningText }), await expect(testPage.getByRole("listbox")).not.toBeVisible();
).toBeVisible(); await expect(selector).toContainText(profile.name);
await option.click(); await prCapture.screenshot("desktop-selected-profile", {
caption: "The selected profile keeps its name without a model advisory.",
});
await expect(selector.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
await expect(selector.locator("button")).toHaveCount(0); await expect(selector.locator("button")).toHaveCount(0);
} finally { } finally {
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
} }
}); });
test("names one host-advertised variation while keeping the profile selectable", async ({ test("selects a unique-variation profile without a host model advisory", async ({
testPage, testPage,
apiClient, apiClient,
}) => { }) => {
@@ -70,18 +72,27 @@ test.describe("executor-authoritative model selection", () => {
.getByRole("option", { name: profile.name, exact: false }); .getByRole("option", { name: profile.name, exact: false });
await expect(option).toBeVisible(); await expect(option).toBeVisible();
await expect(option).toBeEnabled(); await expect(option).toBeEnabled();
const warning = option.getByTestId("agent-profile-model-probe-warning"); await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0);
await expect(warning).toBeVisible(); await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
const warningText = `The host probe found one possible variation of ${MODEL_VARIATION_BASE}: ${UNIQUE_MODEL_VARIATION}. The selected executor will decide the model at launch.`;
await warning.hover();
await expect(
testPage
.locator('[data-slot="tooltip-content"]:not([data-state="closed"])')
.filter({ hasText: warningText }),
).toBeVisible();
await option.click(); await option.click();
await expect(testPage.getByRole("listbox")).not.toBeVisible();
await expect(selector).toContainText(profile.name);
await expect(selector.locator(".tabler-icon-alert-triangle")).toHaveCount(0);
} finally { } finally {
await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); await apiClient.deleteAgentProfile(profile.id, true).catch(() => {});
} }
}); });
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5
// @covers AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6
test("launches a host-mismatched profile on its requested executor model without a warning", async ({
testPage,
apiClient,
}) => {
const profile = await createExecutorOnlyModelProfile(apiClient);
try {
await launchExecutorOnlyModelProfile(testPage, apiClient, profile, false);
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
});
}); });
@@ -0,0 +1,103 @@
import { expect, type Page } from "@playwright/test";
import type { AgentProfile } from "../../../lib/types/http-agents";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { waitForSessionDone } from "../../helpers/session";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { readModelSelectionWarnings } from "../session/model-mismatch-warning-helpers";
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((agent) => agent.name === "mock-agent")!
.model_config.available_models.map((model) => model.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");
}
async function assertRequestedModel(
page: Page,
apiClient: ApiClient,
taskId: string,
sessionId: string,
profile: AgentProfile,
) {
const { sessions } = await apiClient.listTaskSessions(taskId);
const current = sessions.find((session) => session.id === sessionId)!;
expect(current.metadata?.runtime_config).toMatchObject({
model: profile.model,
config_options: { effort: "high" },
});
expect(current.metadata?.acp_model_state).toMatchObject({
current_model_id: profile.model,
models: expect.arrayContaining([expect.objectContaining({ model_id: profile.model })]),
});
expect(await readModelSelectionWarnings(apiClient, sessionId)).toHaveLength(0);
const modelState = current.metadata?.acp_model_state as
| { models?: Array<{ model_id?: string; name?: string }> }
| undefined;
const requestedModelName = modelState?.models?.find(
(model) => model.model_id === profile.model,
)?.name;
expect(requestedModelName).toBeTruthy();
const modelTrigger = page.getByRole("button", { name: "Session model settings" });
await expect(modelTrigger).toContainText(requestedModelName!);
await expect(modelTrigger).toContainText("High");
const saved = await apiClient.getAgentProfile(profile.id);
expect(saved.model).toBe(profile.model);
expect(saved.config_options).toEqual(profile.config_options);
expect(saved.fallback_model).toBe(profile.fallback_model);
expect(saved.auto_fallback).toBe(profile.auto_fallback);
}
-2
View File
@@ -503,8 +503,6 @@
"previewSound": "Preview sound", "previewSound": "Preview sound",
"profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents", "profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents",
"programming": "Programming", "programming": "Programming",
"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.", "modelVariationAdvisory": "The host catalog has one possible variation of {{model}}: {{variation}}. The saved model remains unchanged until launch.",
"promptAdd": "Add prompt", "promptAdd": "Add prompt",
"promptEditorAriaLabel": "Prompt editor", "promptEditorAriaLabel": "Prompt editor",
@@ -503,8 +503,6 @@
"previewSound": "Ƥŕēvĩēŵ śōũńď", "previewSound": "Ƥŕēvĩēŵ śōũńď",
"profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś", "profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś",
"programming": "Ƥŕōĝŕàḿḿĩńĝ", "programming": "Ƥŕōĝŕàḿḿĩńĝ",
"profileStartModelNotAdvertisedOnHost": "Ţĥē ĥōśţ ƥŕōƀē ďĩď ńōţ àďvēŕţĩśē {{model}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.",
"profileStartModelUniqueVariationOnHost": "Ţĥē ĥōśţ ƥŕōƀē ƒōũńď ōńē ƥōśśĩƀĺē vàŕĩàţĩōń ōƒ {{model}}: {{variation}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.",
"modelVariationAdvisory": "Ţĥē ĥōśţ ćàţàĺōĝ ĥàś ōńē ƥōśśĩƀĺē vàŕĩàţĩōń ōƒ {{model}}: {{variation}}. Ţĥē śàvēď ḿōďēĺ ŕēḿàĩńś ũńćĥàńĝēď ũńţĩĺ ĺàũńćĥ.", "modelVariationAdvisory": "Ţĥē ĥōśţ ćàţàĺōĝ ĥàś ōńē ƥōśśĩƀĺē vàŕĩàţĩōń ōƒ {{model}}: {{variation}}. Ţĥē śàvēď ḿōďēĺ ŕēḿàĩńś ũńćĥàńĝēď ũńţĩĺ ĺàũńćĥ.",
"promptAdd": "Àďď ƥŕōḿƥţ", "promptAdd": "Àďď ƥŕōḿƥţ",
"promptEditorAriaLabel": "Ƥŕōḿƥţ ēďĩţōŕ", "promptEditorAriaLabel": "Ƥŕōḿƥţ ēďĩţōŕ",
-2
View File
@@ -750,8 +750,6 @@
"taskBehavior": "Comportamento das tarefas", "taskBehavior": "Comportamento das tarefas",
"terminalAndEditors": "Terminal e editores", "terminalAndEditors": "Terminal e editores",
"workspacesAndAccess": "Workspaces e acesso", "workspacesAndAccess": "Workspaces e acesso",
"profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque.",
"profileStartModelUniqueVariationOnHost": "A sondagem do anfitrião encontrou uma única variação possível de {{model}}: {{variation}}. O executor selecionado decidirá o modelo no arranque.",
"modelVariationAdvisory": "O catálogo do anfitrião tem uma única variação possível de {{model}}: {{variation}}. O modelo guardado permanece inalterado até ao arranque.", "modelVariationAdvisory": "O catálogo do anfitrião tem uma única variação possível de {{model}}: {{variation}}. O modelo guardado permanece inalterado até ao arranque.",
"navUnits": "Unidades da organização", "navUnits": "Unidades da organização",
"unitsTitle": "Unidades da organização", "unitsTitle": "Unidades da organização",
-2
View File
@@ -750,8 +750,6 @@
"keyValueAddItem": "添加条目", "keyValueAddItem": "添加条目",
"keyValueKeyPlaceholder": "键", "keyValueKeyPlaceholder": "键",
"keyValueValuePlaceholder": "值", "keyValueValuePlaceholder": "值",
"profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。",
"profileStartModelUniqueVariationOnHost": "主机探测发现 {{model}} 的一个可能变体:{{variation}}。所选执行器将在启动时决定使用的模型。",
"modelVariationAdvisory": "主机目录中有一个可能的 {{model}} 变体:{{variation}}。保存的模型在启动前保持不变。", "modelVariationAdvisory": "主机目录中有一个可能的 {{model}} 变体:{{variation}}。保存的模型在启动前保持不变。",
"navUnits": "组织单元", "navUnits": "组织单元",
"unitsTitle": "组织单元", "unitsTitle": "组织单元",
-2
View File
@@ -750,8 +750,6 @@
"keyValueAddItem": "新增條目", "keyValueAddItem": "新增條目",
"keyValueKeyPlaceholder": "鍵", "keyValueKeyPlaceholder": "鍵",
"keyValueValuePlaceholder": "值", "keyValueValuePlaceholder": "值",
"profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。",
"profileStartModelUniqueVariationOnHost": "主機探測發現 {{model}} 的一個可能變體:{{variation}}。所選執行器將在啓動時決定使用的模型。",
"modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。", "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。",
"navUnits": "組織單元", "navUnits": "組織單元",
"unitsTitle": "組織單元", "unitsTitle": "組織單元",
-2
View File
@@ -750,8 +750,6 @@
"keyValueAddItem": "新增條目", "keyValueAddItem": "新增條目",
"keyValueKeyPlaceholder": "鍵", "keyValueKeyPlaceholder": "鍵",
"keyValueValuePlaceholder": "值", "keyValueValuePlaceholder": "值",
"profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。",
"profileStartModelUniqueVariationOnHost": "主機探測發現 {{model}} 的一個可能變體:{{variation}}。所選執行器將在啟動時決定使用的模型。",
"modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。", "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。",
"navUnits": "組織單元", "navUnits": "組織單元",
"unitsTitle": "組織單元", "unitsTitle": "組織單元",
@@ -0,0 +1,203 @@
---
created: 2026-09-08
status: completed
requirements:
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003
system_design:
- ../../specs/agents/system-design/no-silent-model-fallback-01.md
- ../../specs/agents/system-design/no-silent-model-fallback-02.md
legacy_specs: []
---
# Implementation Plan: Profile selector model warnings
## Overview
Remove host model advisories from saved-profile selectors. Keep profile editing
diagnostics and actual executor model-selection warnings available where users
can act on them.
The agent system owns this change because it owns profile model intent and
executor model authority. Amend the existing
[requirements](../../specs/agents/requirements/no-silent-model-fallback.md);
do not create a separate UI specification.
One work order delivers the shared selector change with unit, desktop, mobile,
and runtime-warning regression coverage. The user authorized implementation,
PR creation, a 15-minute wait, and PR fixup through `@implement`.
Deployment remains outside this package.
## Evidence and classification
This is a change to intended product behavior. The former system design
explicitly required the amber selector icon; the implementation follows that
rule. Requirement 002.7 now locates the host advisory in the profile editor.
Requirement 003 defines selector behavior and preservation checks.
Read-only investigation on 2026-09-08 established:
- The live instance used backend port 38429 and database
`/root/.kandev/data/kandev.db`.
- Profile `4fe051a9-f5cc-422b-bd0c-05048c22699e`, named Astra High, stored
`gpt-6-astra` and `settings.config_options.reasoning_effort = high`.
- Session `cfac5c57-f9a8-4b89-bc4c-2db8a0d6e20f` persisted that model and
effort in its settled runtime configuration.
- At 22:29:53 +01:00, logs reported that start-model policy applied Astra and
published `current_model_id = gpt-6-astra`.
- A fresh `acpdbg probe --timeout 45s codex-acp` completed with exit code 0.
Codex ACP 1.10.0 exposed 31 legacy model/effort IDs and six canonical model
configuration values. Both representations included Astra.
- Both live capability and available-agent APIs contained the canonical Astra
ID at inspection time.
The confirmed warning mechanism is the exact membership check in
`useAgentProfileOptions`: a nonempty browser host catalog that omits the
saved ID produces an amber advisory in both render paths. It never checks the
eventual executor outcome. A host catalog with several bracketed variants also
takes this path because no single variation matches.
The browser catalog at screenshot time was not captured. Staleness and legacy
ID representation are possible triggers, not established cache defects. The
design does not depend on choosing between those hypotheses.
The temporary probe capture was
`/tmp/kandev-model-warning-probe-OL5GGn/codex-acp-probe-20260908-213045.jsonl`.
It is optional local evidence, not a dependency for implementation. Do not
commit raw protocol frames or copy the live database into test fixtures.
## Scope
### In scope
- Remove host model advisory icons, help triggers, and selected-label indicators.
- Apply the result through the existing shared profile-option hook.
- Preserve capability health indicators, eligibility, recent-use ordering,
profile labels, saved configuration, and passthrough indicators.
- Preserve profile-editor model diagnostics and persisted task warnings.
- Remove unused selector translations and explain warning placement in public
agent-profile documentation when the implementation ships.
- Cover desktop keyboard selection, mobile row taps, successful model launch,
actual fallback, and reload.
### Out of scope
- Host probe refresh, browser cache invalidation, or new polling.
- ACP normalization changes or provider-specific bracket parsing.
- Model/fallback policy, runtime warning metadata, database schema, or migrations.
- Runtime model-selector redesign, profile-editor redesign, Office routing,
and unrelated capability-warning accessibility changes.
- New flags, telemetry, screenshots for public docs, or provider network calls.
## Technical approach
In `apps/web/components/task-create-dialog-options.tsx`, remove the
model-catalog comparison and the two model-warning renderers. Keep the
capability hook active, and use its snapshot only to refresh health fields on
profile options. Keep the existing option interface and shared label rendering
compatible with consumers.
The shared hook serves these existing callers:
- `components/task-create-dialog-computed.ts`
- `components/task/new-subtask-dialog.tsx`
- `components/task/new-session-dialog.tsx`
- `components/quick-chat/quick-chat-setup.tsx`
- `components/automations/config-section.tsx`
- `app/office/setup/agent-profile-setup-controls.tsx`
These callers require no separate presentation branches. In particular,
Office keeps its surrounding compatibility filtering.
Do not remove `getCapabilityWarning`, model fields from profile state, or the
editor's `findUniqueModelVariation` helper. Remove only the two unused
`settings` selector keys from every locale, including pseudo. Keep
`modelVariationAdvisory` and runtime-warning translations.
The profile editor already prefers structured model configuration options in
`components/settings/profile-model-fields.tsx`. Preserve that canonical source;
this task does not introduce another model-ID resolver.
Public documentation is owned by the same work order: add a short explanation
under “Host probes and executor model catalogs” in
`docs/public/agents-and-profiles.md`. This is an explanation section within
the existing agent-profile guide.
## Tests
The following regression scenarios define the acceptance coverage.
| Acceptance | Test file and scenario |
| --- | --- |
| 003.1 | `components/task-create-dialog-options.test.tsx`: “does not show host model advisories in option or selected labels”, parameterized over exact, missing, unique, multiple, bracketed, empty, pending, and changed host catalogs. |
| 003.2, 003.6 | Same file: preserve eligibility, ordering, profile label and saved values; retain existing disabled-profile and recent-use cases. |
| 003.3 | Same file: “preserves capability health warnings independently of model membership”, for auth_required, not_installed, failed, and healthy status. |
| 003.4, 002.7 | `components/settings/profile-form-fields.test.tsx` and `lib/model-variation.test.ts`: retain missing-model, unique-variation, and fallback-control coverage. |
| 003.5, 001.4 | `components/task/chat/messages/status-message.test.tsx`: retain structured model-selection warning rendering. |
| 001.5, 003.6 | Browser reload checks and saved-profile API comparison in the successful-launch scenario below. |
The first red test must render a healthy-capability profile with a missing host
model and assert no advisory in either label. It fails on the current
implementation because the dropdown renders a warning button and the selected
label renders a titled icon. Absence of the old test ID alone is insufficient.
## E2E tests
| Project / file under `apps/web/e2e/tests` | Outcome and acceptance |
| --- | --- |
| chromium / `settings/no-silent-model-fallback.spec.ts` | Replace tooltip assertions with warning-free missing/unique profile selection. Check option and selected label, keyboard selection, and no nested warning control. Covers 003.1–003.2. |
| mobile-chrome / `settings/mobile-no-silent-model-fallback.spec.ts` | Replace warning-drawer taps with row selection. Verify selection closes the picker, updates its label, and leaves the create flow usable without horizontal overflow. Covers 003.1–003.2. |
| Both settings files above | Add “launches a host-mismatched profile on its requested executor model without a warning”. Submit through the UI, verify effective model and zero model-selection warnings after completion and reload, then compare saved profile configuration. Covers 003.5–003.6 and 001.5. |
| chromium / `session/model-mismatch-warning.spec.ts` | Preserve actual fallback, unique-variation, ambiguous-catalog, single-warning, and reload coverage. Covers 001.4 and the preservation side of 003.5. |
| mobile-chrome / `session/mobile-model-mismatch-warning.spec.ts` | Preserve visible actual fallback warning and reload without horizontal overflow. Covers 001.4 and 003.5. |
For the successful-launch fixture, extend
`session/model-mismatch-warning-helpers.ts` with a disposable mock profile
whose exact model is `opus[270k]` and whose profile environment selects the
existing `MOCK_AGENT_MODEL_CATALOG=ambiguous` fixture. The default E2E host
catalog includes `opus[1m]`, so it cannot serve as the omitted model. Confirm that the host
catalog omits that ID while the executor advertises it. Assert those
preconditions so an ordinary exact-match launch cannot pass as the regression.
Use existing model options for valid saved configuration values; no live Codex
account is needed.
## Work orders
- [x] [Task 01: Remove host model advisories from profile selectors](task-01-remove-host-model-advisories.md) — done, sequential, no dependencies.
## Verification results
Planning checks on 2026-09-08:
- Specification-linter tests: 30 passed.
- Full specification lint: passed after reducing the amended design to its size limit.
- Whitespace check: passed.
Implementation checks on 2026-09-08:
- Red: 11 component assertions and two selector cases per browser project failed
on the former model-warning controls.
- Green: 56 targeted unit tests, six desktop browser tests, and four mobile
browser tests passed.
- Typecheck, targeted ESLint, localization checks, and the localization ratchet passed.
- Public-documentation validator: 61 tests passed and 46 pages validated.
- Desktop and mobile screenshots show selectable profiles without a model
advisory or an empty warning control.
Task 01 records the commands, fixture correction, and mobile interaction detail.
## Risks
- Removing all warning icons would hide agent health errors; tests must distinguish
those indicators from model advisories.
- Removing only the dropdown button leaves the selected-label warning intact.
- Capability polling is shared with the selector hook. Keep it independent from
model-ID matching so health status remains current in selector-only surfaces.
- Old mobile tests deliberately open the warning drawer. Replace those steps
with completed row selection rather than deleting the tests.
- The saved screenshot does not establish a stale-cache defect. Do not expand
implementation into speculative discovery changes.
- Runtime unique-variation behavior is already covered by current code and
requirements, although its ADR remains proposed. This package does not
change that policy or its decision status.
@@ -0,0 +1,230 @@
---
id: "01-remove-host-model-advisories"
title: "Remove host model advisories from profile selectors"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003
acceptance_criteria:
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.4
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-001.5
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.7
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.1
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.2
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.3
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.4
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5
- AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6
system_design:
- ../../specs/agents/system-design/no-silent-model-fallback-01.md
- ../../specs/agents/system-design/no-silent-model-fallback-02.md
---
# Task 01: Remove host model advisories from profile selectors
## Summary
Remove the host-catalog warning from profile options and their selected labels.
Keep agent health indicators, editor diagnostics, and actual model-selection
warnings. Deliver the change through the shared hook with focused regression
tests and updated public guidance.
## In scope
- Own the shared hook, associated component tests, selector-copy cleanup, and
existing desktop/mobile model-selection E2E files.
- Add a successful exact-model launch with differing host and executor catalogs,
using disposable mock profiles and existing catalog fixtures.
- Keep the existing actual-fallback browser tests as preservation checks.
- Update the agent-profile guide's explanation of warning placement.
- Update this work order and the plan's results after the exact checks pass.
## Out of scope
- Production Go changes, ACP normalization, discovery cache/polling, schema,
migrations, model-selection policy, or runtime warning metadata.
- Caller-specific warning flags, new overlays, new settings, or profile rewrites.
- Broad verification and repository-wide review. The user's later `@implement`
request separately authorized commit, push, PR creation, and PR fixup.
## Acceptance
1. Both label render paths pass the plan's host-catalog matrix without model
advisories. Existing health indicators, eligibility, ordering, and saved
profile configuration remain intact.
2. Desktop keyboard and mobile touch flows select and launch the requested
profile. A successful exact-model launch has no model-selection warning;
actual fallback still displays one persisted warning after reload.
3. Unused selector translations are removed consistently. The profile editor
keeps its advisories and the public guide explains the shipped behavior.
## Implementation sequence
1. Read the linked requirements and design section 8, scoped web guidance,
existing source, and test helpers. Mark this work order in progress only
after the user requests implementation.
2. Run the new component absence assertions against the current implementation
and record their expected failure. Cover both render paths, not only the old
warning-button test ID.
3. Update the desktop/mobile selector scenarios for the intended behavior and
run the focused files against the current production build to record red.
Keep actual-fallback tests intact.
4. Remove the host model comparison, warning renderers, and exclusive imports.
Preserve the hook interface, health warning renderer, and caller data loading.
5. Complete the successful executor-model fixture, saved-value checks, and
localization cleanup. Update public guidance and run the commands below.
6. Mark done only after targeted checks pass. Record outcomes and any remaining
limitations in this file and the manifest.
## Verification
Run from the repository root. Install once in a fresh worktree:
```bash
rtk pnpm --dir apps install --frozen-lockfile
```
Use the first command for red and green component checks. The listed preservation
suites cover editor and chat behavior without a broad audit:
```bash
rtk pnpm --dir apps/web exec vitest run components/task-create-dialog-options.test.tsx components/settings/profile-form-fields.test.tsx lib/model-variation.test.ts components/task/chat/messages/status-message.test.tsx
rtk pnpm --dir apps/web exec eslint components/task-create-dialog-options.tsx components/task-create-dialog-options.test.tsx
rtk pnpm --dir apps/web run typecheck
rtk pnpm --dir apps/web run i18n:check
rtk pnpm --dir apps/web run i18n:ratchet
```
Run projects sequentially. The managed runner builds production assets and
isolates its instance. Check discovery counts; zero tests is not a pass.
```bash
rtk pnpm --dir apps/web e2e:run --project chromium tests/settings/no-silent-model-fallback.spec.ts tests/session/model-mismatch-warning.spec.ts
rtk pnpm --dir apps/web e2e:run --project mobile-chrome tests/settings/mobile-no-silent-model-fallback.spec.ts tests/session/mobile-model-mismatch-warning.spec.ts
```
Inspect the rendered mobile selection state or its test screenshot for row
spacing, viewport containment, and the absence of an empty warning hit area.
Tests must verify selection completion and no document horizontal overflow.
After public-documentation changes:
```bash
rtk node --test scripts/validate-public-docs.test.mjs
rtk node scripts/validate-public-docs.mjs
rtk git diff --check
```
Audit removed controls and keys:
```bash
rtk rg -n 'agent-profile-model-probe-warning|profileStartModelNotAdvertisedOnHost|profileStartModelUniqueVariationOnHost' apps/web
```
Only intentional negative assertions may retain the removed control identity.
Production references to the two translation keys must be absent. Keep
`modelVariationAdvisory` and existing runtime-warning keys.
## Files likely touched
- `apps/web/components/task-create-dialog-options.tsx`
- `apps/web/components/task-create-dialog-options.test.tsx`
- `apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts`
- `apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts`
- `apps/web/e2e/tests/settings/profile-model-selection-helpers.ts`
- `apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts`
- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/settings.json`
- `docs/public/agents-and-profiles.md`
Preservation inputs, normally unchanged:
- `apps/web/lib/capability-warning.ts`
- `apps/web/components/settings/profile-model-fields.tsx`
- `apps/web/components/settings/profile-form-fields.test.tsx`
- `apps/web/lib/model-variation.ts` and its test file
- `apps/web/components/task/chat/messages/status-message.test.tsx`
- `apps/web/e2e/tests/session/model-mismatch-warning.spec.ts`
- `apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts`
- The six shared-hook consumers listed in the manifest
## Dependencies
None.
## Risks
The selected-label path can retain a warning after the option path is fixed.
Blanket icon removal can hide capability errors. Fixture setup must prove
different host and executor catalogs, and cleanup must delete only the
disposable profiles created by the tests.
Keep capability polling active through the shared selector hook. Apply its
snapshot to health fields only. Do not reintroduce model-ID matching.
## Parallelism
`sequential`. No subagents are authorized.
## Inputs
- [Requirements](../../specs/agents/requirements/no-silent-model-fallback.md):
001.4–001.5, 002.7, and 003.
- [Design part 1](../../specs/agents/system-design/no-silent-model-fallback-01.md):
section 8, runtime warning contract, and tests.
- [Design part 2](../../specs/agents/system-design/no-silent-model-fallback-02.md):
warning-removal and evidence risks.
- [Executor authority ADR](../../decisions/2026-08-15-executor-authoritative-model-selection.md).
- [Variation resolution ADR](../../decisions/2026-09-07-unique-model-variation-resolution.md).
- Existing option/selected-label renderers and their component harness.
- Existing model-mismatch helpers and mock catalog environment fixture.
- Mobile exemplar and composition rationale in design section 8.
## Results
Completed on 2026-09-08.
- Removed host-catalog membership checks and both model-warning render paths
from the shared hook. Kept capability polling active and applied its snapshot
to health fields only. Eligibility and recent-use behavior remain unchanged.
- Removed the two selector-only keys from all six locale catalogs. Editor
diagnostics and runtime-warning code remain unchanged.
- The red component run failed 11 of 23 assertions. The old production build
failed both selector cases in each browser project on the warning controls.
- The four targeted unit suites passed all 56 tests.
- The managed desktop run passed six tests with `--host --project chromium`.
The mobile run passed four tests with `--host --no-build --project mobile-chrome`.
Both used the exact file lists in Verification. The mobile run reused the
unchanged production assets from the desktop run.
- The successful-launch fixture proves that the host omits `opus[270k]` and the
executor advertises it. It verifies the model, effort, zero warnings, and
unchanged saved profile after completion and reload.
- The first mobile launch run failed because it assumed desktop navigation.
The corrected test taps the created task card before it checks the session.
- Targeted ESLint passed for the component, its test, and all four changed or
new E2E files. Typecheck, `i18n:check`, and `i18n:ratchet` passed.
- Public-documentation validation passed: 61 validator tests and 46 pages.
- Fresh desktop/mobile screenshots show both the option list and selected label.
The mobile screenshots show usable row spacing with no empty warning control.
### PR fixup remediation
- Greptile's P1 finding was valid: the shared selector hook also kept capability
revalidation alive for selector-only surfaces. The hook now calls
`useAvailableAgents` and applies `refreshProfileCapabilities` to the local
option input. The host catalog supplies health status only. It cannot remove,
disable, or rewrite a profile because of a model ID.
- The remediation reran the focused desktop project (six passed) and mobile
project (four passed) after rebuilding the desktop production assets.
- The E2E launch helper now derives the expected model display name from the
executor's settled ACP model state. It no longer couples the assertion to a
fixture display string.
- Claude's trigger-label suggestion was already satisfied by the parameterized
test loop, which checks positive profile text and warning absence in both
render paths.
No backend changes, schema changes, live-instance restarts, or profile rewrites
were required. The screenshot-time browser catalog remains unknown.
+4 -1
View File
@@ -258,7 +258,10 @@ stale browser action does not replace a newer route decision.
The model list shown while editing a profile comes from a host probe. It is an 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 editing hint, not a launch gate. A profile remains selectable when its saved
model is missing from that host list. 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 At task launch, the selected executor's ACP catalog is authoritative. For
profiles without automatic fallback, Kandev follows the four-step order above. profiles without automatic fallback, Kandev follows the four-step order above.
+2 -1
View File
@@ -50,7 +50,8 @@ surface shared by task and Office consumers.
- [Agent MCP Timeout Budgets](requirements/mcp-timeout-budgets.md) - [Agent MCP Timeout Budgets](requirements/mcp-timeout-budgets.md)
- [Mock-agent slow command duration syntax](requirements/mock-agent-slow-duration.md) - [Mock-agent slow command duration syntax](requirements/mock-agent-slow-duration.md)
- [Native Code Review](requirements/native-code-review.md) - [Native Code Review](requirements/native-code-review.md)
- [No Silent Model Fallback](requirements/no-silent-model-fallback.md) - [No Silent Model Fallback](requirements/no-silent-model-fallback.md), including
profile-selector warning placement and executor-authoritative task warnings.
- [Copy agent configuration to isolated executors](requirements/portable-agent-configuration.md) - [Copy agent configuration to isolated executors](requirements/portable-agent-configuration.md)
- [Disable an Agent Profile](requirements/profile-disable.md) - [Disable an Agent Profile](requirements/profile-disable.md)
- [Agent Profile Recent Use](requirements/profile-recent-use.md) - [Agent Profile Recent Use](requirements/profile-recent-use.md)
@@ -82,11 +82,44 @@ my session to an unrelated default model.
variation, it shall keep the saved model unchanged and shall persist a variation, it shall keep the saved model unchanged and shall persist a
warning that identifies the inferred effective model. warning that identifies the inferred effective model.
- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.7:** The host profile probe shall - **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.7:** The host profile probe shall
present unique-variation resolution as an advisory. It shall not become the present unique-variation resolution as an advisory in the profile editor.
launch authority or disable the profile. It shall not become the launch authority or add a model warning to a profile
selector. It shall not disable the profile.
- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.8:** The same resolution order shall - **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-002.8:** The same resolution order shall
apply at initial launch, context reset, and workspace rebind. apply at initial launch, context reset, and workspace rebind.
### 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.
#### Acceptance criteria
- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-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-AGENTS-NO-SILENT-MODEL-FALLBACK-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-AGENTS-NO-SILENT-MODEL-FALLBACK-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-AGENTS-NO-SILENT-MODEL-FALLBACK-003.4:** The profile editor shall retain
its missing-model treatment, unique-variation advisory, and fallback controls.
- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.5:** When the executor applies the
requested model successfully, a host catalog difference shall not cause a
model-selection warning in task chat. Actual fallback warnings shall retain
the persistence and visibility defined by requirement 001.
- **AC-AGENTS-NO-SILENT-MODEL-FALLBACK-003.6:** Selecting a profile shall not
rewrite its saved model, reasoning configuration, or fallback settings.
## Compatibility
The September 2026 selector amendment removes host model advisories from
profile selection. It preserves executor model selection and profile editing.
Delivery is tracked in the [implementation package](../../../plans/profile-selector-model-warnings/plan.md).
## Out of scope ## Out of scope
- Ranking multiple variations by context size, speed, price, or display order. - Ranking multiple variations by context size, speed, price, or display order.
@@ -94,6 +127,7 @@ my session to an unrelated default model.
- Inferring a base model from an already bracketed request. - Inferring a base model from an already bracketed request.
- Rewriting the profile model to the current executor's advertised variation. - Rewriting the profile model to the current executor's advertised variation.
- Changing Office post-start provider routing or mid-turn model switching. - Changing Office post-start provider routing or mid-turn model switching.
- Changing host discovery refresh, cache invalidation, or ACP model normalization.
## System design ## System design
@@ -4,6 +4,7 @@ system: agents
requirements: requirements:
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003
created: 2026-08-23 created: 2026-08-23
owners: owners:
- kandev - kandev
@@ -21,6 +22,7 @@ resolution from a bare requested model to one advertised bracketed variation.
| --- | --- | | --- | --- |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) | | `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Unique model-variation resolution](#unique-model-variation-resolution) | | `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Unique model-variation resolution](#unique-model-variation-resolution) |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003` | [Profile selection and warning placement](#8-profile-selection-and-warning-placement) |
## Migrated source detail ## Migrated source detail
@@ -139,7 +141,7 @@ over `fallback_model`):
| Session start, model selection unsupported | Continue on the agent default and persist a warning. | Same | Same | | Session start, model selection unsupported | Continue on the agent default and persist a warning. | Same | Same |
| Mid-session model/auth failure (office run, post-start) | Unchanged ADR behavior: office re-dispatches via the workspace routing chain (`routingerr.Decide(ContextOffice)`; availability codes → `DecisionFallback`). The profile's model policy does **not** gate office fallback — the workspace routing configuration is the office authorization owner. | Same as default-on-mismatch: `fallback_model` is a session-start policy, not an office routing input. | Legacy: re-dispatch to next candidate in the provider order (unchanged). | | Mid-session model/auth failure (office run, post-start) | Unchanged ADR behavior: office re-dispatches via the workspace routing chain (`routingerr.Decide(ContextOffice)`; availability codes → `DecisionFallback`). The profile's model policy does **not** gate office fallback — the workspace routing configuration is the office authorization owner. | Same as default-on-mismatch: `fallback_model` is a session-start policy, not an office routing input. | Legacy: re-dispatch to next candidate in the provider order (unchanged). |
| Boot reconciliation | Never overwrite a gone start model (keep it; UI shows it red). Same for a gone `fallback_model`. | Same | Same (reconciler is mode-independent). | | Boot reconciliation | Never overwrite a gone start model (keep it; UI shows it red). Same for a gone `fallback_model`. | Same | Same (reconciler is mode-independent). |
| New-task / new-agent profile picker | Profile selectable with a host-catalog warning. | Profile selectable with a host-catalog warning. | Profile selectable with a host-catalog warning. | | New-task / new-agent profile picker | Profile selectable without a host model advisory. | Same. | Same. |
| Model picker (profile editor, session toolbar) | Gone models greyed out, unselectable, visible. | Same. | Same. | | Model picker (profile editor, session toolbar) | Gone models greyed out, unselectable, visible. | Same. | Same. |
`SetModel` failures that mean "this agent does not support model selection" `SetModel` failures that mean "this agent does not support model selection"
@@ -227,17 +229,17 @@ table. This calculation is advisory because the host catalog can differ from
the selected executor catalog. the selected executor catalog.
When the host advertises one variation, the task profile option remains When the host advertises one variation, the task profile option remains
selectable and identifies the possible target in its existing amber advisory. selectable without a host model advisory. The profile editor keeps the saved
The profile editor keeps the saved bare ID visible and describes the unique bare ID visible and describes the unique
host variation instead of reporting that the model is simply gone. Selecting host variation instead of reporting that the model is simply gone. Selecting
the advertised variation remains an explicit way to update the profile. the advertised variation remains an explicit way to update the profile.
When the host advertises zero or multiple variations, the current missing-model When the host advertises zero or multiple variations, the profile editor's
presentation remains. The runtime can still reach a different decision from missing-model presentation remains. The runtime can still reach a different decision from
the selected executor catalog. the selected executor catalog.
The task-create advisory keeps its existing desktop tooltip and coarse-pointer The task-create selector has no host model tooltip or help drawer.
drawer. The profile editor uses its existing responsive selector. The durable The profile editor uses its existing responsive selector. The durable
chat warning uses the existing status-message layout on desktop and mobile. chat warning uses the existing status-message layout on desktop and mobile.
This change adds no nested scroll area or hover-only information. This change adds no nested scroll area or hover-only information.
@@ -496,27 +498,57 @@ All new copy is externalized via `t()` into the `settings` i18n namespace
(`apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/settings.json`) — the i18n ratchet (`apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/settings.json`) — the i18n ratchet
judges added lines even in unmigrated files. judges added lines even in unmigrated files.
### 8. Profile picker warnings (new-task / new-agent) ### 8. Profile selection and warning placement
`apps/web/lib/state/slices/settings/types.ts` — `AgentProfileOption` gains `apps/web/lib/state/slices/settings/types.ts` — `AgentProfileOption` gains
`model`, `fallbackModel`, `autoFallback` (populated in `model`, `fallbackModel`, `autoFallback` (populated in
`toAgentProfileOption`). `toAgentProfileOption`).
`apps/web/components/task-create-dialog-options.tsx` `apps/web/components/task-create-dialog-options.tsx`
(`useAgentProfileOptions`) can compute a host-catalog difference. (`useAgentProfileOptions`) derives profile labels and eligibility without
This difference is advisory only. comparing the saved model with the host model catalog. Remove
`advertisedModelIDs`, `ModelProbeWarning`, and `ModelProbeWarningIndicator` from
this module, plus imports used only by those helpers. The hook no longer needs
`useAvailableAgents` solely to produce model advisories.
- Every profile remains selectable. Both `renderLabel` and `renderTriggerLabel` use the same profile presentation.
- A missing host model shows one amber warning icon beside the profile name. Keep the option interface compatible with its consumers. Preserve profile
- On fine pointers, hovering or focusing the warning icon reveals the full eligibility, recent-use ordering, names, logos, and CLI passthrough indicators.
localized advisory that the executor decides availability at launch. On Keep `getCapabilityWarning` and its authentication, installation, and probe
coarse pointers, tapping the icon opens the same advisory in a drawer. The failure states. Those states describe agent health rather than a model mismatch.
advisory is not shown as an always-visible secondary row in the option list.
- The warning does not promise that an explicit fallback will be available. Consumers include task creation, new subtasks, new sessions, quick chat,
- The warning does not change the saved profile model. automation configuration, and Office setup. All receive this behavior from the
shared hook. Do not add consumer-specific warning suppression flags.
Retain profile model/configuration fields and the editor's
`findUniqueModelVariation`. After checking references, remove these unused
selector keys from every locale:
`profileStartModelNotAdvertisedOnHost` and
`profileStartModelUniqueVariationOnHost`.
`apps/web/app/office/setup/agent-profile-setup-controls.tsx` `apps/web/app/office/setup/agent-profile-setup-controls.tsx`
(`useSelectableProfileOptions`) uses the same advisory behavior. (`useSelectableProfileOptions`) retains its Office eligibility filtering around
the shared profile options.
#### Desktop and mobile composition
Keep the touch-usable combobox and shared selection logic. A row tap completes
the temporary profile choice without another help drawer or an empty hit area.
Preserve keyboard selection, focus return, picker-owned scrolling, and safe-area
constraints. Assert no document horizontal overflow.
Use `e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts` as the shipped
flow and `components/task/mobile/mobile-picker-sheet.tsx` as the curated
temporary-choice precedent. No picker-shell replacement is needed.
#### Rationale and compatibility
A host observation cannot establish the executor outcome. A neutral icon or
better ID comparison would still report valid catalog differences during
selection. Keep the existing executor-authority ADR and runtime behavior.
This change needs no resolver, cache, schema, or new ADR. Explain warning
placement in `docs/public/agents-and-profiles.md` when implementation ships.
The persisted `model_selection_warning` chat message renders through The persisted `model_selection_warning` chat message renders through
`apps/web/components/task/chat/messages/status-message.tsx`. `apps/web/components/task/chat/messages/status-message.tsx`.
@@ -566,8 +598,11 @@ Frontend (Vitest, `*.test.ts(x)`):
- `model-config-selector`: disabled option not selectable; greyed class. - `model-config-selector`: disabled option not selectable; greyed class.
- `session-models` WS handler: stale active model is kept (not cleared). - `session-models` WS handler: stale active model is kept (not cleared).
- `useAgentProfileOptions`: every host-mismatch profile remains selectable and - `useAgentProfileOptions`: every otherwise eligible host-mismatch profile
shows an advisory warning. remains selectable with no model advisory in either label. Cover canonical
IDs, legacy bracketed IDs, unique and multiple variations, and empty catalogs.
- Capability health warnings, recent-use ordering, disabled-profile filtering,
and saved profile values remain unchanged by selector rendering.
- Profile editor: gone start model renders red + disabled; auto-fallback keeps - Profile editor: gone start model renders red + disabled; auto-fallback keeps
the explicit fallback choice visible but disables its controls. the explicit fallback choice visible but disables its controls.
- Profile editors: fallback settings start collapsed; expanding exposes both - Profile editors: fallback settings start collapsed; expanding exposes both
@@ -581,8 +616,11 @@ E2E (Playwright, `apps/web/e2e`):
- Mock backend (`KANDEV_E2E_MOCK=true`): create a profile whose start model - Mock backend (`KANDEV_E2E_MOCK=true`): create a profile whose start model
is not in the host catalog. Make sure that the task-create picker keeps the is not in the host catalog. Make sure that the task-create picker keeps the
profile selectable, shows one warning icon, and reveals the advisory warning profile selectable without a host model warning in the option or selected
through the fine-pointer tooltip or coarse-pointer drawer. label. Select the profile with keyboard on desktop and a row tap on mobile.
- Launch a profile whose executor advertises its exact model while the host
omits it. Verify the selected model, no model-selection warning, and unchanged
profile values after reload.
- Launch with an executor catalog that omits the profile model. Make sure that - Launch with an executor catalog that omits the profile model. Make sure that
no model-selection call occurs, the task continues, and chat shows one warning. no model-selection call occurs, the task continues, and chat shows one warning.
- Reload the task page. Make sure that the warning remains in chat. - Reload the task page. Make sure that the warning remains in chat.
@@ -4,6 +4,7 @@ system: agents
requirements: requirements:
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002
- REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003
created: 2026-08-23 created: 2026-08-23
owners: owners:
- kandev - kandev
@@ -21,6 +22,7 @@ variation resolution.
| --- | --- | | --- | --- |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) | | `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Risks & Open Questions](#risks--open-questions) | | `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002` | [Risks & Open Questions](#risks--open-questions) |
| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003` | [Risks & Open Questions](#risks--open-questions) |
## Migrated source detail ## Migrated source detail
@@ -36,8 +38,8 @@ variation resolution.
workspace routing configuration, not the execution profile — and is workspace routing configuration, not the execution profile — and is
documented in the behavior matrix above. documented in the behavior matrix above.
- **Probe staleness**: the advertised list can be stale (probe cached). - **Probe staleness**: the advertised list can be stale (probe cached).
The profile picker uses it only as a hint. The executor session catalog owns The profile editor uses it only as a hint. Profile selectors do not render
the launch decision. host model advisories. The executor session catalog owns the launch decision.
- **Cold Claude model lists**: a valid restricted model can be absent from a - **Cold Claude model lists**: a valid restricted model can be absent from a
cold bridge's initial list. Pre-session exposure lets the bridge include and cold bridge's initial list. Pre-session exposure lets the bridge include and
select the configured model. If the bridge still omits it, Kandev uses the select the configured model. If the bridge still omits it, Kandev uses the
@@ -46,7 +48,7 @@ variation resolution.
before the initial process starts. It does not restart a live ACP bridge to before the initial process starts. It does not restart a live ACP bridge to
expose a newly selected hidden model during context reset. expose a newly selected hidden model during context reset.
- **Office vs. kanban surfaces**: both share the same agent-profile rows. - **Office vs. kanban surfaces**: both share the same agent-profile rows.
The advisory picker behavior covers kanban task creation and Office setup. The shared selector behavior covers kanban task creation and Office setup.
Office run-detail routing surfaces are unchanged. Office run-detail routing surfaces are unchanged.
- **Collapsed controls remain legible**: the disclosure header summarizes the - **Collapsed controls remain legible**: the disclosure header summarizes the
effective mode, and dirty-state decoration is applied to the disclosure effective mode, and dirty-state decoration is applied to the disclosure
@@ -65,3 +67,9 @@ variation resolution.
- **Legacy automatic fallback remains unchanged**: when `auto_fallback` is - **Legacy automatic fallback remains unchanged**: when `auto_fallback` is
enabled and the requested model is absent, Kandev does not apply an explicit enabled and the requested model is absent, Kandev does not apply an explicit
fallback or infer a variation. It continues with the provider default. fallback or infer a variation. It continues with the provider default.
- **Removal must be narrow**: authentication, missing-CLI, and failed-probe
indicators remain. Profile-editor advisories and persisted task warnings
retain their existing behavior.
- **Evidence has limits**: a model warning does not prove a cache defect.
Discovery refresh or normalization changes require separate root-cause
evidence; this amendment does not change those contracts.