diff --git a/apps/web/components/settings/agents/agent-profiles-section.test.tsx b/apps/web/components/settings/agents/agent-profiles-section.test.tsx index 3a9d785902..9eb90ce076 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.test.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.test.tsx @@ -18,7 +18,8 @@ function profile(id: string, name: string): AgentProfile { } const ALPHA_PROFILE_NAME = "Alpha"; -const PROFILE_ROW_SELECTOR = '[data-testid="agent-profile-row"]'; +const PROFILE_ROW_TEST_ID = "agent-profile-row"; +const PROFILE_ROW_SELECTOR = `[data-testid="${PROFILE_ROW_TEST_ID}"]`; const PROFILE_ACTIONS_MENU_SELECTOR = '[data-testid="profile-actions-menu-p-1"]'; const AGENT = { @@ -115,6 +116,99 @@ function renderRows() { , ); } +describe("ProfileRow fallback summary", () => { + const PROFILE_BADGES_SELECTOR = '[data-slot="badge"]'; + const MODEL_NAME = "start-model"; + beforeEach(() => { + storeState = { + settingsAgents: { items: [] }, + agentProfiles: { items: [] }, + auth: { mode: undefined, user: undefined }, + }; + mocks.responsive.isFullDesktop = false; + mocks.responsive.isFinePointer = false; + }); + afterEach(() => cleanup()); + + it("renders the opaque fallback badge immediately after the model badge", () => { + const fallbackModel = " provider/model:with spaces "; + const fallbackProfile = { + ...profile("p-fallback", "Fallback"), + model: MODEL_NAME, + fallbackModel, + autoFallback: false, + } as AgentProfile; + + renderWithTooltipProvider(); + + const badges = Array.from( + screen.getByTestId(PROFILE_ROW_TEST_ID).querySelectorAll(PROFILE_BADGES_SELECTOR), + ).map((badge) => badge.textContent); + expect(badges).toEqual([MODEL_NAME, `fallback: ${fallbackModel}`]); + }); + it("renders the no-configured-fallback label", () => { + const strictProfile = { + ...profile("p-strict", "Strict"), + model: MODEL_NAME, + fallbackModel: "", + autoFallback: false, + } as AgentProfile; + + renderWithTooltipProvider(); + + const badges = screen + .getByTestId(PROFILE_ROW_TEST_ID) + .querySelectorAll(PROFILE_BADGES_SELECTOR); + expect(badges[1]?.textContent).toBe("fallback: none"); + }); + it("renders exact when exact-model selection keeps a saved explicit fallback", () => { + const exactProfile = { + ...profile("p-exact", "Exact"), + model: MODEL_NAME, + fallbackModel: "saved-explicit-model", + autoFallback: false, + requireExactModel: true, + } as AgentProfile; + + renderWithTooltipProvider(); + + const badges = screen + .getByTestId(PROFILE_ROW_TEST_ID) + .querySelectorAll(PROFILE_BADGES_SELECTOR); + expect(badges[1]?.textContent).toBe("fallback: exact"); + }); + it("renders exact when exact-model selection keeps automatic fallback enabled", () => { + const exactProfile = { + ...profile("p-exact-auto", "Exact automatic"), + model: MODEL_NAME, + fallbackModel: "", + autoFallback: true, + requireExactModel: true, + } as AgentProfile; + + renderWithTooltipProvider(); + + const badges = screen + .getByTestId(PROFILE_ROW_TEST_ID) + .querySelectorAll(PROFILE_BADGES_SELECTOR); + expect(badges[1]?.textContent).toBe("fallback: exact"); + }); + it("renders next when automatic fallback takes precedence", () => { + const automaticProfile = { + ...profile("p-automatic", "Automatic"), + model: MODEL_NAME, + fallbackModel: "saved-explicit-model", + autoFallback: true, + } as AgentProfile; + + renderWithTooltipProvider(); + + const badges = screen + .getByTestId(PROFILE_ROW_TEST_ID) + .querySelectorAll(PROFILE_BADGES_SELECTOR); + expect(badges[1]?.textContent).toBe("fallback: next"); + }); +}); function confirmDeleteFor(name: string) { const row = screen.getByLabelText(name).closest(PROFILE_ROW_SELECTOR); @@ -344,7 +438,7 @@ describe("AgentProfilesSubList layout", () => { renderWithTooltipProvider(); expect(screen.queryByText("2 profiles", { exact: true })).toBeNull(); - expect(screen.getAllByTestId("agent-profile-row")).toHaveLength(2); + expect(screen.getAllByTestId(PROFILE_ROW_TEST_ID)).toHaveLength(2); expect(screen.queryByTestId("new-profile-claude")).toBeNull(); }); diff --git a/apps/web/components/settings/agents/agent-profiles-section.tsx b/apps/web/components/settings/agents/agent-profiles-section.tsx index c452407d8f..340851d794 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.tsx @@ -23,6 +23,7 @@ import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation"; import { useRouter } from "@/lib/routing/client-router"; +import { classifyAgentProfileFallback } from "@/lib/agent-profile-fallback"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import type { Agent, AgentProfile } from "@/lib/types/http"; import { RecordDot } from "@/components/settings/record-dot"; @@ -254,6 +255,16 @@ function ProfileRowCard({ confirmationProps, }: ProfileRowCardProps) { const { isMobile } = useResponsiveBreakpoint(); + const { t } = useTranslation(); + const fallbackState = classifyAgentProfileFallback(profile); + let fallbackLabel = t("agents:fallbackNone"); + if (fallbackState.kind === "exact") { + fallbackLabel = t("agents:fallbackExact"); + } else if (fallbackState.kind === "next") { + fallbackLabel = t("agents:fallbackNext"); + } else if (fallbackState.kind === "model") { + fallbackLabel = t("agents:fallbackModel", { model: fallbackState.model }); + } return ( {profile.name} {profile.enabled === false && } - {(profile.model || profile.mode) && ( -
- {profile.model && {profile.model}} - {profile.mode && {profile.mode}} -
- )} +
+ {profile.model && {profile.model}} + + {fallbackLabel} + + {profile.mode && {profile.mode}} +
{canManage && diff --git a/apps/web/e2e/tests/settings/agents-browse-page.spec.ts b/apps/web/e2e/tests/settings/agents-browse-page.spec.ts index eb9e388250..423fd1a33b 100644 --- a/apps/web/e2e/tests/settings/agents-browse-page.spec.ts +++ b/apps/web/e2e/tests/settings/agents-browse-page.spec.ts @@ -99,4 +99,45 @@ test.describe("Agents browse page", () => { // Compatibility guard: the exact test ID PR #2544 introduced is gone too. await expect(testPage.getByTestId("available-to-install-trigger")).toHaveCount(0); }); + + test("renders the saved fallback summary after the model badge", async ({ + testPage, + apiClient, + }) => { + const { agents } = await apiClient.listAgents(); + const agent = agents[0]; + if (!agent || agent.profiles.length === 0) { + throw new Error("The E2E fixture must provide a configured agent profile"); + } + + const fallbackModel = "saved-explicit-model"; + const profileName = "Desktop fallback summary"; + let profileId: string | undefined; + + try { + await testPage.goto("/settings/agents"); + const seededRow = testPage + .getByTestId("agent-profile-row") + .filter({ hasText: agent.profiles[0].name }); + await expect(seededRow).toBeVisible({ timeout: 15_000 }); + + const profile = await apiClient.createAgentProfile(agent.id, profileName, { + model: agent.profiles[0].model, + fallback_model: fallbackModel, + }); + profileId = profile.id; + await testPage.reload(); + + const row = testPage.getByTestId("agent-profile-row").filter({ hasText: profile.name }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row.locator('[data-slot="badge"]')).toHaveText([ + profile.model, + `fallback: ${fallbackModel}`, + ]); + } finally { + if (profileId) { + await apiClient.deleteAgentProfile(profileId, true); + } + } + }); }); diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-layout.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-layout.spec.ts index 7c4f76b4ac..5601a302db 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-profile-layout.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-profile-layout.spec.ts @@ -53,4 +53,68 @@ test.describe("Agent settings profile layout on mobile", () => { new RegExp(`/settings/agents/${encodeURIComponent(agent.name)}\\?mode=create$`), ); }); + + test("wraps the saved fallback summary without horizontal overflow", async ({ + testPage, + apiClient, + }) => { + const { agents } = await apiClient.listAgents(); + const agent = agents[0]; + if (!agent || agent.profiles.length === 0) { + throw new Error("The E2E fixture must provide a configured agent profile"); + } + + const fallbackModel = `saved-explicit-model-${"x".repeat(128)}`; + const profileName = "Mobile fallback summary"; + let profileId: string | undefined; + + try { + await testPage.goto("/settings/agents"); + const seededRow = testPage + .getByTestId("agent-profile-row") + .filter({ hasText: agent.profiles[0].name }); + await expect(seededRow).toBeVisible({ timeout: 15_000 }); + + const profile = await apiClient.createAgentProfile(agent.id, profileName, { + model: agent.profiles[0].model, + fallback_model: fallbackModel, + }); + profileId = profile.id; + await testPage.reload(); + + const row = testPage.getByTestId("agent-profile-row").filter({ hasText: profile.name }); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect + .poll( + async () => + testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + { timeout: 15_000 }, + ) + .toBe(true); + await expect(row.locator('[data-slot="badge"]')).toHaveText([ + profile.model, + `fallback: ${fallbackModel}`, + ]); + const fallbackBadge = row.locator('[data-slot="badge"]').nth(1); + const [badgeBox, rowBox] = await Promise.all([ + fallbackBadge.boundingBox(), + row.boundingBox(), + ]); + expect(badgeBox).not.toBeNull(); + expect(rowBox).not.toBeNull(); + expect(badgeBox!.width).toBeLessThanOrEqual(rowBox!.width); + expect( + await fallbackBadge.evaluate((element) => element.scrollWidth <= element.clientWidth + 1), + ).toBe(true); + expect( + await fallbackBadge.evaluate((element) => element.scrollHeight <= element.clientHeight + 1), + ).toBe(true); + expect(badgeBox!.y).toBeGreaterThanOrEqual(rowBox!.y - 1); + expect(badgeBox!.y + badgeBox!.height).toBeLessThanOrEqual(rowBox!.y + rowBox!.height + 1); + } finally { + if (profileId) { + await apiClient.deleteAgentProfile(profileId, true); + } + } + }); }); diff --git a/apps/web/lib/agent-profile-fallback.test.ts b/apps/web/lib/agent-profile-fallback.test.ts new file mode 100644 index 0000000000..c0ec3462dd --- /dev/null +++ b/apps/web/lib/agent-profile-fallback.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { classifyAgentProfileFallback } from "./agent-profile-fallback"; + +describe("classifyAgentProfileFallback", () => { + it("classifies a profile with no configured fallback as none", () => { + expect(classifyAgentProfileFallback({ autoFallback: false, fallbackModel: "" })).toEqual({ + kind: "none", + }); + }); + + it("classifies exact-model selection before a saved explicit fallback", () => { + expect( + classifyAgentProfileFallback({ + requireExactModel: true, + autoFallback: false, + fallbackModel: "saved-explicit-model", + }), + ).toEqual({ kind: "exact" }); + }); + + it("classifies exact-model selection before saved automatic fallback", () => { + expect( + classifyAgentProfileFallback({ + requireExactModel: true, + autoFallback: true, + fallbackModel: "", + }), + ).toEqual({ kind: "exact" }); + }); + + it("classifies automatic fallback as next", () => { + expect(classifyAgentProfileFallback({ autoFallback: true, fallbackModel: "" })).toEqual({ + kind: "next", + }); + }); + + it("preserves an explicit fallback model opaquely", () => { + const model = " provider/model:with spaces "; + expect(classifyAgentProfileFallback({ autoFallback: false, fallbackModel: model })).toEqual({ + kind: "model", + model, + }); + }); + + it("gives automatic fallback precedence over an explicit saved model", () => { + expect( + classifyAgentProfileFallback({ autoFallback: true, fallbackModel: "saved-explicit-model" }), + ).toEqual({ kind: "next" }); + }); +}); diff --git a/apps/web/lib/agent-profile-fallback.ts b/apps/web/lib/agent-profile-fallback.ts new file mode 100644 index 0000000000..dca84b0bef --- /dev/null +++ b/apps/web/lib/agent-profile-fallback.ts @@ -0,0 +1,16 @@ +export type AgentProfileFallbackState = + | { kind: "none" } + | { kind: "exact" } + | { kind: "next" } + | { kind: "model"; model: string }; + +export function classifyAgentProfileFallback(profile: { + autoFallback?: boolean; + fallbackModel?: string; + requireExactModel?: boolean; +}): AgentProfileFallbackState { + if (profile.requireExactModel) return { kind: "exact" }; + if (profile.autoFallback) return { kind: "next" }; + if (profile.fallbackModel) return { kind: "model", model: profile.fallbackModel }; + return { kind: "none" }; +} diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index 9c0640712d..7f45a38362 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -6,7 +6,7 @@ // Wired via thin wrappers around the server actions / WS payloads in // `app/actions/agents.ts` and `lib/ws/handlers/agents.ts`. -import type { ProfileEnvVar } from "@/lib/types/http"; +import type { Agent, ProfileEnvVar } from "@/lib/types/http"; import type { AgentProfile, DynamicAgentPolicy, @@ -254,6 +254,16 @@ export function normalizeAgentProfile(raw: unknown): AgentProfile { }; } +/** Normalize all profiles on an agent returned by a boot, HTTP, or WS boundary. */ +export function normalizeAgentProfiles(agent: Agent): Agent { + return { + ...agent, + profiles: Array.isArray(agent.profiles) + ? agent.profiles.map((profile) => normalizeAgentProfile(profile)) + : [], + }; +} + function setPayloadField( payload: Partial, key: K, diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 63b0233d69..664421fd12 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -246,18 +246,11 @@ export async function deleteExecutorProfile( } // Agents -import { normalizeAgentProfile } from "@/lib/api/domains/agent-profile-normalize"; - -function normalizeAgentResponse(agent: Agent): Agent { - return { - ...agent, - profiles: (agent.profiles ?? []).map((profile) => normalizeAgentProfile(profile)), - }; -} +import { normalizeAgentProfiles } from "@/lib/api/domains/agent-profile-normalize"; export async function listAgents(options?: ApiRequestOptions): Promise { const res = await fetchJson("/api/v1/agents", options); - return { ...res, agents: (res.agents ?? []).map(normalizeAgentResponse) }; + return { ...res, agents: (res.agents ?? []).map(normalizeAgentProfiles) }; } export async function listAgentDiscovery( @@ -356,7 +349,7 @@ export async function createCustomTUIAgent( ...options, init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); - return normalizeAgentResponse(res); + return normalizeAgentProfiles(res); } /** @@ -391,7 +384,7 @@ export async function updateCustomTUIAgentMCPStrategy( ...(options?.init ?? {}), }, }); - return normalizeAgentResponse(res); + return normalizeAgentProfiles(res); } export async function fetchDynamicModels( diff --git a/apps/web/lib/state/default-state.test.ts b/apps/web/lib/state/default-state.test.ts index d80c7da666..60b857c305 100644 --- a/apps/web/lib/state/default-state.test.ts +++ b/apps/web/lib/state/default-state.test.ts @@ -40,3 +40,41 @@ describe("failed Inbox hydration state", () => { expect(state.failedInbox.readRevisionByWorkspaceId).toEqual({}); }); }); + +describe("settings agent hydration", () => { + it("normalizes fallback fields from boot-hydrated profiles", () => { + const state = mergeInitialState({ + settingsAgents: { + items: [ + { + profiles: [ + { + id: "explicit-profile", + fallback_model: "provider/model", + auto_fallback: false, + }, + { + id: "automatic-profile", + fallback_model: "", + auto_fallback: true, + }, + ], + }, + ], + }, + } as unknown as HydrationState); + + expect(state.settingsAgents.items[0]?.profiles).toMatchObject([ + { + id: "explicit-profile", + fallbackModel: "provider/model", + autoFallback: false, + }, + { + id: "automatic-profile", + fallbackModel: "", + autoFallback: true, + }, + ]); + }); +}); diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index c657b7af51..39607cf3ca 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -27,6 +27,18 @@ import { seedSettledSessionBoundaries } from "@/lib/state/slices/session/turn-ac import { migrateSidebarViewDraft, migrateView } from "./slices/ui/ui-slice"; import { mergeAgentProfileRecentUseState } from "@/lib/agent-profile-recent-use"; import { normalizeThreadViews } from "./slices/ui/thread-view-builtins"; +import { normalizeAgentProfiles } from "@/lib/api/domains/agent-profile-normalize"; + +function mergeHydratedSettingsAgents( + incoming: HydrationState["settingsAgents"], +): DefaultState["settingsAgents"] { + if (!incoming) return defaultState.settingsAgents; + return { + ...defaultState.settingsAgents, + ...incoming, + items: incoming.items.map(normalizeAgentProfiles), + }; +} export const defaultState = { kanban: defaultKanbanState.kanban, @@ -456,7 +468,7 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState { repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, repositoryScripts: { ...defaultState.repositoryScripts, ...initialState.repositoryScripts }, executors: { ...defaultState.executors, ...initialState.executors }, - settingsAgents: { ...defaultState.settingsAgents, ...initialState.settingsAgents }, + settingsAgents: mergeHydratedSettingsAgents(initialState.settingsAgents), agentDiscovery: { ...defaultState.agentDiscovery, ...initialState.agentDiscovery }, availableAgents: { ...defaultState.availableAgents, ...initialState.availableAgents }, agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index 3c09d6bc97..ede58b9df0 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -425,6 +425,46 @@ describe("mergeInitialState — sidebar views from boot settings", () => { }); }); +describe("hydrateState — settings agent profiles", () => { + it("normalizes fallback fields from nested boot hydration", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + hydrateState(draft, { + settingsAgents: { + items: [ + { + profiles: [ + { + id: "explicit-profile", + fallback_model: "provider/model", + auto_fallback: false, + }, + { + id: "automatic-profile", + fallback_model: "", + auto_fallback: true, + }, + ], + }, + ], + }, + } as unknown as Partial); + }); + + expect(result.settingsAgents.items[0]?.profiles).toMatchObject([ + { + id: "explicit-profile", + fallbackModel: "provider/model", + autoFallback: false, + }, + { + id: "automatic-profile", + fallbackModel: "", + autoFallback: true, + }, + ]); + }); +}); + describe("hydrateState — user settings revisions", () => { it("applies a newer boot snapshot after an earlier websocket update", () => { const result = produce(makeAppDraft(), (draft: Draft) => { diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index 4f7e448949..7dbb54c597 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -27,6 +27,7 @@ import { readMcpAttachmentHistory, shouldReplaceMcpAttachmentHistory, } from "@/lib/state/slices/session-runtime/mcp-attachment-reconciliation"; +import { normalizeAgentProfiles } from "@/lib/api/domains/agent-profile-normalize"; import { preserveOmittedExecutorFields } from "@/lib/kanban/map-task"; import { mergeStepOrderRevisions } from "@/lib/kanban/workflow-step-order"; import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; @@ -170,7 +171,10 @@ function hydrateSettings(draft: Draft, state: HydrationState): void { const preserveLiveAgentProfiles = (state.agentProfiles?.version ?? 0) < draft.agentProfiles.version; if (state.settingsAgents && !preserveLiveAgentProfiles) { - deepMerge(draft.settingsAgents, state.settingsAgents); + deepMerge(draft.settingsAgents, { + ...state.settingsAgents, + items: state.settingsAgents.items.map(normalizeAgentProfiles), + }); } if (state.agentProfiles) { // Preserve a newer profile mutation delivered over WebSocket while this diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 1759db39a7..7fa9412cc6 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -140,6 +140,10 @@ "mcpStrategyOption": "{{name}}: {{mechanism}}", "model": "Model", "modelHeading": "Model", + "fallbackNone": "fallback: none", + "fallbackExact": "fallback: exact", + "fallbackNext": "fallback: next", + "fallbackModel": "fallback: {{model}}", "modelRequiredForAllProfiles": "Model is required for all profiles.", "noAuthHintLogin": "Click Open terminal to sign in, then refresh.", "noAuthHintNotInstalled": "Install the agent CLI, then click Refresh.", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index ac52404f86..56ee639e50 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -140,6 +140,10 @@ "mcpStrategyOption": "{{name}}: {{mechanism}}", "model": "Ḿōďēĺ", "modelHeading": "Ḿōďēĺ", + "fallbackNone": "ƒàĺĺƀàćķ: ńōńē", + "fallbackExact": "ƒàĺĺƀàćķ: ēxàćţ", + "fallbackNext": "ƒàĺĺƀàćķ: ńēxţ", + "fallbackModel": "ƒàĺĺƀàćķ: {{model}}", "modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.", "noAuthHintLogin": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō śĩĝń ĩń, ţĥēń ŕēƒŕēśĥ.", "noAuthHintNotInstalled": "Ĩńśţàĺĺ ţĥē àĝēńţ ĆĹĨ, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 722bd9b31e..99733bc02b 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -128,6 +128,10 @@ "mcpStrategyOption": "{{name}}: {{mechanism}}", "model": "Modelo", "modelHeading": "Modelo", + "fallbackNone": "alternativa: nenhum", + "fallbackExact": "alternativa: exato", + "fallbackNext": "alternativa: seguinte", + "fallbackModel": "alternativa: {{model}}", "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.", "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.", "modelRequiredForAllProfiles": "O modelo é obrigatório em todos os perfis.", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index 2773b708da..3877d0df2c 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -138,6 +138,10 @@ "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", "modelHeading": "模型", + "fallbackNone": "回退:无", + "fallbackExact": "回退:精确模型", + "fallbackNext": "回退:下一个", + "fallbackModel": "回退:{{model}}", "modelRequiredForAllProfiles": "所有配置都需要模型。", "noAuthHintLogin": "点击“打开终端”登录,然后刷新。", "noAuthHintNotInstalled": "安装智能体 CLI,然后点击“刷新”。", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 60421677e6..37daf8f9c6 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -138,6 +138,10 @@ "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", "modelHeading": "模型", + "fallbackNone": "後備:無", + "fallbackExact": "後備:精確模型", + "fallbackNext": "後備:下一個", + "fallbackModel": "後備:{{model}}", "modelRequiredForAllProfiles": "所有設定都需要模型。", "noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。", "noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 2af6c35106..b00a50ac5c 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -138,6 +138,10 @@ "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", "modelHeading": "模型", + "fallbackNone": "備援:無", + "fallbackExact": "備援:精確模型", + "fallbackNext": "備援:下一個", + "fallbackModel": "備援:{{model}}", "modelRequiredForAllProfiles": "所有設定都需要模型。", "noAuthHintLogin": "點選「開啟終端」登入,然後重新整理。", "noAuthHintNotInstalled": "安裝代理程式 CLI,然後點選「重新整理」。", diff --git a/docs/plans/configured-fallback-summary/plan.md b/docs/plans/configured-fallback-summary/plan.md new file mode 100644 index 0000000000..26b8b75176 --- /dev/null +++ b/docs/plans/configured-fallback-summary/plan.md @@ -0,0 +1,87 @@ +--- +created: 2026-09-08 +status: complete +requirements: + - REQ-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001 +system_design: + - ../../specs/agents/system-design/configured-fallback-summary.md +legacy_specs: [] +--- + +# Implementation Plan: Configured Fallback Summary + +## Overview + +Expose each saved agent profile's fallback policy as a pill in the existing +Agents settings profile rows. Implement the pure state classification first, +then render and localize it, and finally prove desktop and mobile behavior. + +## Scope + +### In scope + +- Show `fallback: exact`, `fallback: none`, `fallback: next`, or + `fallback: ` after the saved model pill. +- Derive the display from normalized profile fields with exact-model selection + taking precedence over saved fallbacks, then automatic fallback taking + precedence over an explicit saved model. +- Localize the new labels in all required web locales. +- Preserve wrapping, accessibility, and mobile overflow behavior. + +### Out of scope + +- Runtime model selection or fallback policy changes. +- Profile editor controls or persistence changes. +- Executor catalog probing and effective-model warnings. + +## Technical approach + +Add a pure helper under `apps/web/lib/` that classifies normalized +`AgentProfile` fallback fields into exact, executor-default, automatic, or +explicit-model display states. Use it from `ProfileRowCard` in +`apps/web/components/settings/agents/agent-profiles-section.tsx`, translating +only the display label and keeping model IDs opaque. Keep the existing +flex-wrapped metadata container and place the fallback badge after the model +badge. + +Add the four labels to `agents.json` for `en`, `pt-pt`, `zh-cn`, `zh-hk`, and +`zh-tw`, plus pseudo-locale generation/ratchet coverage through the repository's +existing i18n checks. + +## Tests + +- `apps/web/lib/*fallback*.test.ts`: exact, executor-default, automatic, + explicit, and precedence classification. +- `apps/web/components/settings/agents/agent-profiles-section.test.tsx`: badge + ordering and opaque explicit model rendering. + +## E2E tests + +- Extend `apps/web/e2e/tests/settings/agents-browse-page.spec.ts` with a saved + profile row assertion for the desktop outcome. +- Extend `apps/web/e2e/tests/settings/mobile-agent-profile-layout.spec.ts` with + the same fallback summary assertion and the existing no-horizontal-overflow + check. + +## Work orders + +- [x] [Task 01: Render configured fallback summary](task-01-render-configured-fallback-summary.md) + +## Verification results + +- `make fmt` passed. +- `make typecheck` passed. +- `make lint` passed with an external `TMPDIR` to avoid host temp-disk quota. +- `cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet` passed. +- Focused web tests passed: 17 tests across the helper and profile-row suites. +- Desktop and mobile focused E2E specs passed: 2 tests in each project. +- `make test` reached the backend suite but remains red on unrelated existing + repository/worktree path and error-sanitization tests; an initial attempt + also hit the host temporary-disk quota. + +## Risks + +- Automatic fallback must win when both `auto_fallback` and `fallback_model` + are present, matching runtime precedence. +- Locale catalogs must retain matching keys and placeholders in every required + locale. diff --git a/docs/plans/configured-fallback-summary/task-01-render-configured-fallback-summary.md b/docs/plans/configured-fallback-summary/task-01-render-configured-fallback-summary.md new file mode 100644 index 0000000000..b0f81f131d --- /dev/null +++ b/docs/plans/configured-fallback-summary/task-01-render-configured-fallback-summary.md @@ -0,0 +1,94 @@ +--- +id: "01-render-configured-fallback-summary" +title: "Render configured fallback summary" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001 +acceptance_criteria: + - AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.1 + - AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.2 + - AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.3 + - AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.4 +system_design: + - ../../specs/agents/system-design/configured-fallback-summary.md +--- + +# Task 01: Render configured fallback summary + +## Summary + +Add a pure fallback-state classifier and show its localized result as a badge +following the model badge in shared Agents settings profile rows. Preserve the +existing responsive row layout and saved profile data. + +## In scope + +- Add helper and unit tests for exact, executor-default, automatic, explicit, + and precedence + cases. +- Add the fallback badge and component regression coverage. +- Add required locale keys and update desktop/mobile settings E2E assertions. + +## Out of scope + +- Runtime fallback behavior, executor model catalogs, and profile persistence. +- New settings controls or interaction patterns. + +## Acceptance + +- Profile rows show the four required fallback states in the specified order. +- A saved explicit model is rendered unchanged, while automatic fallback takes + precedence when both fields are set. Exact-model selection takes precedence + when dormant automatic or explicit fallback values are also saved. +- Desktop and phone-sized rows remain accessible, wrapped, and free of document + horizontal overflow. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/*fallback* components/settings/agents/agent-profiles-section +cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet +cd apps && pnpm --filter @kandev/web e2e:run -- settings/agents-browse-page.spec.ts settings/mobile-agent-profile-layout.spec.ts +``` + +## Files likely touched + +- `apps/web/lib/agent-profile-fallback.ts` +- `apps/web/lib/agent-profile-fallback.test.ts` +- `apps/web/components/settings/agents/agent-profiles-section.tsx` +- `apps/web/components/settings/agents/agent-profiles-section.test.tsx` +- `apps/web/src/locales/*/agents.json` +- `apps/web/e2e/tests/settings/agents-browse-page.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-profile-layout.spec.ts` + +## Dependencies + +None. + +## Risks + +- Reusing translated interpolation values for `none` and `next` would leave + those state names untranslated; use dedicated localized labels. +- The shared row is used by mobile and desktop, so badge order must not depend + on viewport branches. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/agents/requirements/configured-fallback-summary.md` +- `docs/specs/agents/system-design/configured-fallback-summary.md` +- Existing `AgentProfile` normalization and profile-row tests. + +## Results + +Implemented the classifier, localized profile-row fallback badge, unit/component +regressions, and desktop/mobile E2E coverage. `make fmt`, `make typecheck`, +`make lint`, i18n checks, focused web tests, and both focused E2E projects pass. +The repository-wide `make test` command reaches unrelated backend test failures +in repository/worktree path and error-sanitization coverage. diff --git a/docs/specs/agents/README.md b/docs/specs/agents/README.md index d4011e84ef..ea1d1f1e84 100644 --- a/docs/specs/agents/README.md +++ b/docs/specs/agents/README.md @@ -28,6 +28,8 @@ surface shared by task and Office consumers. system](../office/README.md). - Presentation-only behavior belongs to the [UI system](../ui/README.md). + + ## Migration record Migration remains in progress while legacy source detail is extracted from the diff --git a/docs/specs/agents/requirements/configured-fallback-summary.md b/docs/specs/agents/requirements/configured-fallback-summary.md new file mode 100644 index 0000000000..9d4dd90943 --- /dev/null +++ b/docs/specs/agents/requirements/configured-fallback-summary.md @@ -0,0 +1,62 @@ +--- +status: active +system: agents +created: 2026-09-08 +owners: + - kandev +--- + +# Configured fallback summary requirements + +## Overview + +Agent profile rows on the Agents settings page expose the fallback policy saved +for each profile. The summary lets users distinguish executor-default behavior, +exact-model selection, automatic selection of the next model, and a configured +explicit fallback without opening the profile editor. + +## Terminology + +- **Exact-model state:** `require_exact_model` is true. Saved automatic or + explicit fallback values are dormant while this policy is enabled. +- **Executor-default state:** `require_exact_model` is false, `auto_fallback` is + false, and `fallback_model` is empty. The executor may use its default model. +- **Automatic fallback state:** `require_exact_model` is false and + `auto_fallback` is true. The configured explicit fallback value is ignored by + runtime precedence. +- **Explicit fallback state:** `require_exact_model` is false, + `auto_fallback` is false, and `fallback_model` is non-empty. + +## Requirements + +### REQ-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001: Show the configured fallback policy + +**Intent:** Make the saved fallback policy visible in the profile list. + +**User story:** As a Kandev user, I want each agent profile row to show its +configured fallback policy, so that I can understand how an unavailable start +model will be handled without opening every profile. + +#### Acceptance criteria + +- **AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.1:** When a profile row is shown + on Settings > Agents, it shall render a fallback pill immediately after the + model pill, with `fallback: exact` for the exact-model state, `fallback: none` + for the executor-default state, `fallback: next` for the automatic fallback + state, or `fallback: ` for the explicit fallback state. +- **AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.2:** The fallback pill shall use + the profile's saved `require_exact_model`, `auto_fallback`, and + `fallback_model` values, and shall not change those values or infer runtime + availability. +- **AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.3:** The fallback summary shall + remain readable and wrap with the existing profile metadata on phone-sized + screens without introducing document-level horizontal overflow. +- **AC-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001.4:** The fallback summary shall + be localized through the Agents translation namespace while preserving the + configured model identifier as opaque product data. + +## Out of scope + +- Changing runtime fallback precedence or model selection. +- Editing profile fallback settings from the profile list. +- Showing executor availability, runtime effective models, or fallback warnings. diff --git a/docs/specs/agents/system-design/configured-fallback-summary.md b/docs/specs/agents/system-design/configured-fallback-summary.md new file mode 100644 index 0000000000..fa9f1b8c92 --- /dev/null +++ b/docs/specs/agents/system-design/configured-fallback-summary.md @@ -0,0 +1,78 @@ +--- +status: current +system: agents +requirements: + - REQ-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001 +created: 2026-09-08 +owners: + - kandev +--- + +# Configured fallback summary system design + +## Context and boundaries + +The agent system owns the saved `AgentProfile` model policy. The Agents settings +page presents that policy as a read-only summary; it does not consult executor +catalogs or runtime session state. Runtime precedence remains defined by the +[no silent model fallback design](no-silent-model-fallback-01.md). + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-AGENTS-CONFIGURED-FALLBACK-SUMMARY-001` | Profile-row presentation and responsive behavior | + +## Profile-row presentation + +`apps/web/components/settings/agents/agent-profiles-section.tsx` remains the +shared renderer for profile rows on the Agents index and grouped agent cards. +The canonical `AgentProfile` fields `requireExactModel`/`require_exact_model`, +`autoFallback`/`auto_fallback`, and `fallbackModel`/`fallback_model` are already +normalized at the API boundary. + +A small pure presentation helper shall classify the profile into `exact`, +`none`, `next`, or an explicit model identifier using this precedence: + +1. `require_exact_model` true -> `exact`. +2. Otherwise, `auto_fallback` true -> `next`. +3. Otherwise, a non-empty `fallback_model` -> that model identifier. +4. Otherwise -> `none` (the executor-default state). + +The row shall render the existing model `Badge` first and the new fallback +`Badge` immediately after it. The fallback badge may appear alongside the +existing mode badge. The model identifier is interpolated unchanged; the +helper must not trim, validate, or compare it against advertised models. + +The translation namespace `agents` owns separate localized labels for the exact, +executor-default, and automatic states plus an interpolated explicit-model +label. No module-scope translation call is permitted. + +## Responsive and accessibility behavior + +The row keeps its existing flex-wrap metadata layout and link/action hit areas. +The additional badge is content-only: desktop retains the current row anatomy, +and phone-sized layouts wrap the badges instead of adding a second scroll owner +or changing navigation. Existing profile-row links remain the accessible +navigation target; the fallback badge is non-interactive and does not obscure +that target. + +The nearest shipped mobile exemplar is the existing profile-row layout covered +by `mobile-agent-profile-layout.spec.ts`; its flex wrapping and horizontal +overflow assertion remain the geometry baseline. A focused mobile E2E assertion +shall verify the fallback text in the same row and preserve the no-overflow +contract. + +## Verification boundaries + +Unit coverage tests the pure classification helper for exact, executor-default, +automatic, and explicit profiles, including exact precedence when dormant +fallback values are saved and automatic precedence when exact mode is off. +Component coverage verifies ordering after the model badge and opaque model +rendering. Desktop and mobile Playwright coverage verifies the rendered row from +real saved profile data and confirms the phone layout remains usable. + +## Related design + +- [No Silent Model Fallback Part 1](no-silent-model-fallback-01.md) +- [No Silent Model Fallback Part 2](no-silent-model-fallback-02.md)