From 17562fcb665082b59b808b92b5c72e2d509eda52 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:16:33 +0200 Subject: [PATCH] fix(app): stop New workspace sending a mode the provider doesn't offer (#5847) The New workspace screen submitted the composer's remembered mode as-is, so a saved mode for a provider that offers no modes (or no longer offers that one) failed creation with "Invalid mode '' for provider ''". A new chat tab and the workspace setup dialog already reconciled the mode against the provider's modes, each with its own copy of the rule. The draft composer state now exposes effectiveModeId, the mode the picker shows, and all three create paths submit it. While the provider is still loading, the selected mode is kept because its modes aren't known yet. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Matt Cowger --- .../src/components/workspace-setup-dialog.tsx | 14 +---- .../app/src/composer/draft/input-draft.ts | 10 ++++ .../app/src/composer/draft/workspace-tab.tsx | 44 +++++---------- .../provider-selection.test.ts | 54 +++++++++++++++++++ .../provider-selection/provider-selection.ts | 17 ++++++ .../app/src/screens/new-workspace-screen.tsx | 6 +-- 6 files changed, 98 insertions(+), 47 deletions(-) diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1954394176..3a326aed3d 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -125,8 +125,7 @@ function buildCreateAgentOptions({ provider, }: { composerState: { - modeOptions: { id: string }[]; - selectedMode: string; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; }; @@ -137,20 +136,11 @@ function buildCreateAgentOptions({ workspaceId: string; provider: CreateAgentRequestOptions["provider"]; }): CreateAgentRequestOptions { - // Reconcile the selected mode against the discovered modes. The mode picker - // shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale - // globally-remembered mode this workspace's provider config no longer - // defines), so the submitted mode must match that display rather than send a - // stale mode the provider would reject. - const modeOptionIds = composerState.modeOptions.map((mode) => mode.id); - const reconciledMode = modeOptionIds.includes(composerState.selectedMode) - ? composerState.selectedMode - : (modeOptionIds[0] ?? ""); return { provider, cwd: workspaceDirectory, workspaceId, - ...(reconciledMode !== "" ? { modeId: reconciledMode } : {}), + ...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 7e6805b0b3..c64753694a 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -17,6 +17,7 @@ import { } from "@/composer/draft/input-draft-core"; import { buildDraftCommandTarget, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, type ProviderSelectionState, @@ -46,6 +47,7 @@ interface UseAgentInputDraftInput { type DraftComposerState = UseAgentFormStateResult & { workingDir: string; + effectiveModeId: string; effectiveModelId: string; effectiveThinkingOptionId: string; featureValues: Record | undefined; @@ -256,6 +258,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ], ); + const effectiveModeId = useMemo( + () => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading), + [formState.isModelLoading, providerSelection], + ); + const effectiveModelId = useMemo( () => resolveEffectiveComposerModelId(providerSelection), [providerSelection], @@ -315,6 +322,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { ...formState, workingDir, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, featureValues: draftFeatureValues, @@ -329,6 +337,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }, [ commandDraft, composerOptions, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, draftFeatures, @@ -355,6 +364,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr export const __private__ = { resolveDraftKey, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, buildDraftCommandTarget, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 6e9c45eab5..4aa22a324d 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -86,46 +86,30 @@ function resolveAutoSubmitConfig( }; } -// Reconcile the form's selected mode against the currently discovered modes. -// The mode picker displays modeOptions[0] when the stored mode isn't in the -// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config -// no longer defines), so the submitted mode must match that display — otherwise -// we'd send a stale mode the provider rejects while the UI showed a valid one. -function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string { - if (modeOptionIds.length === 0) { - return ""; - } - return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? ""); -} - function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): { modeId: string } | Record { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId) { return { modeId: autoSubmitConfig.modeId }; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return { modeId: reconciled }; + if (effectiveModeId !== "") { + return { modeId: effectiveModeId }; } return {}; } function resolveDraftModeId(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): string | null { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId !== undefined) { return autoSubmitConfig.modeId; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return reconciled; + if (effectiveModeId !== "") { + return effectiveModeId; } return null; } @@ -143,8 +127,7 @@ async function submitDraftCreateRequest(input: { autoSubmitConfig: AutoSubmitConfig | null; composerState: { selectedProvider: string | null; - selectedMode: string; - modeOptions: readonly { id: string }[]; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; featureValues: Record | undefined; @@ -177,8 +160,7 @@ async function submitDraftCreateRequest(input: { } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const config = buildWorkspaceDraftAgentConfig({ provider, @@ -219,8 +201,7 @@ function buildDraftAgentSnapshot(input: { composerState: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - modeOptions: readonly { id: string }[]; - selectedMode: string; + effectiveModeId: string; selectedProvider: string | null; agentControls: { features?: Agent["features"] }; }; @@ -234,8 +215,7 @@ function buildDraftAgentSnapshot(input: { autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null); const modeId = resolveDraftModeId({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; if (!provider) { diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 690bd439af..6c13ad3d83 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -9,6 +9,7 @@ import { buildSelectedTriggerLabel, filterAndRankModelRows, matchesModelSearch, + resolveEffectiveComposerModeId, resolveSelectedModelLabel, resolveSubmissionReadiness, } from "./provider-selection"; @@ -441,3 +442,56 @@ function getAllModelLabels(providers: ReturnType { + const selection = { + provider: "pi", + modelId: "", + thinkingOptionId: "", + availableModels: [], + }; + + it("drops a remembered mode when the provider offers no modes", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, false), + ).toBe(""); + }); + + it("submits the mode the picker shows when the remembered mode is not offered", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "plan", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("build"); + }); + + it("keeps a selected mode the provider offers", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "ask", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("ask"); + }); + + it("keeps the selected mode while the provider's modes are still loading", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, true), + ).toBe("build"); + }); +}); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index ae00dd9528..f66a1b1476 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -272,6 +272,23 @@ export function resolveEffectiveComposerModelId(selection: ProviderSelectionStat ); } +// The mode picker shows the first offered mode when the selected one isn't +// offered (e.g. a remembered mode the provider no longer advertises), so the +// submitted mode must match that display. A provider with no modes gets none. +// While the provider is still loading its modes aren't known yet, so the +// selected mode is kept. +export function resolveEffectiveComposerModeId( + selection: ProviderSelectionState, + isModelLoading: boolean, +): string { + const selectedModeId = selection.modeId.trim(); + if (isModelLoading) { + return selectedModeId; + } + const modeIds = selection.modeOptions.map((mode) => mode.id); + return modeIds.includes(selectedModeId) ? selectedModeId : (modeIds[0] ?? ""); +} + export function resolveEffectiveComposerThinkingOptionId( selection: ProviderSelectionState, effectiveModelId: string, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 1cf8d76f23..39c414d9e7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -891,7 +891,7 @@ function buildWorkspaceDraftSetupFromComposer(input: { return { provider: input.provider, cwd: input.cwd, - modeId: input.composerState.selectedMode || null, + modeId: input.composerState.effectiveModeId || null, model: input.composerState.effectiveModelId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, featureValues: input.composerState.featureValues ?? {}, @@ -971,7 +971,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise