feat(agents): show configured fallback in profile rows (#3754)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -140,6 +140,10 @@
|
||||
"mcpStrategyOption": "{{name}}: {{mechanism}}",
|
||||
"model": "Ḿōďēĺ",
|
||||
"modelHeading": "Ḿōďēĺ",
|
||||
"fallbackNone": "ƒàĺĺƀàćķ: ńōńē",
|
||||
"fallbackExact": "ƒàĺĺƀàćķ: ēxàćţ",
|
||||
"fallbackNext": "ƒàĺĺƀàćķ: ńēxţ",
|
||||
"fallbackModel": "ƒàĺĺƀàćķ: {{model}}",
|
||||
"modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
|
||||
"noAuthHintLogin": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō śĩĝń ĩń, ţĥēń ŕēƒŕēśĥ.",
|
||||
"noAuthHintNotInstalled": "Ĩńśţàĺĺ ţĥē àĝēńţ ĆĹĨ, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -138,6 +138,10 @@
|
||||
"mcpStrategyOption": "{{name}}:{{mechanism}}",
|
||||
"model": "模型",
|
||||
"modelHeading": "模型",
|
||||
"fallbackNone": "回退:无",
|
||||
"fallbackExact": "回退:精确模型",
|
||||
"fallbackNext": "回退:下一个",
|
||||
"fallbackModel": "回退:{{model}}",
|
||||
"modelRequiredForAllProfiles": "所有配置都需要模型。",
|
||||
"noAuthHintLogin": "点击“打开终端”登录,然后刷新。",
|
||||
"noAuthHintNotInstalled": "安装智能体 CLI,然后点击“刷新”。",
|
||||
|
||||
@@ -138,6 +138,10 @@
|
||||
"mcpStrategyOption": "{{name}}:{{mechanism}}",
|
||||
"model": "模型",
|
||||
"modelHeading": "模型",
|
||||
"fallbackNone": "後備:無",
|
||||
"fallbackExact": "後備:精確模型",
|
||||
"fallbackNext": "後備:下一個",
|
||||
"fallbackModel": "後備:{{model}}",
|
||||
"modelRequiredForAllProfiles": "所有設定都需要模型。",
|
||||
"noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。",
|
||||
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。",
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user