From 7d56253e26a2f133ad3038ef678e1975d2a7a6a0 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 16 Sep 2026 22:16:09 +0200 Subject: [PATCH] fix: promote a flow input to the model button only where its control can edit it Co-Authored-By: Claude Opus 5 (1M context) --- .../FlowChatModelSettings.svelte | 12 ++-- .../conversations/agentChatInputs.test.ts | 14 ++++ .../flows/conversations/agentChatInputs.ts | 65 +++++++++++-------- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte index f43344794f..f27e14749e 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte @@ -24,7 +24,7 @@ import { Plug, Plus } from 'lucide-svelte' import { resource } from 'runed' import { - composerDrivesEffort, + composerDrivenFields, type AgentModelWiring, type ProviderField } from './agentChatInputs' @@ -45,8 +45,10 @@ return name ? values[name] : wiring.fixed[field] } + const driven = $derived(composerDrivenFields(wiring)) + function editable(field: ProviderField): boolean { - return wiring.whole !== undefined || wiring.fields[field] !== undefined + return driven.has(field) } /** Written together, because choosing a resource also invalidates the model. */ @@ -63,7 +65,7 @@ const resourceEditable = $derived(editable('resource')) const modelEditable = $derived(editable('model')) - const effortEditable = $derived(composerDrivesEffort(wiring)) + const effortEditable = $derived(editable('reasoning_effort')) // Wired, but left to the Configure-inputs modal: the button has no model to place it on. const effortInModal = $derived(wiring.fields.reasoning_effort !== undefined && !effortEditable) // Nothing to write: the flow fixes the lot, so the button names it and opens nothing. @@ -180,7 +182,9 @@ // arrives async, so there is nothing to carry the current one against — nor the // effort, which only means something against a model. Cleared only where the // registry can speak for the new provider, for the same reason as `effortPatch`. - model: undefined, + // `''`, not `undefined`: storage drops an undefined key, and the schema's default + // model would then come back under the new provider on reload. + model: '', ...(getReasoningCapability(picked, '').known ? { reasoning_effort: '' } : {}) }) } diff --git a/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts b/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts index 0c4fdbac8a..7c111dbe9d 100644 --- a/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts +++ b/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts @@ -144,6 +144,20 @@ describe('agentModelWiringInputs', () => { expect(agentModelWiringInputs(wiring)).toEqual([]) expect(showsModelButton(wiring)).toBe(false) }) + + // The same one level up: agents on different providers leave the model menu nothing to + // list and no provider to gate a typed id, so the shared model input stays in the modal. + it('leaves a shared model to the modal when the agents fix different providers', () => { + const wiring = resolveAgentModelWiring([ + agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.model })`), + agent( + `({ "kind": "azure_openai", "resource": "$res:u/admin/azure", model: flow_input.model })` + ) + ]) + expect(wiring?.fields.model).toBe('model') + expect(agentModelWiringInputs(wiring)).toEqual([]) + expect(showsModelButton(wiring)).toBe(false) + }) }) // The modal is whatever this does not return, so the two cannot disagree about an input. diff --git a/frontend/src/lib/components/flows/conversations/agentChatInputs.ts b/frontend/src/lib/components/flows/conversations/agentChatInputs.ts index 472929649d..b97130f047 100644 --- a/frontend/src/lib/components/flows/conversations/agentChatInputs.ts +++ b/frontend/src/lib/components/flows/conversations/agentChatInputs.ts @@ -314,38 +314,47 @@ export function agentModelGap(wiring: AgentModelWiring | undefined): string | un } /** - * The flow inputs the model button actually writes, so the modal does not ask for them a - * second time — and, just as much, so it still asks for the ones the button cannot reach. + * The provider fields the model button edits. Everything else wired to an input is left to + * the Configure-inputs modal, and the button draws no control for it. * - * `kind` is the one to watch: the button writes it only alongside a resource, since a - * provider is picked as a pair. A flow that wires `kind` to an input while fixing the - * resource leaves the button nothing to write it with, and hiding it would leave the run - * without a provider kind and no way to supply one. - * - * `reasoning_effort` is the button's only where it has a model to place the effort against - * (see `composerDrivesEffort`). + * A field is the button's only where its control can be used, which each field makes depend + * on the one before it. An input promoted without a usable control is one nothing can edit. + * - `resource` is always usable: the submenu lists the workspace's AI resources. + * - `kind` is written only alongside a resource, since a provider is picked as a pair. Wired + * with the resource fixed, the button has nothing to write it with. + * - `model` needs a provider to list models for and to gate the typed entry: a wired resource + * or kind, or one fixed kind. Agents that fix different kinds leave none. + * - `reasoning_effort` needs a model to place the effort on: a driven model or one fixed + * model. Agents that fix different models leave none. */ +export function composerDrivenFields(wiring: AgentModelWiring): Set { + if (wiring.whole) return new Set(PROVIDER_FIELDS) + const wired = (field: ProviderField) => wiring.fields[field] !== undefined + const driven = new Set() + if (wired('resource')) { + driven.add('resource') + if (wired('kind')) driven.add('kind') + } + const providerKnown = wired('resource') || wired('kind') || fixedOne(wiring, 'kind') + if (wired('model') && providerKnown) driven.add('model') + const modelKnown = driven.has('model') || fixedOne(wiring, 'model') + if (wired('reasoning_effort') && modelKnown) driven.add('reasoning_effort') + return driven +} + +/** Whether every agent fixes the field to the same non-empty literal. */ +function fixedOne(wiring: AgentModelWiring, field: ProviderField): boolean { + const value = wiring.fixed[field] + return value !== undefined && value !== '' +} + +/** The flow inputs the model button writes, so the modal does not ask for them twice. */ export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] { if (!wiring) return [] if (wiring.whole) return [wiring.whole] - const driven: ProviderField[] = ['resource', 'model'] - if (wiring.fields.resource !== undefined) driven.push('kind') - if (composerDrivesEffort(wiring)) driven.push('reasoning_effort') - return driven.map((field) => wiring.fields[field]).filter((name): name is string => !!name) -} - -/** - * Whether the model button draws the thinking control for a wired effort. - * - * Every thinking state but one is usable: a ladder, a typed token, or why the model has - * neither. The exception is a model the button cannot name, as when agents share an effort - * input but fix different models. It would say "Pick a model first" with no model to pick, - * so the effort stays with the modal. - */ -export function composerDrivesEffort(wiring: AgentModelWiring): boolean { - if (wiring.whole) return true - if (wiring.fields.reasoning_effort === undefined) return false - return wiring.fields.model !== undefined || !agentFieldEmpty(wiring, 'model') + return [...composerDrivenFields(wiring)] + .map((field) => wiring.fields[field]) + .filter((name): name is string => !!name) } /** @@ -354,7 +363,7 @@ export function composerDrivesEffort(wiring: AgentModelWiring): boolean { */ export function showsModelButton(wiring: AgentModelWiring | undefined): boolean { if (!wiring) return false - return agentModelWiringInputs(wiring).length > 0 || !agentFieldEmpty(wiring, 'model') + return agentModelWiringInputs(wiring).length > 0 || fixedOne(wiring, 'model') } /**