From e6eb6655d1c30159286c535c32e93092a7900f6c Mon Sep 17 00:00:00 2001 From: Carlos Florencio Date: Tue, 8 Sep 2026 23:13:22 +0100 Subject: [PATCH 1/2] fix: remove host model warnings from profile selectors --- .../task-create-dialog-options.test.tsx | 171 +++++++------- .../components/task-create-dialog-options.tsx | 101 +------- .../session/model-mismatch-warning-helpers.ts | 13 ++ .../mobile-no-silent-model-fallback.spec.ts | 67 +++--- .../settings/no-silent-model-fallback.spec.ts | 61 +++-- .../profile-model-selection-helpers.ts | 96 ++++++++ apps/web/src/locales/en/settings.json | 2 - apps/web/src/locales/pseudo/settings.json | 2 - apps/web/src/locales/pt-pt/settings.json | 2 - apps/web/src/locales/zh-cn/settings.json | 2 - apps/web/src/locales/zh-hk/settings.json | 2 - apps/web/src/locales/zh-tw/settings.json | 2 - .../profile-selector-model-warnings/plan.md | 203 +++++++++++++++++ .../task-01-remove-host-model-advisories.md | 215 ++++++++++++++++++ docs/public/agents-and-profiles.md | 5 +- docs/specs/agents/README.md | 3 +- .../requirements/no-silent-model-fallback.md | 38 +++- .../no-silent-model-fallback-01.md | 86 +++++-- .../no-silent-model-fallback-02.md | 14 +- 19 files changed, 808 insertions(+), 277 deletions(-) create mode 100644 apps/web/e2e/tests/settings/profile-model-selection-helpers.ts create mode 100644 docs/plans/profile-selector-model-warnings/plan.md create mode 100644 docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md diff --git a/apps/web/components/task-create-dialog-options.test.tsx b/apps/web/components/task-create-dialog-options.test.tsx index 20cd143607..0c25f5f10d 100644 --- a/apps/web/components/task-create-dialog-options.test.tsx +++ b/apps/web/components/task-create-dialog-options.test.tsx @@ -58,19 +58,6 @@ const AGENT_WITH_GPT: 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 DATA_DISABLED = "data-disabled"; const MODEL_PROBE_WARNING_TEST_ID = "agent-profile-model-probe-warning"; @@ -118,14 +105,6 @@ function renderOptions(profiles: AgentProfileOption[]) { return screen.getByTestId("option-0"); } -function getModelProbeWarning() { - return screen.getByTestId(MODEL_PROBE_WARNING_TEST_ID); -} - -function getModelProbeWarningLabel() { - return getModelProbeWarning().getAttribute("aria-label"); -} - beforeEach(() => { vi.clearAllMocks(); setAvailableAgents([AGENT_WITH_GPT]); @@ -226,80 +205,96 @@ describe("useAgentProfileOptions recent-use ordering", () => { }); }); -describe("useAgentProfileOptions executor-authoritative model hint", () => { - it("keeps a profile whose start model is absent from the host probe selectable", () => { - const option = renderOptions([profileOption({ model: GONE_MODEL })]); - expect(option.getAttribute(DATA_DISABLED)).toBeNull(); - expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); - }); - - it("keeps a profile with an available start model selectable", () => { - const option = renderOptions([profileOption({ model: "gpt-5" })]); - expect(option.getAttribute(DATA_DISABLED)).toBeNull(); - }); - - it("names a unique advertised variation without disabling the profile", () => { - setAvailableAgents([AGENT_WITH_OPUS_VARIATION]); - const option = renderOptions([profileOption({ model: "opus" })]); - - expect(option.getAttribute(DATA_DISABLED)).toBeNull(); - expect(getModelProbeWarningLabel()).toContain("opus[1m]"); - }); - - 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" }), +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 })), + }, + }, ]); - expect(option.getAttribute(DATA_DISABLED)).toBeNull(); - expect(getModelProbeWarningLabel()).toContain(GONE_MODEL); - }); - - it("keeps auto-fallback profiles selectable and shows the host probe hint", () => { - 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 })]), - ); - + 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( -
{result.current[0]?.renderTriggerLabel?.()}
+
{option.renderLabel()}
+
{option.renderTriggerLabel?.()}
, ); - - expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); - expect(screen.getByTitle(/claude-gone/)).toBeTruthy(); + 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(); + } }); - 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) => { + const profile = profileOption({ + model: GONE_MODEL, + capability_status, + capability_error: "Agent needs attention", + }); + const { result } = renderHook(() => useAgentProfileOptions([profile])); + const option = result.current[0]!; + render( + +
{option.renderLabel()}
+
{option.renderTriggerLabel?.()}
+
, + ); + 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([]); - const option = renderOptions([profileOption({ model: GONE_MODEL })]); - expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + const profile = profileOption({ model: GONE_MODEL }); + const { rerender } = render(); + const initialLabel = screen.getByTestId("option-0").innerHTML; + setAvailableAgents([AGENT_WITH_GPT]); + rerender(); + expect(screen.getByTestId("option-0").innerHTML).toBe(initialLabel); + expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); }); }); diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 509ec43606..7e1bb501a3 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -1,21 +1,12 @@ "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"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@kandev/ui/drawer"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import type { LocalRepository, Repository, @@ -23,17 +14,13 @@ import type { Executor, ExecutorProfile, } from "@/lib/types/http"; -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 { 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"; import { buildBranchKeywords } from "./branch-picker-options"; import { ensureAgentProfileRecentUseLoaded, @@ -50,50 +37,6 @@ type OptionItem = { disabledReason?: string; }; -function ModelProbeWarning({ note }: { note: string }) { - const usesTouchDrawer = useTouchDrawer(); - const [drawerOpen, setDrawerOpen] = useState(false); - const trigger = ( - - ); - - if (usesTouchDrawer) { - return ( - - {trigger} - - - {note} - {note} - - - - ); - } - - return ( - - {trigger} - {note} - - ); -} - -function ModelProbeWarningIndicator({ note }: { note: string }) { - return ; -} - export function useRepositoryOptions( repositories: Repository[], discoveredRepositories: LocalRepository[], @@ -185,19 +128,11 @@ export function useBranchOptions(branchOptionsRaw: Branch[]) { }, [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( agentProfiles: AgentProfileOption[], context?: AgentProfileRecentUseContext, ): OptionItem[] { const { t } = useTranslation(); - const { items: availableAgents } = useAvailableAgents(); const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); const storeApi = useAppStoreApi(); const recentUseLoaded = useAppStore((state) => !context || state.agentProfileRecentUse.loaded); @@ -223,26 +158,7 @@ export function useAgentProfileOptions( const profileLabel = parts[1] ?? ""; const isPassthrough = profile.cli_passthrough === true; const warning = getCapabilityWarning(profile.capability_status, profile.capability_error); - // The host-utility probe is an editing hint only. The selected - // 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) => ( + const renderProfileLabel = () => ( @@ -251,7 +167,6 @@ export function useAgentProfileOptions( {warning && ( )} - {modelProbeWarning} {isPassthrough && ( @@ -274,15 +189,11 @@ export function useAgentProfileOptions( label: profile.label, disabled: undefined, disabledReason: undefined, - renderLabel: () => - renderProfileLabel(modelProbeNote ? : null), - renderTriggerLabel: () => - renderProfileLabel( - modelProbeNote ? : null, - ), + renderLabel: renderProfileLabel, + renderTriggerLabel: renderProfileLabel, }; }); - }, [agentProfiles, availableAgents, context, dynamicRoutingEnabled, recentProfileIds, t]); + }, [agentProfiles, context, dynamicRoutingEnabled, recentProfileIds, t]); } export function useExecutorOptions(executors: Executor[]): OptionItem[] { diff --git a/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts b/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts index 67a15a9eba..bcd21deb9d 100644 --- a/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts +++ b/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts @@ -43,3 +43,16 @@ export async function readModelSelectionWarnings( const { messages } = await apiClient.listSessionMessages(sessionId); return messages.filter((message) => message.metadata?.kind === "model_selection_warning"); } + +export async function createExecutorOnlyModelProfile(apiClient: ApiClient): Promise { + 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" }], + }); +} diff --git a/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts b/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts index 9b77ccbdaa..240dbd4731 100644 --- a/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts @@ -2,15 +2,18 @@ import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; import { + createExecutorOnlyModelProfile, createModelVariationProfile, createMismatchedProfile, - MODEL_VARIATION_BASE, - UNIQUE_MODEL_VARIATION, - UNADVERTISED_MODEL, } from "../session/model-mismatch-warning-helpers"; +import { launchExecutorOnlyModelProfile } from "./profile-model-selection-helpers"; 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"); try { const kanban = new KanbanPage(testPage); @@ -29,23 +32,25 @@ test.describe("executor-authoritative model selection on mobile", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - const warning = option.getByTestId("agent-profile-model-probe-warning"); - await expect(warning).toBeVisible(); - const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; - await expect(dialog).not.toContainText(warningText); - await warning.tap(); - await expect( - testPage - .locator('[data-slot="drawer-content"][data-state="open"]') - .filter({ hasText: warningText }), - ).toBeVisible(); - await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch selector"); + await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); + await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); + await prCapture.screenshot("mobile-profile-options", { + caption: "Saved profiles remain selectable without host-model warnings.", + }); + await option.tap(); + await expect(testPage.getByRole("listbox")).not.toBeVisible(); + await expect(selector).toContainText(profile.name); + await prCapture.screenshot("mobile-selected-profile", { + caption: "The selected profile keeps its name without a model advisory.", + }); + await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0); + await assertNoDocumentHorizontalOverflow(testPage, "mobile profile selection"); } finally { 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, apiClient, }) => { @@ -70,18 +75,28 @@ test.describe("executor-authoritative model selection on mobile", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - const warning = option.getByTestId("agent-profile-model-probe-warning"); - await expect(warning).toBeVisible(); - 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.tap(); - await expect( - testPage - .locator('[data-slot="drawer-content"][data-state="open"]') - .filter({ hasText: warningText }), - ).toBeVisible(); - await assertNoDocumentHorizontalOverflow(testPage, "mobile unique variation advisory"); + await expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); + await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); + await option.tap(); + await expect(testPage.getByRole("listbox")).not.toBeVisible(); + await expect(selector).toContainText(profile.name); + await expect(selector.locator("button, .tabler-icon-alert-triangle")).toHaveCount(0); + await assertNoDocumentHorizontalOverflow(testPage, "mobile profile selection"); } finally { 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); + } + }); }); diff --git a/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts b/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts index 852ce3d30a..008b2d6ae4 100644 --- a/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts +++ b/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts @@ -1,15 +1,14 @@ import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { + createExecutorOnlyModelProfile, createModelVariationProfile, createMismatchedProfile, - MODEL_VARIATION_BASE, - UNIQUE_MODEL_VARIATION, - UNADVERTISED_MODEL, } from "../session/model-mismatch-warning-helpers"; +import { launchExecutorOnlyModelProfile } from "./profile-model-selection-helpers"; 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"); try { const kanban = new KanbanPage(testPage); @@ -28,24 +27,27 @@ test.describe("executor-authoritative model selection", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - const warning = option.getByTestId("agent-profile-model-probe-warning"); - await expect(warning).toBeVisible(); - const warningText = `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`; - await expect(dialog).not.toContainText(warningText); - await warning.hover(); - await expect( - testPage - .locator('[data-slot="tooltip-content"]:not([data-state="closed"])') - .filter({ hasText: warningText }), - ).toBeVisible(); - await option.click(); + 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); + 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); } finally { 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, apiClient, }) => { @@ -70,18 +72,27 @@ test.describe("executor-authoritative model selection", () => { .getByRole("option", { name: profile.name, exact: false }); await expect(option).toBeVisible(); await expect(option).toBeEnabled(); - const warning = option.getByTestId("agent-profile-model-probe-warning"); - await expect(warning).toBeVisible(); - 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 expect(option.getByTestId("agent-profile-model-probe-warning")).toHaveCount(0); + await expect(option.locator(".tabler-icon-alert-triangle")).toHaveCount(0); 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 { 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); + } + }); }); diff --git a/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts b/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts new file mode 100644 index 0000000000..f84d0b7490 --- /dev/null +++ b/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts @@ -0,0 +1,96 @@ +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 modelTrigger = page.getByRole("button", { name: "Session model settings" }); + await expect(modelTrigger).toContainText("Opus (270k)"); + 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); +} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 54b2dc5a11..27880c9f16 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -503,8 +503,6 @@ "previewSound": "Preview sound", "profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents", "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.", "promptAdd": "Add prompt", "promptEditorAriaLabel": "Prompt editor", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 854dc35322..9377614a10 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -503,8 +503,6 @@ "previewSound": "Ƥŕēvĩēŵ śōũńď", "profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś", "programming": "Ƥŕōĝŕàḿḿĩńĝ", - "profileStartModelNotAdvertisedOnHost": "Ţĥē ĥōśţ ƥŕōƀē ďĩď ńōţ àďvēŕţĩśē {{model}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.", - "profileStartModelUniqueVariationOnHost": "Ţĥē ĥōśţ ƥŕōƀē ƒōũńď ōńē ƥōśśĩƀĺē vàŕĩàţĩōń ōƒ {{model}}: {{variation}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.", "modelVariationAdvisory": "Ţĥē ĥōśţ ćàţàĺōĝ ĥàś ōńē ƥōśśĩƀĺē vàŕĩàţĩōń ōƒ {{model}}: {{variation}}. Ţĥē śàvēď ḿōďēĺ ŕēḿàĩńś ũńćĥàńĝēď ũńţĩĺ ĺàũńćĥ.", "promptAdd": "Àďď ƥŕōḿƥţ", "promptEditorAriaLabel": "Ƥŕōḿƥţ ēďĩţōŕ", diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 3e665b9d50..7b147fe714 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -750,8 +750,6 @@ "taskBehavior": "Comportamento das tarefas", "terminalAndEditors": "Terminal e editores", "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.", "navUnits": "Unidades da organização", "unitsTitle": "Unidades da organização", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 91cc1fd4c9..5451e8f2b4 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -750,8 +750,6 @@ "keyValueAddItem": "添加条目", "keyValueKeyPlaceholder": "键", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。", - "profileStartModelUniqueVariationOnHost": "主机探测发现 {{model}} 的一个可能变体:{{variation}}。所选执行器将在启动时决定使用的模型。", "modelVariationAdvisory": "主机目录中有一个可能的 {{model}} 变体:{{variation}}。保存的模型在启动前保持不变。", "navUnits": "组织单元", "unitsTitle": "组织单元", diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index a4ca7d3ed3..39c7b4ce8d 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -750,8 +750,6 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。", - "profileStartModelUniqueVariationOnHost": "主機探測發現 {{model}} 的一個可能變體:{{variation}}。所選執行器將在啓動時決定使用的模型。", "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。", "navUnits": "組織單元", "unitsTitle": "組織單元", diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index ea271fe70f..b18fa67d28 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -750,8 +750,6 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。", - "profileStartModelUniqueVariationOnHost": "主機探測發現 {{model}} 的一個可能變體:{{variation}}。所選執行器將在啟動時決定使用的模型。", "modelVariationAdvisory": "主機目錄中有一個可能的 {{model}} 變體:{{variation}}。已儲存的模型在啟動前保持不變。", "navUnits": "組織單元", "unitsTitle": "組織單元", diff --git a/docs/plans/profile-selector-model-warnings/plan.md b/docs/plans/profile-selector-model-warnings/plan.md new file mode 100644 index 0000000000..ff960fdae7 --- /dev/null +++ b/docs/plans/profile-selector-model-warnings/plan.md @@ -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. Drop their +exclusive imports and the host-catalog hook dependency. 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: 55 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. +- Removing the host hook can alter capability-loading timing in a caller. + Preserve capability status already carried on profiles; inspect consumers and + keep any required loading in their existing domain data layer. +- 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. diff --git a/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md b/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md new file mode 100644 index 0000000000..cb2ccf7829 --- /dev/null +++ b/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md @@ -0,0 +1,215 @@ +--- +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. + +If removing the host-catalog subscription exposes a caller that relies on it +for capability loading, retain that loading through the caller's existing +domain hook. Do not introduce another model-warning calculation. + +## 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. Capability health, eligibility, and recent-use behavior + remain unchanged. Existing callers retain their domain-level capability loading. +- 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 55 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. + +No backend changes, schema changes, live-instance restarts, or profile rewrites +were required. The screenshot-time browser catalog remains unknown. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index b540377526..b9eba56a1a 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -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 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 profiles without automatic fallback, Kandev follows the four-step order above. diff --git a/docs/specs/agents/README.md b/docs/specs/agents/README.md index 246d795e0b..78215261bc 100644 --- a/docs/specs/agents/README.md +++ b/docs/specs/agents/README.md @@ -50,7 +50,8 @@ surface shared by task and Office consumers. - [Agent MCP Timeout Budgets](requirements/mcp-timeout-budgets.md) - [Mock-agent slow command duration syntax](requirements/mock-agent-slow-duration.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) - [Disable an Agent Profile](requirements/profile-disable.md) - [Agent Profile Recent Use](requirements/profile-recent-use.md) diff --git a/docs/specs/agents/requirements/no-silent-model-fallback.md b/docs/specs/agents/requirements/no-silent-model-fallback.md index 2cc1b853e5..ee7d647477 100644 --- a/docs/specs/agents/requirements/no-silent-model-fallback.md +++ b/docs/specs/agents/requirements/no-silent-model-fallback.md @@ -82,11 +82,44 @@ my session to an unrelated default model. variation, it shall keep the saved model unchanged and shall persist a warning that identifies the inferred effective model. - **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 - launch authority or disable the profile. + present unique-variation resolution as an advisory in the profile editor. + 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 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 - 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. - Rewriting the profile model to the current executor's advertised variation. - Changing Office post-start provider routing or mid-turn model switching. +- Changing host discovery refresh, cache invalidation, or ACP model normalization. ## System design diff --git a/docs/specs/agents/system-design/no-silent-model-fallback-01.md b/docs/specs/agents/system-design/no-silent-model-fallback-01.md index a3dc936212..436c9fd7de 100644 --- a/docs/specs/agents/system-design/no-silent-model-fallback-01.md +++ b/docs/specs/agents/system-design/no-silent-model-fallback-01.md @@ -4,6 +4,7 @@ system: agents requirements: - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003 created: 2026-08-23 owners: - 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-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 @@ -139,7 +141,7 @@ over `fallback_model`): | 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). | | 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. | `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. When the host advertises one variation, the task profile option remains -selectable and identifies the possible target in its existing amber advisory. -The profile editor keeps the saved bare ID visible and describes the unique +selectable without a host model advisory. The profile editor keeps the saved +bare ID visible and describes the unique host variation instead of reporting that the model is simply gone. Selecting the advertised variation remains an explicit way to update the profile. -When the host advertises zero or multiple variations, the current missing-model -presentation remains. The runtime can still reach a different decision from +When the host advertises zero or multiple variations, the profile editor's +missing-model presentation remains. The runtime can still reach a different decision from the selected executor catalog. -The task-create advisory keeps its existing desktop tooltip and coarse-pointer -drawer. The profile editor uses its existing responsive selector. The durable +The task-create selector has no host model tooltip or help drawer. +The profile editor uses its existing responsive selector. The durable chat warning uses the existing status-message layout on desktop and mobile. 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 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 `model`, `fallbackModel`, `autoFallback` (populated in `toAgentProfileOption`). `apps/web/components/task-create-dialog-options.tsx` -(`useAgentProfileOptions`) can compute a host-catalog difference. -This difference is advisory only. - -- Every profile remains selectable. -- A missing host model shows one amber warning icon beside the profile name. -- On fine pointers, hovering or focusing the warning icon reveals the full - localized advisory that the executor decides availability at launch. On - coarse pointers, tapping the icon opens the same advisory in a drawer. The - 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. -- The warning does not change the saved profile model. +(`useAgentProfileOptions`) derives profile labels and eligibility without +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. + +Both `renderLabel` and `renderTriggerLabel` use the same profile presentation. +Keep the option interface compatible with its consumers. Preserve profile +eligibility, recent-use ordering, names, logos, and CLI passthrough indicators. +Keep `getCapabilityWarning` and its authentication, installation, and probe +failure states. Those states describe agent health rather than a model mismatch. + +Consumers include task creation, new subtasks, new sessions, quick chat, +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` -(`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 `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. - `session-models` WS handler: stale active model is kept (not cleared). -- `useAgentProfileOptions`: every host-mismatch profile remains selectable and - shows an advisory warning. +- `useAgentProfileOptions`: every otherwise eligible host-mismatch profile + 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 the explicit fallback choice visible but disables its controls. - 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 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 - through the fine-pointer tooltip or coarse-pointer drawer. + profile selectable without a host model warning in the option or selected + 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 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. diff --git a/docs/specs/agents/system-design/no-silent-model-fallback-02.md b/docs/specs/agents/system-design/no-silent-model-fallback-02.md index 2a712c9fa8..a3d87cd48a 100644 --- a/docs/specs/agents/system-design/no-silent-model-fallback-02.md +++ b/docs/specs/agents/system-design/no-silent-model-fallback-02.md @@ -4,6 +4,7 @@ system: agents requirements: - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-001 - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-002 + - REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003 created: 2026-08-23 owners: - 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-002` | [Risks & Open Questions](#risks--open-questions) | +| `REQ-AGENTS-NO-SILENT-MODEL-FALLBACK-003` | [Risks & Open Questions](#risks--open-questions) | ## Migrated source detail @@ -36,8 +38,8 @@ variation resolution. workspace routing configuration, not the execution profile — and is documented in the behavior matrix above. - **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 launch decision. + The profile editor uses it only as a hint. Profile selectors do not render + 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 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 @@ -46,7 +48,7 @@ variation resolution. before the initial process starts. It does not restart a live ACP bridge to expose a newly selected hidden model during context reset. - **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. - **Collapsed controls remain legible**: the disclosure header summarizes the 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 enabled and the requested model is absent, Kandev does not apply an explicit 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. From 06e75fd9f5531a82464cd004e2ec3a6b3e553d6d Mon Sep 17 00:00:00 2001 From: Carlos Florencio Date: Tue, 8 Sep 2026 23:37:30 +0100 Subject: [PATCH 2/2] fix: preserve capability refresh in profile selectors --- .../task-create-dialog-options.test.tsx | 26 ++++++++++++++++++ .../components/task-create-dialog-options.tsx | 18 ++++++++++--- .../profile-model-selection-helpers.ts | 9 ++++++- .../profile-selector-model-warnings/plan.md | 14 +++++----- .../task-01-remove-host-model-advisories.md | 27 ++++++++++++++----- 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/apps/web/components/task-create-dialog-options.test.tsx b/apps/web/components/task-create-dialog-options.test.tsx index 0c25f5f10d..9450c49459 100644 --- a/apps/web/components/task-create-dialog-options.test.tsx +++ b/apps/web/components/task-create-dialog-options.test.tsx @@ -249,6 +249,7 @@ describe("useAgentProfileOptions model-independent labels", () => { 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, @@ -298,3 +299,28 @@ describe("useAgentProfileOptions model-independent labels", () => { 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( + +
{option.renderLabel()}
+
{option.renderTriggerLabel?.()}
+
, + ); + expect(screen.getAllByTitle("Agent unavailable")).toHaveLength(2); + expect(screen.queryByTestId(MODEL_PROBE_WARNING_TEST_ID)).toBeNull(); +}); diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 7e1bb501a3..edc78de77b 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -15,8 +15,12 @@ import type { ExecutorProfile, } from "@/lib/types/http"; 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"; @@ -133,6 +137,10 @@ export function useAgentProfileOptions( 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); @@ -144,9 +152,13 @@ export function useAgentProfileOptions( void ensureAgentProfileRecentUseLoaded(storeApi); }, [context, recentUseLoaded, storeApi]); return useMemo(() => { + const profilesWithCapabilities = refreshProfileCapabilities( + agentProfiles, + availableAgents.items, + ); // Disabled profiles stay in the store (existing sessions keep their // labels) but are never offered as a choice for new work. - const selectable = agentProfiles.filter((profile) => + const selectable = profilesWithCapabilities.filter((profile) => isSelectableAgentProfile(profile, dynamicRoutingEnabled), ); const orderedProfiles = context @@ -193,7 +205,7 @@ export function useAgentProfileOptions( renderTriggerLabel: renderProfileLabel, }; }); - }, [agentProfiles, context, dynamicRoutingEnabled, recentProfileIds, t]); + }, [agentProfiles, availableAgents.items, context, dynamicRoutingEnabled, recentProfileIds, t]); } export function useExecutorOptions(executors: Executor[]): OptionItem[] { diff --git a/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts b/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts index f84d0b7490..07eff9a910 100644 --- a/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts +++ b/apps/web/e2e/tests/settings/profile-model-selection-helpers.ts @@ -85,8 +85,15 @@ async function assertRequestedModel( 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("Opus (270k)"); + await expect(modelTrigger).toContainText(requestedModelName!); await expect(modelTrigger).toContainText("High"); const saved = await apiClient.getAgentProfile(profile.id); expect(saved.model).toBe(profile.model); diff --git a/docs/plans/profile-selector-model-warnings/plan.md b/docs/plans/profile-selector-model-warnings/plan.md index ff960fdae7..f23cd60c87 100644 --- a/docs/plans/profile-selector-model-warnings/plan.md +++ b/docs/plans/profile-selector-model-warnings/plan.md @@ -93,9 +93,10 @@ commit raw protocol frames or copy the live database into test fixtures. ## Technical approach In `apps/web/components/task-create-dialog-options.tsx`, remove the -model-catalog comparison and the two model-warning renderers. Drop their -exclusive imports and the host-catalog hook dependency. Keep the existing -option interface and shared label rendering compatible with consumers. +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: @@ -177,7 +178,7 @@ 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: 55 targeted unit tests, six desktop browser tests, and four mobile +- 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. @@ -191,9 +192,8 @@ Task 01 records the commands, fixture correction, and mobile interaction detail. - 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. -- Removing the host hook can alter capability-loading timing in a caller. - Preserve capability status already carried on profiles; inspect consumers and - keep any required loading in their existing domain data layer. +- 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 diff --git a/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md b/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md index cb2ccf7829..c9d82e93d0 100644 --- a/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md +++ b/docs/plans/profile-selector-model-warnings/task-01-remove-host-model-advisories.md @@ -162,9 +162,8 @@ 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. -If removing the host-catalog subscription exposes a caller that relies on it -for capability loading, retain that loading through the caller's existing -domain hook. Do not introduce another model-warning calculation. +Keep capability polling active through the shared selector hook. Apply its +snapshot to health fields only. Do not reintroduce model-ID matching. ## Parallelism @@ -189,13 +188,13 @@ domain hook. Do not introduce another model-warning calculation. Completed on 2026-09-08. - Removed host-catalog membership checks and both model-warning render paths - from the shared hook. Capability health, eligibility, and recent-use behavior - remain unchanged. Existing callers retain their domain-level capability loading. + 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 55 tests. +- 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 @@ -211,5 +210,21 @@ Completed on 2026-09-08. - 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.