Skip to content

Commit 0909ac1

Browse files
authored
Merge pull request #197 from panda-z519/fix/subagent-model-picker
fix(subagents): choose a model only from the configured providers
2 parents ca4510e + fb98619 commit 0909ac1

24 files changed

Lines changed: 280 additions & 165 deletions

apps/desktop/src/components/settings/SubagentEditorSheet.tsx

Lines changed: 33 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
groupSubagentModelChoices,
2323
subagentModelChoices,
2424
subagentModelOrphanPin,
25+
subagentModelPinParts,
2526
subagentModelSelectValue,
2627
} from "./subagent-models";
2728

@@ -180,9 +181,14 @@ export function subagentDraftError(draft: SubagentDraft): string | null {
180181
if (!subagentSlug(draft.name)) return "extensions.subagents.errorSlug";
181182
if (!draft.description.trim()) return "extensions.subagents.errorDescription";
182183
if (draft.tools.length === 0) return "extensions.subagents.errorTools";
183-
// `provider/model` is the only shape main can resolve; a bare model id has no
184-
// provider to look up, so it would be dropped with a diagnostic nobody reads.
185-
if (draft.model.trim() && !/^[^/\s]+\/.+$/.test(draft.model.trim())) {
184+
// `provider/model` is the only shape the runtime can resolve; a bare model id
185+
// has no provider to look up, so it would be dropped with a diagnostic nobody
186+
// reads. Only the slash is structural: the provider half is matched by a
187+
// normalized alias, and a custom endpoint's display name may contain spaces —
188+
// the picker offers those, so rejecting them here would make a selectable
189+
// option impossible to save. This shares the picker's own splitter so the two
190+
// can never disagree.
191+
if (draft.model.trim() && !subagentModelPinParts(draft.model.trim())) {
186192
return "extensions.subagents.errorModel";
187193
}
188194
// 0 is the cleared state, not an invalid one: a definition may leave the turn
@@ -326,9 +332,14 @@ function ManagementScope({
326332
);
327333
}
328334

329-
const CUSTOM_SUBAGENT_MODEL_VALUE = "__custom__";
330-
331-
/** Model and thinking controls for a subagent definition. */
335+
/**
336+
* Model and thinking controls for a subagent definition.
337+
*
338+
* The model list offers only models the user already configured, so the value
339+
* saved is always resolvable in Settings; there is no free-text escape hatch.
340+
* When no provider offers a runnable model the field explains that and links to
341+
* Models instead of accepting a hand-typed id the runtime could not resolve.
342+
*/
332343
function ModelField({
333344
draft,
334345
setDraft,
@@ -343,10 +354,7 @@ function ModelField({
343354
orphanModel: string | null;
344355
}) {
345356
const { t } = useTranslation();
346-
const [customModel, setCustomModel] = useState(false);
347-
const modelValue = customModel
348-
? CUSTOM_SUBAGENT_MODEL_VALUE
349-
: subagentModelSelectValue(draft.model, modelChoices);
357+
const modelValue = subagentModelSelectValue(draft.model, modelChoices);
350358

351359
return (
352360
<>
@@ -360,28 +368,25 @@ function ModelField({
360368
}
361369
>
362370
{modelChoices.length === 0 ? (
363-
<Input
364-
value={draft.model}
365-
placeholder={t("extensions.subagents.modelPickPlaceholder")}
366-
aria-label={t("extensions.subagents.model")}
367-
onChange={(event) =>
368-
setDraft({ ...draft, model: event.target.value })
369-
}
370-
/>
371+
<div className="ext-field-empty">
372+
<Button
373+
variant="secondary"
374+
size="sm"
375+
onClick={() => {
376+
const store = useAppStore.getState();
377+
store.setSettingsTab("agent");
378+
}}
379+
>
380+
{t("extensions.subagents.modelPickEmptyAction")}
381+
</Button>
382+
</div>
371383
) : (
372384
<Select
373385
value={modelValue}
374386
aria-label={t("extensions.subagents.model")}
375-
onChange={(event) => {
376-
const value = event.target.value;
377-
if (value === CUSTOM_SUBAGENT_MODEL_VALUE) {
378-
setCustomModel(true);
379-
setDraft({ ...draft, model: "" });
380-
} else {
381-
setCustomModel(false);
382-
setDraft({ ...draft, model: value });
383-
}
384-
}}
387+
onChange={(event) =>
388+
setDraft({ ...draft, model: event.target.value })
389+
}
385390
>
386391
<option value="">{t("extensions.subagents.modelInherit")}</option>
387392
{modelGroups.map((group) => (
@@ -396,9 +401,6 @@ function ModelField({
396401
{orphanModel ? (
397402
<option value={orphanModel}>{orphanModel}</option>
398403
) : null}
399-
<option value={CUSTOM_SUBAGENT_MODEL_VALUE}>
400-
{t("extensions.subagents.modelPickCustom")}
401-
</option>
402404
</Select>
403405
)}
404406
</Field>
@@ -427,21 +429,6 @@ function ModelField({
427429
</Select>
428430
</Field>
429431
</div>
430-
{modelChoices.length > 0 && customModel ? (
431-
<Field
432-
label={t("extensions.subagents.modelPickCustom")}
433-
hint={t("extensions.subagents.modelPickCustomHint")}
434-
>
435-
<Input
436-
value={draft.model}
437-
placeholder={t("extensions.subagents.modelPickPlaceholder")}
438-
aria-label={t("extensions.subagents.modelPickCustom")}
439-
onChange={(event) =>
440-
setDraft({ ...draft, model: event.target.value })
441-
}
442-
/>
443-
</Field>
444-
) : null}
445432
</>
446433
);
447434
}

apps/desktop/src/components/settings/subagent-models.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,19 @@ function providerAlias(value: string): string {
5454
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
5555
}
5656

57-
function pinParts(pin: string): { providerPart: string; modelId: string } | null {
57+
/**
58+
* Split a pin into its provider half and model half, or null when it is not a
59+
* pin at all.
60+
*
61+
* Only the slash is structural. The provider half is matched by a normalized
62+
* alias at both ends of the app (`findProvider` in `agent-runtime` and
63+
* `providerAlias` here), so a display name is a valid spelling — and a custom
64+
* endpoint's display name may contain spaces. The editor validates drafts with
65+
* this same function so the picker can never offer an option it would reject.
66+
*/
67+
export function subagentModelPinParts(
68+
pin: string,
69+
): { providerPart: string; modelId: string } | null {
5870
const trimmed = pin.trim();
5971
const slash = trimmed.indexOf("/");
6072
if (slash < 1 || slash === trimmed.length - 1) return null;
@@ -65,13 +77,13 @@ function pinParts(pin: string): { providerPart: string; modelId: string } | null
6577
}
6678

6779
export function pinMatchesChoice(pin: string, choice: SubagentModelChoice): boolean {
68-
const parts = pinParts(pin);
80+
const parts = subagentModelPinParts(pin);
6981
if (!parts) return false;
7082
if (!modelIdsMatch(parts.modelId, choice.modelId)) return false;
7183
if (parts.providerPart === choice.providerId) return true;
7284
const alias = providerAlias(parts.providerPart);
7385
if (!alias) return false;
74-
const canonicalProviderPart = pinParts(choice.value)?.providerPart;
86+
const canonicalProviderPart = subagentModelPinParts(choice.value)?.providerPart;
7587
if (canonicalProviderPart && providerAlias(canonicalProviderPart) === alias) {
7688
return true;
7789
}
@@ -122,17 +134,6 @@ export function subagentModelChoices(
122134
for (const { provider, modelId } of defaultModelOptions(
123135
runnableProviders,
124136
)) {
125-
// Delegation is an explicit per-binding opt-in. The Composer can use every
126-
// runnable model, but a subagent must not be able to select a binding that
127-
// the user has not exposed to AI delegation in Models.
128-
if (
129-
!provider.models?.some(
130-
(binding) =>
131-
modelIdsMatch(binding.id, modelId) && binding.availableForSubagents === true,
132-
)
133-
) {
134-
continue;
135-
}
136137
const value = `${uniqueProviderPart(provider, runnableProviders)}/${modelId}`;
137138
if (seen.has(value.toLowerCase())) continue;
138139
seen.add(value.toLowerCase());

apps/desktop/src/styles/extensions.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,12 @@
11671167
min-width: 0;
11681168
}
11691169

1170+
/* A field with nothing to pick yet: the action replaces the control. */
1171+
.ext-field-empty {
1172+
display: flex;
1173+
align-items: center;
1174+
}
1175+
11701176
/* The transport choice is two cards, not a select: it changes the whole form. */
11711177
.ext-transport-pick {
11721178
display: grid;

apps/desktop/test/subagent-editor-presets.test.mjs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,14 @@ test("the model picker keeps existing pins visible", () => {
100100
assert.match(editorSource, /orphanModel \? \(/);
101101
});
102102

103-
test("the model picker keeps custom ids available without exposing opted-out bindings", () => {
104-
assert.match(editorSource, /CUSTOM_SUBAGENT_MODEL_VALUE/);
105-
assert.match(editorSource, /extensions\.subagents\.modelPickCustom/);
106-
assert.match(editorSource, /extensions\.subagents\.modelPickCustomHint/);
103+
test("the model picker offers every configured provider model, with no free-text path", () => {
104+
// The picker is the only way to set a model: every option comes from the
105+
// configured provider catalog, so a saved pin is always resolvable.
106+
assert.match(editorSource, /subagentModelChoices\(providers\)/);
107+
assert.match(editorSource, /groupSubagentModelChoices\(modelChoices\)/);
108+
assert.match(editorSource, /subagentModelOrphanPin\(draft\.model, modelChoices\)/);
109+
assert.doesNotMatch(editorSource, /CUSTOM_SUBAGENT_MODEL_VALUE/);
110+
assert.doesNotMatch(editorSource, /modelPickCustom/);
107111
});
108112

109113
test("the editor styles ship with the picker", () => {

apps/desktop/test/subagent-models.test.mjs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const {
1919
subagentModelChoices,
2020
subagentModelOrphanPin,
2121
subagentModelPin,
22+
subagentModelPinParts,
2223
subagentModelSelectValue,
2324
} = await import("../src/components/settings/subagent-models.ts");
2425

@@ -55,6 +56,46 @@ test("a custom endpoint uses its display name instead of the generic vendor key"
5556
subagentModelPin({ vendorKey: "custom", name: "My Gateway" }, "local-model"),
5657
"My Gateway/local-model",
5758
);
59+
60+
test("every option the picker offers is a pin the editor will save", () => {
61+
// A custom endpoint's display name contains spaces, and the picker offers it.
62+
// The editor's draft check must accept exactly what the picker produces, or
63+
// the user can pick an option and then never save the sheet.
64+
const choices = subagentModelChoices([
65+
provider(),
66+
provider({ id: "p2", name: "My Gateway", vendorKey: "custom" }),
67+
provider({ id: "p3", name: "My Ollama", vendorKey: "" }),
68+
]);
69+
assert.ok(choices.length > 0);
70+
for (const choice of choices) {
71+
assert.notEqual(
72+
subagentModelPinParts(choice.value),
73+
null,
74+
`${choice.value} is offered but not saveable`,
75+
);
76+
}
77+
});
78+
79+
test("a pin needs a provider half, a model half, and keeps its own slashes", () => {
80+
assert.deepEqual(subagentModelPinParts("anthropic/claude-haiku-4-5"), {
81+
providerPart: "anthropic",
82+
modelId: "claude-haiku-4-5",
83+
});
84+
assert.deepEqual(subagentModelPinParts(" My Gateway/local-model "), {
85+
providerPart: "My Gateway",
86+
modelId: "local-model",
87+
});
88+
// An openrouter-style model id carries its own slashes; only the first one
89+
// separates the provider.
90+
assert.deepEqual(subagentModelPinParts("openrouter/deepseek/deepseek-chat"), {
91+
providerPart: "openrouter",
92+
modelId: "deepseek/deepseek-chat",
93+
});
94+
// A bare id has no provider to look up, so it is not a pin.
95+
assert.equal(subagentModelPinParts("claude-haiku-4-5"), null);
96+
assert.equal(subagentModelPinParts("/claude-haiku-4-5"), null);
97+
assert.equal(subagentModelPinParts("anthropic/"), null);
98+
});
5899
});
59100

60101
test("the sheet lists configured models from enabled, credentialed providers", () => {
@@ -89,7 +130,10 @@ test("the sheet lists configured models from enabled, credentialed providers", (
89130
);
90131
});
91132

92-
test("the sheet only lists bindings explicitly enabled for subagents", () => {
133+
test("the sheet lists every configured binding regardless of the delegation flag", () => {
134+
// The editor no longer consults `availableForSubagents`: a model the user
135+
// configured in Settings is selectable, so the list cannot be empty while a
136+
// runnable provider exists.
93137
const choices = subagentModelChoices([
94138
provider({
95139
models: [
@@ -104,7 +148,7 @@ test("the sheet only lists bindings explicitly enabled for subagents", () => {
104148
]);
105149
assert.deepEqual(
106150
choices.map((choice) => choice.modelId),
107-
["claude-haiku-4-5"],
151+
["claude-haiku-4-5", "claude-sonnet-4-6", "claude-opus-4-6"],
108152
);
109153
});
110154

@@ -222,7 +266,7 @@ test("a pin that is no longer configured stays selectable", () => {
222266
assert.equal(subagentModelOrphanPin("openai/gpt-5", choices), "openai/gpt-5");
223267
});
224268

225-
test("the editor model field offers configured, custom, and empty-list paths", async () => {
269+
test("the editor model field is a configured-only select with an empty-state action", async () => {
226270
const source = await readFile(
227271
new URL("../src/components/settings/SubagentEditorSheet.tsx", import.meta.url),
228272
"utf8",
@@ -234,11 +278,13 @@ test("the editor model field offers configured, custom, and empty-list paths", a
234278
assert.match(modelField, /<Select/);
235279
assert.match(modelField, /<optgroup/);
236280
assert.match(modelField, /extensions\.subagents\.modelInherit/);
237-
assert.match(modelField, /extensions\.subagents\.modelPickCustom/);
238281
assert.match(modelField, /extensions\.subagents\.modelPickEmpty/);
282+
assert.match(modelField, /extensions\.subagents\.modelPickEmptyAction/);
283+
assert.match(modelField, /setSettingsTab\("agent"\)/);
284+
// No hand-typed model id: the field never renders a free-text input.
285+
assert.doesNotMatch(modelField, /<Input/);
286+
assert.doesNotMatch(modelField, /modelPickCustom/);
239287
assert.match(source, /subagentModelChoices\(providers\)/);
240-
assert.match(source, /CUSTOM_SUBAGENT_MODEL_VALUE/);
241-
assert.match(source, /extensions\.subagents\.modelPickCustomHint/);
242288
assert.match(source, /resetSubagentTemplate\(draft\)/);
243289
});
244290

0 commit comments

Comments
 (0)