feat(agents): show configured fallback in profile rows (#3754)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-09-17 15:34:41 +02:00
committed by GitHub
parent cf9c98ded0
commit 33b1d23946
23 changed files with 747 additions and 22 deletions
@@ -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(<ProfileRow agent={AGENT} profile={fallbackProfile} />);
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(<ProfileRow agent={AGENT} profile={strictProfile} />);
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(<ProfileRow agent={AGENT} profile={exactProfile} />);
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(<ProfileRow agent={AGENT} profile={exactProfile} />);
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(<ProfileRow agent={AGENT} profile={automaticProfile} />);
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(<AgentProfilesSubList savedAgent={AGENT} agentName="claude" />);
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();
});
@@ -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 (
<Card
// Same surface treatment as the workspace section tiles.
@@ -274,12 +285,16 @@ function ProfileRowCard({
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.enabled === false && <DisabledBadge />}
</div>
{(profile.model || profile.mode) && (
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
<Badge
className="h-auto min-h-5 max-w-full min-w-0 overflow-visible whitespace-pre-wrap break-all text-left"
variant="secondary"
>
{fallbackLabel}
</Badge>
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
</div>
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1">
{canManage &&
@@ -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);
}
}
});
});
@@ -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);
}
}
});
});
@@ -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" });
});
});
+16
View File
@@ -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" };
}
@@ -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<K extends keyof AgentProfilePayload>(
payload: Partial<AgentProfilePayload>,
key: K,
+4 -11
View File
@@ -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<ListAgentsResponse> {
const res = await fetchJson<ListAgentsResponse>("/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(
+38
View File
@@ -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,
},
]);
});
});
+13 -1
View File
@@ -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 },
@@ -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<AppState>) => {
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<AppState>);
});
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<AppState>) => {
+5 -1
View File
@@ -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<AppState>, 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
+4
View File
@@ -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.",
+4
View File
@@ -140,6 +140,10 @@
"mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Ḿōďēĺ",
"modelHeading": "Ḿōďēĺ",
"fallbackNone": "ƒàĺĺƀàćķ: ńōńē",
"fallbackExact": "ƒàĺĺƀàćķ: ēxàćţ",
"fallbackNext": "ƒàĺĺƀàćķ: ńēxţ",
"fallbackModel": "ƒàĺĺƀàćķ: {{model}}",
"modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
"noAuthHintLogin": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō śĩĝń ĩń, ţĥēń ŕēƒŕēśĥ.",
"noAuthHintNotInstalled": "Ĩńśţàĺĺ ţĥē àĝēńţ ĆĹĨ, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
+4
View File
@@ -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.",
+4
View File
@@ -138,6 +138,10 @@
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"fallbackNone": "回退:无",
"fallbackExact": "回退:精确模型",
"fallbackNext": "回退:下一个",
"fallbackModel": "回退:{{model}}",
"modelRequiredForAllProfiles": "所有配置都需要模型。",
"noAuthHintLogin": "点击“打开终端”登录,然后刷新。",
"noAuthHintNotInstalled": "安装智能体 CLI,然后点击“刷新”。",
+4
View File
@@ -138,6 +138,10 @@
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"fallbackNone": "後備:無",
"fallbackExact": "後備:精確模型",
"fallbackNext": "後備:下一個",
"fallbackModel": "後備:{{model}}",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
"noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。",
+4
View File
@@ -138,6 +138,10 @@
"mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"fallbackNone": "備援:無",
"fallbackExact": "備援:精確模型",
"fallbackNext": "備援:下一個",
"fallbackModel": "備援:{{model}}",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
"noAuthHintLogin": "點選「開啟終端」登入,然後重新整理。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點選「重新整理」。",
@@ -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: <model>` 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.
@@ -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.
+2
View File
@@ -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
@@ -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: <configured model>` 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.
@@ -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)