From c8cb740b93804c4b64c7f2b68e60c99ae3089c33 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 8 Jun 2026 12:49:53 -0700 Subject: [PATCH] FN-6026: use model dropdowns for workflow lane settings Use the shared model picker for workflow lane settings instead of raw provider/model text entry. - replace built-in workflow model provider/model string fields with combined dropdown controls in the Values tab - fetch available models and save or clear paired provider/model workflow values together, including fallback lanes - add regression coverage for dropdown rendering, selection, clearing, empty registries, and paired-key validation errors - update dashboard and settings documentation to describe the shared workflow model picker behavior Files changed: docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 22 +- packages/dashboard/app/components/WorkflowSettingsPanel.tsx | 241 +++++++++++++++++++-- packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx | 133 +++++++++++- 4 files changed, 369 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-6026 Fusion-Task-Lineage: 7f41120f-f764-4011-9aa5-a323e0aa64d6 --- docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 22 +- .../app/components/WorkflowSettingsPanel.tsx | 241 ++++++++++++++++-- .../__tests__/WorkflowSettingsPanel.test.tsx | 133 +++++++++- 4 files changed, 369 insertions(+), 31 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 13fe5d076f..41497bfd16 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -108,8 +108,8 @@ Navigation: Behavior: - Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels - Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology. -- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Definitions remain available for custom workflow schema authoring. -- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those controls write workflow setting values for the active default workflow. +- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring. +- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those dropdown controls write workflow setting values for the active default workflow. - On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata - On viewports `<=768px`, the editor switches to a full-screen mobile sheet, opens to the workflow list with no workflow preselected, prompts users to select a workflow to edit, and uses larger workflow-editor touch targets so each section remains scrollable and usable on phones - The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 70ca351211..3ebd4e11f1 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -183,9 +183,9 @@ govern that execution belong to the workflow. **Where to set them.** The common model lanes for a project's default workflow are available directly in **Settings → Project Models → Default workflow model lanes**: -Plan/Triage, Executor, and Reviewer. Those controls still write workflow setting -values for the active project's default workflow; they do not restore the old -project settings keys. +Plan/Triage, Executor, and Reviewer. Those dropdown controls use the shared model +picker and still write workflow setting values for the active project's default +workflow; they do not restore the old project settings keys. For step execution, review/approval policy, fallbacks, title summarization, and custom workflow settings, open the **workflow editor** (the workflow node editor in @@ -194,8 +194,11 @@ the dashboard) and select the **Settings** panel. It has two tabs: - **Definitions** — the typed declarations and defaults (read-only for the built-in `builtin:coding` workflow; editable for custom workflows). - **Values** — the per-project values for the workflow that is open. Values are - editable for any workflow, including built-ins. Edits batch and commit through a - single **Save** in the Values tab. + editable for any workflow, including built-ins. Common provider/model lane pairs + (Plan/Triage, Executor, Reviewer, and fallbacks declared by the workflow) use the + same model dropdown picker as Project Models so clearing or selecting a model + updates both keys together. Advanced/custom non-model settings still use typed + controls. Edits batch and commit through a single **Save** in the Values tab. **How values resolve.** The engine resolves *effective settings* per task as `stored value ?? declaration default`. A built-in workflow with no stored value @@ -231,9 +234,10 @@ These groups moved out of project settings and into workflow settings (built-in | **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) | In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor, -and Reviewer controls for the default workflow. Former locations for advanced -workflow policy still show a short redirect stub linking to the workflow editor -(for one release). +and Reviewer dropdown controls for the default workflow. The workflow editor's +Settings → Values tab uses the same dropdown picker for declared provider/model +pairs, including fallbacks. Former locations for advanced workflow policy still +show a short redirect stub linking to the workflow editor (for one release). > Note: the global baseline model lanes (`executionGlobalProvider` etc.) and > integrity guarantees stay where they are — only the per-workflow process policy @@ -766,7 +770,7 @@ Short-lived token bounds are enforced server-side: ## Model Selection Hierarchy -Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited from Settings -> Project Models -> Default workflow model lanes. +Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. ### Planning model diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx index 7a033fd0d5..04bcf7727b 100644 --- a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx +++ b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx @@ -26,7 +26,7 @@ * silently rebinding writes. With no active project it shows a requires-project * state and no write path. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2, AlertTriangle, ChevronRight, ChevronDown, Save, RotateCcw } from "lucide-react"; import type { @@ -36,9 +36,11 @@ import type { WorkflowSettingRejection, } from "../api"; import { + fetchModels, fetchWorkflowSettingValues, updateWorkflowSettingValues, ApiRequestError, + type ModelInfo, type WorkflowSettingValuesPayload, } from "../api"; import { @@ -47,6 +49,7 @@ import { WORKFLOW_SETTING_GROUP_LABELS, } from "./workflow-setting-display"; import { + SettingsFieldRow, SettingsToggleRow, SettingsNumberRow, SettingsSelectRow, @@ -54,6 +57,7 @@ import { SettingsTextareaRow, } from "./settings"; import type { ToastType } from "../hooks/useToast"; +import { CustomModelDropdown } from "./CustomModelDropdown"; import "./WorkflowSettingsPanel.css"; interface WorkflowSettingsPanelProps { @@ -487,6 +491,73 @@ function rawValueDisplay(value: unknown): string { } } +interface WorkflowModelLanePair { + id: string; + providerId: string; + modelId: string; + label: string; + help: string; +} + +const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [ + { + id: "planning", + providerId: "planningProvider", + modelId: "planningModelId", + label: "Plan/Triage Model", + help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.", + }, + { + id: "execution", + providerId: "executionProvider", + modelId: "executionModelId", + label: "Executor Model", + help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.", + }, + { + id: "validator", + providerId: "validatorProvider", + modelId: "validatorModelId", + label: "Reviewer Model", + help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.", + }, + { + id: "planning-fallback", + providerId: "planningFallbackProvider", + modelId: "planningFallbackModelId", + label: "Planning Fallback Model", + help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", + }, + { + id: "validator-fallback", + providerId: "validatorFallbackProvider", + modelId: "validatorFallbackModelId", + label: "Reviewer Fallback Model", + help: "Fallback provider and model used when the primary Reviewer model cannot be used.", + }, + { + id: "title-summarizer", + providerId: "titleSummarizerProvider", + modelId: "titleSummarizerModelId", + label: "Title Summarizer Model", + help: "Provider and model used for title summarization when this workflow declares the lane.", + }, + { + id: "title-summarizer-fallback", + providerId: "titleSummarizerFallbackProvider", + modelId: "titleSummarizerFallbackModelId", + label: "Title Summarizer Fallback Model", + help: "Fallback provider and model used for title summarization when this workflow declares the lane.", + }, +]; + +function splitModelDropdownValue(value: string): { provider: string; modelId: string } | null { + if (!value) return null; + const slashIdx = value.indexOf("/"); + if (slashIdx <= 0 || slashIdx === value.length - 1) return null; + return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) }; +} + function ValuesTab({ workflowId, settings, @@ -510,7 +581,27 @@ function ValuesTab({ const [rejections, setRejections] = useState>({}); const [saving, setSaving] = useState(false); const [orphanOpen, setOrphanOpen] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const [modelsLoading, setModelsLoading] = useState(false); + const [favoriteProviders, setFavoriteProviders] = useState([]); + const [favoriteModels, setFavoriteModels] = useState([]); const reqSeq = useRef(0); + const modelReqSeq = useRef(0); + + const settingsById = useMemo(() => new Map(settings.map((setting) => [setting.id, setting])), [settings]); + const modelLanePairs = useMemo( + () => + WORKFLOW_MODEL_LANE_CATALOG.filter((pair) => { + const provider = settingsById.get(pair.providerId); + const model = settingsById.get(pair.modelId); + return provider?.type === "string" && model?.type === "string"; + }), + [settingsById], + ); + const modelPairSettingIds = useMemo( + () => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId])), + [modelLanePairs], + ); const staleContext = boundProjectId !== undefined && currentProjectId !== undefined && currentProjectId !== boundProjectId; @@ -537,6 +628,36 @@ function ValuesTab({ void load(); }, [load]); + useEffect(() => { + if (boundProjectId === undefined || modelLanePairs.length === 0) { + setAvailableModels([]); + setFavoriteProviders([]); + setFavoriteModels([]); + setModelsLoading(false); + return; + } + const seq = ++modelReqSeq.current; + setModelsLoading(true); + fetchModels() + .then((res) => { + if (modelReqSeq.current !== seq) return; + setAvailableModels(res.models ?? []); + setFavoriteProviders(res.favoriteProviders ?? []); + setFavoriteModels(res.favoriteModels ?? []); + }) + .catch(() => { + if (modelReqSeq.current === seq) { + setAvailableModels([]); + setFavoriteProviders([]); + setFavoriteModels([]); + addToast(t("workflowSettings.modelsLoadFailed", "Failed to load available models"), "error"); + } + }) + .finally(() => { + if (modelReqSeq.current === seq) setModelsLoading(false); + }); + }, [boundProjectId, modelLanePairs.length, addToast, t]); + // No active project bound → requires-project state, no write path. if (boundProjectId === undefined) { return ( @@ -580,6 +701,22 @@ function ValuesTab({ const clearValue = (id: string) => setValue(id, null); + const setModelPairValue = (pair: WorkflowModelLanePair, value: string) => { + const split = splitModelDropdownValue(value); + setPending((prev) => ({ + ...prev, + [pair.providerId]: split?.provider ?? null, + [pair.modelId]: split?.modelId ?? null, + })); + setRejections((prev) => { + if (!prev[pair.providerId] && !prev[pair.modelId]) return prev; + const next = { ...prev }; + delete next[pair.providerId]; + delete next[pair.modelId]; + return next; + }); + }; + const dirty = Object.keys(pending).length > 0; const save = useCallback(async () => { @@ -611,6 +748,62 @@ function ValuesTab({ } }, [dirty, workflowId, pending, boundProjectId, addToast, t]); + const valueOfSettingId = (id: string): unknown => { + const setting = settingsById.get(id); + return setting ? effectiveOf(setting) : undefined; + }; + + const isCustomizedId = (id: string): boolean => { + const setting = settingsById.get(id); + return setting ? isCustomized(setting) : false; + }; + + const renderModelPairControl = (pair: WorkflowModelLanePair) => { + const providerValue = valueOfSettingId(pair.providerId); + const modelValue = valueOfSettingId(pair.modelId); + const value = typeof providerValue === "string" && typeof modelValue === "string" ? `${providerValue}/${modelValue}` : ""; + const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message; + const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId); + const dropdownDisabled = modelsLoading || availableModels.length === 0; + const emptyHelp = + !modelsLoading && availableModels.length === 0 + ? ` ${t("workflowSettings.noModelsAvailable", "No models are available. Configure authentication before selecting a workflow model.")}` + : ""; + + return ( +
+ setModelPairValue(pair, "")} + > + setModelPairValue(pair, next)} + placeholder={t("workflowSettings.selectModel", "Select a model…")} + defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")} + disabled={dropdownDisabled} + favoriteProviders={favoriteProviders} + favoriteModels={favoriteModels} + /> + + {customized && ( + + {t("workflowSettings.customized", "Customized")} + + )} +
+ ); + }; + const renderValueControl = (setting: WorkflowSettingDefinition) => { const value = effectiveOf(setting); const error = rejections[setting.id]?.message; @@ -739,23 +932,35 @@ function ValuesTab({

) : (
- {groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => ( -
-

- {t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])} -

- {groupSettings.map((setting) => ( -
- {renderValueControl(setting)} - {isCustomized(setting) && ( - - {t("workflowSettings.customized", "Customized")} - - )} -
- ))} -
- ))} + {groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => { + const groupSettingIds = new Set(groupSettings.map((setting) => setting.id)); + const groupPairs = + group === "models" + ? modelLanePairs.filter( + (pair) => groupSettingIds.has(pair.providerId) && groupSettingIds.has(pair.modelId), + ) + : []; + const primitiveSettings = groupSettings.filter((setting) => !modelPairSettingIds.has(setting.id)); + + return ( +
+

+ {t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])} +

+ {groupPairs.map((pair) => renderModelPairControl(pair))} + {primitiveSettings.map((setting) => ( +
+ {renderValueControl(setting)} + {isCustomized(setting) && ( + + {t("workflowSettings.customized", "Customized")} + + )} +
+ ))} +
+ ); + })}
)} diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx index 0aa705f569..72a132bd78 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx @@ -16,11 +16,12 @@ import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; expect.extend(jestDomMatchers); // Keep the real module (type re-exports, ApiRequestError, every other helper) -// and override only the two value-endpoint functions. +// and override only the model/value endpoint functions. vi.mock("../../api", async () => { const actual = await vi.importActual("../../api"); return { ...actual, + fetchModels: vi.fn(), fetchWorkflowSettingValues: vi.fn(), updateWorkflowSettingValues: vi.fn(), }; @@ -31,6 +32,7 @@ import type { WorkflowSettingDefinition, WorkflowSettingValuesPayload } from ".. import { ApiRequestError } from "../../api"; import { WorkflowSettingsPanel } from "../WorkflowSettingsPanel"; +const mockFetchModels = vi.mocked(apiModule.fetchModels); const mockFetchValues = vi.mocked(apiModule.fetchWorkflowSettingValues); const mockUpdateValues = vi.mocked(apiModule.updateWorkflowSettingValues); @@ -70,7 +72,17 @@ function Host({ const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values")); const openDefinitions = () => fireEvent.click(screen.getByTestId("wf-settings-tab-definitions")); +const modelResponse = { + models: [ + { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }, + { provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet", reasoning: true, contextWindow: 200000 }, + ], + favoriteProviders: [], + favoriteModels: [], +}; + beforeEach(() => { + mockFetchModels.mockResolvedValue(modelResponse); mockFetchValues.mockResolvedValue(payload()); mockUpdateValues.mockResolvedValue(payload()); }); @@ -192,7 +204,7 @@ describe("WorkflowSettingsPanel — Values tab", () => { expect(within(screen.getByTestId("wf-settings-group-review")).getByText("Review & Approval")).toBeInTheDocument(); expect(within(screen.getByTestId("wf-settings-group-steps")).getByText("Step Execution")).toBeInTheDocument(); expect(within(screen.getByTestId("wf-settings-group-advanced")).getByText("Advanced")).toBeInTheDocument(); - expect(screen.getByLabelText("Plan/Triage provider")).toBeInTheDocument(); + expect(screen.getByLabelText("Plan/Triage Model")).toBeInTheDocument(); expect(screen.getByLabelText("Reviewer provider")).toBeInTheDocument(); }); @@ -312,4 +324,121 @@ describe("WorkflowSettingsPanel — Values tab", () => { fireEvent.click(screen.getByTestId("wf-settings-save-values")); await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "timeout-ms": null }, "proj-1")); }); + + const modelDecls: WorkflowSettingDefinition[] = [ + { id: "planningProvider", name: "Planning provider", type: "string" }, + { id: "planningModelId", name: "Planning model", type: "string" }, + { id: "executionProvider", name: "Execution provider", type: "string" }, + { id: "executionModelId", name: "Execution model", type: "string" }, + { id: "validatorProvider", name: "Validator provider", type: "string" }, + { id: "validatorModelId", name: "Validator model", type: "string" }, + { id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" }, + { id: "planningFallbackModelId", name: "Planning fallback model", type: "string" }, + { id: "customModelProvider", name: "Custom model provider", type: "string" }, + ]; + + async function openPlanningDropdown() { + const trigger = await screen.findByLabelText("Plan/Triage Model"); + fireEvent.click(trigger); + return trigger; + } + + it("renders built-in model lane pairs as dropdowns without raw provider/model text inputs", async () => { + mockFetchValues.mockResolvedValue( + payload({ + stored: { planningProvider: "openai", planningModelId: "gpt-5" }, + effective: { planningProvider: "openai", planningModelId: "gpt-5" }, + }), + ); + render(); + + await waitFor(() => expect(mockFetchModels).toHaveBeenCalledTimes(1)); + expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("GPT-5"); + expect(screen.getByLabelText("Executor Model")).toBeInTheDocument(); + expect(screen.getByLabelText("Reviewer Model")).toBeInTheDocument(); + expect(screen.getByLabelText("Planning Fallback Model")).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Plan/Triage provider" })).not.toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: "Plan/Triage model" })).not.toBeInTheDocument(); + expect(screen.getByLabelText("Custom model provider")).toBeInTheDocument(); + expect(screen.getByTestId("wf-settings-customized-planning")).toBeInTheDocument(); + }); + + it("selecting a workflow model writes provider and model id together", async () => { + render(); + await waitFor(() => expect(mockFetchModels).toHaveBeenCalled()); + + await openPlanningDropdown(); + fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i })); + fireEvent.click(screen.getByTestId("wf-settings-save-values")); + + await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1)); + expect(mockUpdateValues).toHaveBeenCalledWith( + "wf-1", + { planningProvider: "anthropic", planningModelId: "claude-sonnet" }, + "proj-1", + ); + }); + + it("clearing a workflow model dropdown writes paired null values", async () => { + mockFetchValues.mockResolvedValue( + payload({ + stored: { planningProvider: "openai", planningModelId: "gpt-5" }, + effective: { planningProvider: "openai", planningModelId: "gpt-5" }, + }), + ); + render(); + await waitFor(() => expect(screen.getByTestId("wf-settings-customized-planning")).toBeInTheDocument()); + + await openPlanningDropdown(); + fireEvent.click(await screen.findByRole("option", { name: /Use inherited\/default model/i })); + fireEvent.click(screen.getByTestId("wf-settings-save-values")); + + await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1)); + expect(mockUpdateValues).toHaveBeenCalledWith( + "wf-1", + { planningProvider: null, planningModelId: null }, + "proj-1", + ); + }); + + it("shows inherited/default dropdown state for undefined values without a customized badge", async () => { + render(); + await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1")); + expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("Use inherited/default model"); + expect(screen.queryByTestId("wf-settings-customized-planning")).not.toBeInTheDocument(); + }); + + it("keeps known model lanes dropdown-backed when the model registry is empty", async () => { + mockFetchModels.mockResolvedValueOnce({ ...modelResponse, models: [] }); + render(); + + await waitFor(() => expect(mockFetchModels).toHaveBeenCalled()); + const trigger = screen.getByLabelText("Plan/Triage Model"); + expect(trigger).toBeDisabled(); + expect(screen.getAllByText(/No models are available/i).length).toBeGreaterThan(0); + expect(screen.queryByLabelText(/^Plan\/Triage provider$/i)).not.toBeInTheDocument(); + }); + + it("surfaces paired-key rejections on the combined row while preserving pending selection", async () => { + mockUpdateValues.mockRejectedValueOnce( + new ApiRequestError("rejected", 400, { + rejections: [{ code: "type-mismatch", settingId: "planningModelId", message: "model is invalid" }], + }), + ); + render(); + await waitFor(() => expect(mockFetchModels).toHaveBeenCalled()); + + await openPlanningDropdown(); + fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i })); + fireEvent.click(screen.getByTestId("wf-settings-save-values")); + + const row = await screen.findByTestId("wf-settings-value-planning"); + expect(within(row).getByRole("alert")).toHaveTextContent("model is invalid"); + expect(within(row).getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet"); + expect(mockUpdateValues).toHaveBeenCalledWith( + "wf-1", + { planningProvider: "anthropic", planningModelId: "claude-sonnet" }, + "proj-1", + ); + }); });