fix: promote a flow input to the model button only where its control can edit it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-16 22:16:09 +02:00
co-authored by Claude Opus 5
parent 0de006f47d
commit 7d56253e26
3 changed files with 59 additions and 32 deletions
@@ -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: '' } : {})
})
}
@@ -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.
@@ -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<ProviderField> {
if (wiring.whole) return new Set(PROVIDER_FIELDS)
const wired = (field: ProviderField) => wiring.fields[field] !== undefined
const driven = new Set<ProviderField>()
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')
}
/**