From 36a13601dd1007bfc4097d605f2fd1327f785eb4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 14:01:00 +0200 Subject: [PATCH] Let users choose the metadata generation model (#3215) Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish. --- .../metadata-generation-settings.spec.ts | 110 +++++++++++ packages/app/e2e/support/helpers/settings.ts | 1 + .../src/components/schedules/schedule-row.tsx | 4 +- packages/app/src/i18n/resources/ar.ts | 15 ++ packages/app/src/i18n/resources/en.ts | 15 ++ packages/app/src/i18n/resources/es.ts | 15 ++ packages/app/src/i18n/resources/fr.ts | 15 ++ packages/app/src/i18n/resources/ja.ts | 15 ++ packages/app/src/i18n/resources/ko.ts | 15 ++ packages/app/src/i18n/resources/pt-BR.ts | 15 ++ packages/app/src/i18n/resources/ru.ts | 15 ++ packages/app/src/i18n/resources/zh-CN.ts | 14 ++ packages/app/src/screens/settings-screen.tsx | 5 + .../settings/metadata-generation-page.tsx | 181 ++++++++++++++++++ packages/app/src/utils/host-routes.test.ts | 1 + packages/app/src/utils/host-routes.ts | 1 + .../structured-generation-providers.test.ts | 33 +++- .../agent/structured-generation-providers.ts | 52 ----- public-docs/metadata-generation.md | 10 +- 19 files changed, 466 insertions(+), 66 deletions(-) create mode 100644 packages/app/e2e/browser/metadata-generation-settings.spec.ts create mode 100644 packages/app/src/screens/settings/metadata-generation-page.tsx diff --git a/packages/app/e2e/browser/metadata-generation-settings.spec.ts b/packages/app/e2e/browser/metadata-generation-settings.spec.ts new file mode 100644 index 0000000000..5cdc507e8d --- /dev/null +++ b/packages/app/e2e/browser/metadata-generation-settings.spec.ts @@ -0,0 +1,110 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getServerId } from "../support/helpers/server-id"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { + expectSettingsHeader, + openHostSection, + openSettingsHost, +} from "../support/helpers/settings"; + +async function openMetadataGenerationSettings(page: Page) { + const serverId = getServerId(); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "metadata"); + await expectSettingsHeader(page, "Metadata"); +} + +async function openManualMetadataModelPicker(page: Page) { + await page.getByRole("button", { name: "Manual", exact: true }).click(); + await page.getByRole("button", { name: /Select model/ }).click(); +} + +test("chooses a metadata model and can return to automatic selection", async ({ + page, +}, testInfo) => { + await openMetadataGenerationSettings(page); + + await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible(); + await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.screenshot({ + path: testInfo.outputPath("metadata-automatic.png"), + fullPage: true, + }); + + await openManualMetadataModelPicker(page); + await page.getByText("Mock Load Test", { exact: true }).click(); + await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("metadata-model-picker.png"), + fullPage: true, + }); + await page.getByText("Ten second stream", { exact: true }).click(); + + await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("metadata-manual-persisted.png"), + fullPage: true, + }); + + await page.getByRole("button", { name: "Automatic", exact: true }).click(); + await page.reload(); + + await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0); +}); + +test("replaces only the first configured metadata model", async ({ page }) => { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await client.getDaemonConfig(); + + try { + await client.patchDaemonConfig({ + metadataGeneration: { + providers: [ + { provider: "mock", model: "five-minute-stream" }, + { provider: "mock", model: "thirty-minute-stream" }, + ], + }, + }); + await openMetadataGenerationSettings(page); + + await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await openManualMetadataModelPicker(page); + await page.getByText("Ten second stream", { exact: true }).click(); + + await expect + .poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers) + .toEqual([ + { provider: "mock", model: "ten-second-stream" }, + { provider: "mock", model: "thirty-minute-stream" }, + ]); + } finally { + try { + await client.patchDaemonConfig({ + metadataGeneration: { + providers: previousConfig.config.metadataGeneration.providers, + }, + }); + } finally { + await client.close().catch(() => undefined); + } + } +}); diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 9a3a125124..6f03dfbdcd 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -36,6 +36,7 @@ type HostSection = | "connections" | "pair-device" | "agents" + | "metadata" | "workspaces" | "providers" | "usage" diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index faf491a35d..8c2e50ba81 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -384,12 +384,12 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", gap: theme.spacing[3], }, leading: { width: PROVIDER_ICON_SIZE, - height: PROVIDER_ICON_SIZE, + height: 20, alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 810c8d3a10..c5c5b0431c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1762,12 +1762,27 @@ export const ar: TranslationResources = { projects: "المشاريع", connections: "اتصالات", agents: "Agents", + metadata: "البيانات الوصفية", workspaces: "Workspaces", providers: "مقدمي الخدمات", usage: "الاستخدام", terminals: "Terminals", host: "نظرة عامة", }, + metadataGeneration: { + title: "إنشاء البيانات الوصفية", + description: + "اختر النموذج الذي يستخدمه Paseo لعناوين مساحات العمل وأسماء الفروع ورسائل الالتزام ومسودات طلبات السحب", + selection: "اختيار النموذج", + automatic: "تلقائي", + preferred: "يدوي", + automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا", + preferredHint: "اختر النموذج الذي يستخدمه Paseo", + model: "النموذج", + fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا", + docs: "الوثائق", + saveError: "تعذر تحديث إنشاء البيانات الوصفية", + }, general: { title: "عام", browserData: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9caf7292b1..dde57b1cef 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1773,12 +1773,27 @@ export const en = { projects: "Projects", connections: "Connections", agents: "Agents", + metadata: "Metadata", workspaces: "Workspaces", providers: "Providers", usage: "Usage", terminals: "Terminals", host: "Overview", }, + metadataGeneration: { + title: "Metadata generation", + description: + "Choose the model Paseo uses for workspace titles, branch names, commit messages, and pull request drafts", + selection: "Model selection", + automatic: "Automatic", + preferred: "Manual", + automaticHint: "Paseo picks a fast available model", + preferredHint: "Choose the model Paseo uses", + model: "Model", + fallbackHint: "If it is unavailable, Paseo falls back to another available model", + docs: "Docs", + saveError: "Unable to update metadata generation", + }, general: { title: "General", browserData: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c5d4a2d27d..b286bf6304 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1805,12 +1805,27 @@ export const es: TranslationResources = { projects: "Proyectos", connections: "Conexiones", agents: "Agents", + metadata: "Metadatos", workspaces: "Workspaces", providers: "Proveedores", usage: "Uso", terminals: "Terminals", host: "Resumen", }, + metadataGeneration: { + title: "Generación de metadatos", + description: + "Elige el modelo que Paseo usa para títulos de espacios de trabajo, nombres de ramas, mensajes de commit y borradores de pull requests", + selection: "Selección de modelo", + automatic: "Automática", + preferred: "Manual", + automaticHint: "Paseo elige un modelo rápido disponible", + preferredHint: "Elige el modelo que usa Paseo", + model: "Modelo", + fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible", + docs: "Documentación", + saveError: "No se pudo actualizar la generación de metadatos", + }, general: { title: "General", browserData: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index bc22d3afeb..d7e05cd9c4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1809,12 +1809,27 @@ export const fr: TranslationResources = { projects: "Projets", connections: "Relations", agents: "Agents", + metadata: "Métadonnées", workspaces: "Workspaces", providers: "Fournisseurs", usage: "Utilisation", terminals: "Terminals", host: "Aperçu", }, + metadataGeneration: { + title: "Génération de métadonnées", + description: + "Choisissez le modèle utilisé par Paseo pour les titres d’espaces de travail, les noms de branches, les messages de commit et les brouillons de pull request", + selection: "Sélection du modèle", + automatic: "Automatique", + preferred: "Manuel", + automaticHint: "Paseo choisit un modèle rapide disponible", + preferredHint: "Choisissez le modèle utilisé par Paseo", + model: "Modèle", + fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible", + docs: "Documentation", + saveError: "Impossible de mettre à jour la génération de métadonnées", + }, general: { title: "Général", browserData: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 07308bb8f1..529d6e157f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1778,12 +1778,27 @@ export const ja: TranslationResources = { projects: "プロジェクト", connections: "接続", agents: "エージェント", + metadata: "メタデータ", workspaces: "ワークスペース", providers: "プロバイダー", usage: "使用状況", terminals: "ターミナル", host: "概要", }, + metadataGeneration: { + title: "メタデータ生成", + description: + "ワークスペースのタイトル、ブランチ名、コミットメッセージ、プルリクエストの下書きに使用するモデルを選択します", + selection: "モデル選択", + automatic: "自動", + preferred: "手動", + automaticHint: "Paseo が利用可能な高速モデルを選択します", + preferredHint: "Paseo が使用するモデルを選択します", + model: "モデル", + fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します", + docs: "ドキュメント", + saveError: "メタデータ生成を更新できません", + }, general: { title: "一般", browserData: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 2762f0174d..f242d311a9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1772,12 +1772,27 @@ export const ko: TranslationResources = { projects: "프로젝트", connections: "연결", agents: "에이전트", + metadata: "메타데이터", workspaces: "워크스페이스", providers: "프로바이더", usage: "사용량", terminals: "터미널", host: "개요", }, + metadataGeneration: { + title: "메타데이터 생성", + description: + "워크스페이스 제목, 브랜치 이름, 커밋 메시지 및 풀 리퀘스트 초안에 사용할 모델을 선택하세요", + selection: "모델 선택", + automatic: "자동", + preferred: "수동", + automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다", + preferredHint: "Paseo에서 사용할 모델을 선택하세요", + model: "모델", + fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다", + docs: "문서", + saveError: "메타데이터 생성을 업데이트할 수 없습니다", + }, general: { title: "일반", browserData: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 4aeba0209f..f307377b79 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1791,12 +1791,27 @@ export const ptBR: TranslationResources = { projects: "Projetos", connections: "Conexões", agents: "Agentes", + metadata: "Metadados", workspaces: "Workspaces", providers: "Provedores", usage: "Uso", terminals: "Terminais", host: "Visão geral", }, + metadataGeneration: { + title: "Geração de metadados", + description: + "Escolha o modelo usado pelo Paseo para títulos de espaços de trabalho, nomes de branches, mensagens de commit e rascunhos de pull request", + selection: "Seleção de modelo", + automatic: "Automática", + preferred: "Manual", + automaticHint: "O Paseo escolhe um modelo rápido disponível", + preferredHint: "Escolha o modelo que o Paseo usa", + model: "Modelo", + fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível", + docs: "Documentação", + saveError: "Não foi possível atualizar a geração de metadados", + }, general: { title: "Geral", browserData: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 37ab6b1cf1..1aacec9748 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1796,12 +1796,27 @@ export const ru: TranslationResources = { projects: "Проекты", connections: "Соединения", agents: "Agents", + metadata: "Метаданные", workspaces: "Workspaces", providers: "Провайдеры", usage: "Использование", terminals: "Terminals", host: "Обзор", }, + metadataGeneration: { + title: "Создание метаданных", + description: + "Выберите модель, которую Paseo использует для названий рабочих пространств, веток, сообщений коммитов и черновиков pull request", + selection: "Выбор модели", + automatic: "Автоматически", + preferred: "Вручную", + automaticHint: "Paseo выбирает быструю доступную модель", + preferredHint: "Выберите модель, которую использует Paseo", + model: "Модель", + fallbackHint: "Если она недоступна, Paseo использует другую доступную модель", + docs: "Документация", + saveError: "Не удалось обновить создание метаданных", + }, general: { title: "Общий", browserData: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1d2bed9259..1d53b78de6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1742,12 +1742,26 @@ export const zhCN: TranslationResources = { projects: "项目", connections: "连接", agents: "Agents", + metadata: "元数据", workspaces: "Workspaces", providers: "Providers", usage: "使用情况", terminals: "Terminals", host: "概览", }, + metadataGeneration: { + title: "元数据生成", + description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型", + selection: "模型选择", + automatic: "自动", + preferred: "手动", + automaticHint: "Paseo 会选择一个可用的快速模型", + preferredHint: "选择 Paseo 使用的模型", + model: "模型", + fallbackHint: "如果不可用,Paseo 会改用其他可用模型", + docs: "文档", + saveError: "无法更新元数据生成设置", + }, general: { title: "通用", browserData: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index af52bb2405..c7e6df8e56 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -36,6 +36,7 @@ import { SquareTerminal, Code2, Smartphone, + Sparkles, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; @@ -107,6 +108,7 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; @@ -184,6 +186,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "connections", labelKey: "settings.hostSections.connections", icon: Network }, { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, + { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, @@ -203,6 +206,8 @@ function renderHostSettingsContent( return ; case "agents": return ; + case "metadata": + return ; case "workspaces": return ; case "providers": diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx new file mode 100644 index 0000000000..3576c23839 --- /dev/null +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { CombinedModelSelector } from "@/components/combined-model-selector"; +import { ExternalLink } from "@/components/ui/external-link"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { settingsStyles } from "@/styles/settings"; + +const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation"; +type SelectionMode = "automatic" | "preferred"; + +export function MetadataGenerationPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId); + const snapshot = useProvidersSnapshot(serverId); + const providers = useMemo( + () => buildSelectableProviderSelectorProviders(snapshot.entries), + [snapshot.entries], + ); + const configuredProviders = config?.metadataGeneration.providers; + const configuredProvider = configuredProviders?.[0] ?? null; + const savedMode: SelectionMode = configuredProvider ? "preferred" : "automatic"; + const [draftMode, setDraftMode] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const mode = draftMode ?? savedMode; + + useEffect(() => { + setDraftMode(null); + }, [configuredProvider?.model, configuredProvider?.provider]); + + const modeOptions = useMemo( + () => [ + { value: "automatic" as const, label: t("settings.metadataGeneration.automatic") }, + { value: "preferred" as const, label: t("settings.metadataGeneration.preferred") }, + ], + [t], + ); + + const saveProviders = useCallback( + async (providersPatch: { provider: string; model?: string }[]) => { + setIsSaving(true); + try { + await patchConfig({ metadataGeneration: { providers: providersPatch } }); + } catch (error) { + setDraftMode(null); + Alert.alert( + t("settings.metadataGeneration.saveError"), + error instanceof Error ? error.message : String(error), + ); + } finally { + setIsSaving(false); + } + }, + [patchConfig, t], + ); + + const handleModeChange = useCallback( + (next: SelectionMode) => { + setDraftMode(next); + if (next === "automatic") { + void saveProviders([]); + } + }, + [saveProviders], + ); + + const handleModelSelect = useCallback( + (provider: AgentProvider, model: string) => { + setDraftMode("preferred"); + void saveProviders([ + { provider, ...(model ? { model } : {}) }, + ...(configuredProviders?.slice(1) ?? []), + ]); + }, + [configuredProviders, saveProviders], + ); + + const handleSelectorOpen = useCallback(() => { + snapshot.refetchIfStale(configuredProvider?.provider); + }, [configuredProvider?.provider, snapshot]); + const handleRetryProvider = useCallback( + (provider: AgentProvider) => snapshot.refresh([provider]), + [snapshot], + ); + const docsLink = useMemo( + () => ( + + ), + [t], + ); + + if (isConfigLoading || !config) { + return ( + + + + ); + } + + return ( + + {t("settings.metadataGeneration.description")} + + + + + {t("settings.metadataGeneration.selection")} + + + {mode === "automatic" + ? t("settings.metadataGeneration.automaticHint") + : t("settings.metadataGeneration.preferredHint")} + + + + + {mode === "preferred" ? ( + + + {t("settings.metadataGeneration.model")} + + {t("settings.metadataGeneration.fallbackHint")} + + + + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.45, + marginHorizontal: theme.spacing[1], + }, + loading: { + alignItems: "center", + justifyContent: "center", + minHeight: 180, + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 29ac067c90..644139e92d 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -248,6 +248,7 @@ describe("host settings section slugs", () => { expect(normalizeHostSectionSlug("connections")).toBe("connections"); expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device"); expect(normalizeHostSectionSlug("agents")).toBe("agents"); + expect(normalizeHostSectionSlug("metadata")).toBe("metadata"); expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); expect(normalizeHostSectionSlug("projects")).toBe("projects"); expect(normalizeHostSectionSlug("providers")).toBe("providers"); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 8c53ef0b45..87e1517884 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -512,6 +512,7 @@ export const HOST_SECTION_SLUGS = [ "connections", "pair-device", "agents", + "metadata", "workspaces", "providers", "usage", diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts index 06c8bed6bd..d34aff9640 100644 --- a/packages/server/src/server/agent/structured-generation-providers.test.ts +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -18,8 +18,17 @@ class ProviderSnapshots { } describe("resolveStructuredGenerationProviders", () => { - test("uses explicit configured provider models without refreshing provider snapshots", async () => { - const snapshots = new ProviderSnapshots([]); + test("tries the configured model before dynamically discovered fallbacks", async () => { + const snapshots = new ProviderSnapshots([ + { + provider: "work-claude", + status: READY, + enabled: true, + models: [ + { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true }, + ], + }, + ]); const providers = await resolveStructuredGenerationProviders({ cwd: "/tmp/repo", @@ -31,8 +40,11 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]); - expect(snapshots.calls).toEqual([]); + expect(providers).toEqual([ + { provider: "mock", model: "ten-second-stream" }, + { provider: "work-claude", model: "claude-haiku-2026" }, + ]); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); test("falls back to dynamic defaults and current selection when no provider is configured", async () => { @@ -155,7 +167,7 @@ describe("resolveStructuredGenerationProviders", () => { ]); }); - test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => { + test("normalizes configured nested-provider aliases from the provider snapshot", async () => { const snapshots = new ProviderSnapshots([ { provider: "opencode", @@ -186,8 +198,8 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]); - expect(snapshots.calls).toEqual([]); + expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); test("keeps explicit candidates when provider snapshots are in error state", async () => { @@ -215,7 +227,10 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]); - expect(snapshots.calls).toEqual([]); + expect(providers).toEqual([ + { provider: "current-provider", model: "configured-model" }, + { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); }); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts index 89a331b66d..2a09ee1844 100644 --- a/packages/server/src/server/agent/structured-generation-providers.ts +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -44,22 +44,6 @@ export async function resolveStructuredGenerationProviders( options: ResolveStructuredGenerationProvidersOptions, ): Promise { const configuredProviders = readConfiguredProviders(options.daemonConfig); - if (configuredProviders.length > 0) { - const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders); - if (explicitProviders.length === configuredProviders.length) { - return dedupeProviders(explicitProviders); - } - - const providerEntries = await options.providerSnapshotManager.listProviders({ - cwd: options.cwd, - wait: false, - }); - const providers = resolveConfiguredProviders(configuredProviders, providerEntries); - if (providers.length > 0) { - return dedupeProviders(providers); - } - } - const providerEntries = await options.providerSnapshotManager.listProviders({ cwd: options.cwd, wait: true, @@ -100,42 +84,6 @@ export async function resolveStructuredGenerationProviders( return dedupeProviders(providers); } -function resolveConfiguredProviders( - configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[], - providerEntries: readonly ProviderSnapshotEntry[], -): StructuredGenerationProvider[] { - const enabledEntries = providerEntries.filter((entry) => entry.enabled); - const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0); - const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); - const providers: StructuredGenerationProvider[] = []; - for (const configured of configuredProviders) { - const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider); - if (resolved) { - providers.push(resolved); - } - } - return providers; -} - -function resolveExplicitConfiguredProviders( - configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[], -): StructuredGenerationProvider[] { - const providers: StructuredGenerationProvider[] = []; - for (const configured of configuredProviders) { - const provider = configured.provider.trim(); - const model = configured.model?.trim(); - if (!provider || !model) { - continue; - } - providers.push({ - provider, - model, - ...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}), - }); - } - return providers; -} - function resolveCurrentSelection( selection: ResolveStructuredGenerationProvidersOptions["currentSelection"], readyEntries: readonly ProviderSnapshotEntry[], diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md index f9e7a7c218..1d23ec661d 100644 --- a/public-docs/metadata-generation.md +++ b/public-docs/metadata-generation.md @@ -37,9 +37,13 @@ Each default is a match on the model id or name, so the first enabled provider t The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected. -## Configuring the providers +## Choose a model -To control which models Paseo uses — for example to keep all metadata generation on one provider, or to prefer a local model — set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults. +Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. + +## Configure a custom fallback order + +To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults. ```json { @@ -60,7 +64,7 @@ Each entry accepts: - `model` (optional) — a specific model id. Omit it to use that provider's default model. - `thinkingOptionId` (optional) — a reasoning/thinking level for models that support one. Falls back to the model's default if the value isn't valid for that model. -Restart the daemon after editing the file. +The Settings screen replaces only the first entry and preserves the rest of a custom list. Restart the daemon after editing the file directly. ## Per-project instructions