fix(ui): stage every gateway-honored agent model shape instead of silently dropping input (#123995)

The agents-page fallback editor gated staging on a resolvable primary
model, but the gateway resolver honors { fallbacks } with no primary
(agent-scope.ts, including the explicit empty-array-disables-global
contract). With a fully implicit default model, typing a fallback chip
cleared the input and staged nothing — no chip, no error, no write.
Clearing the primary had the sibling bug: removeFormValue deleted the
whole model node including authored agent fallbacks.

One stageModelShape owner now writes the smallest representable shape
(bare string, { primary, fallbacks }, { fallbacks }, or removal), and
both entry points share existingModelParts instead of duplicating
existing-shape probing.
This commit is contained in:
Peter Steinberger
2026-08-14 21:14:50 -07:00
committed by GitHub
parent ed5e489633
commit 622eec9c36
2 changed files with 114 additions and 41 deletions
+63
View File
@@ -60,4 +60,67 @@ describe("agent model config", () => {
expect(runtimeConfig.state.configForm?.agents).not.toHaveProperty("list");
runtimeConfig.dispose();
});
it("stages fallbacks without a primary when no model is authored anywhere", async () => {
// Fully implicit default model: typing a fallback chip must stage the
// { fallbacks }-only shape the gateway honors, not silently no-op.
const runtimeConfig = createRuntimeConfig({
agents: { entries: { main: { default: true } } },
});
await runtimeConfig.ensureLoaded();
stageAgentModelFallbacks(runtimeConfig, "main", ["openai/gpt-5.4"]);
expect(runtimeConfig.state.configForm).toEqual({
agents: {
entries: {
main: { default: true, model: { fallbacks: ["openai/gpt-5.4"] } },
},
},
});
runtimeConfig.dispose();
});
it("keeps authored fallbacks when the primary model is cleared", async () => {
const runtimeConfig = createRuntimeConfig({
agents: {
defaults: { model: { primary: "openai/gpt-5.4" } },
entries: {
main: {
default: true,
model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.4"] },
},
},
},
});
await runtimeConfig.ensureLoaded();
stageAgentPrimaryModel(runtimeConfig, "main", null);
expect(runtimeConfig.state.configForm).toEqual({
agents: {
defaults: { model: { primary: "openai/gpt-5.4" } },
entries: {
main: { default: true, model: { fallbacks: ["openai/gpt-5.4"] } },
},
},
});
runtimeConfig.dispose();
});
it("still removes the model node when clearing a primary with no fallbacks", async () => {
const runtimeConfig = createRuntimeConfig({
agents: {
entries: { main: { default: true, model: "anthropic/claude-sonnet-4-6" } },
},
});
await runtimeConfig.ensureLoaded();
stageAgentPrimaryModel(runtimeConfig, "main", null);
expect(runtimeConfig.state.configForm).toEqual({
agents: { entries: { main: { default: true } } },
});
runtimeConfig.dispose();
});
});
+51 -41
View File
@@ -21,6 +21,44 @@ function modelEntry(target: AgentConfigEntryTarget) {
};
}
// Stage the smallest config shape that expresses the selection. The gateway
// resolver honors a bare string, { primary, fallbacks }, and { fallbacks }
// with no primary (agent-scope.ts); staging must write all three or an
// authored piece of the selection silently disappears.
function stageModelShape(
runtimeConfig: RuntimeConfig,
path: Array<string | number>,
primary: string | null,
fallbacks: string[] | null,
) {
if (primary && fallbacks) {
runtimeConfig.patchForm(path, { primary, fallbacks });
} else if (primary) {
runtimeConfig.patchForm(path, primary);
} else if (fallbacks) {
runtimeConfig.patchForm(path, { fallbacks });
} else {
runtimeConfig.removeFormValue(path);
}
}
function existingModelParts(existing: unknown): {
primary: string | null;
fallbacks: string[] | null;
} {
if (typeof existing === "string") {
return { primary: existing.trim() || null, fallbacks: null };
}
if (existing && typeof existing === "object") {
const record = existing as { primary?: unknown; fallbacks?: unknown };
return {
primary: typeof record.primary === "string" ? record.primary.trim() || null : null,
fallbacks: Array.isArray(record.fallbacks) ? (record.fallbacks as string[]) : null,
};
}
return { primary: null, fallbacks: null };
}
/** Stage a primary-model change; clearing falls back to the inherited default. */
export function stageAgentPrimaryModel(
runtimeConfig: RuntimeConfig,
@@ -32,17 +70,9 @@ export function stageAgentPrimaryModel(
return;
}
const entry = modelEntry(target);
if (!modelId) {
runtimeConfig.removeFormValue(entry.path);
} else if (entry.existing && typeof entry.existing === "object") {
const fallbacks = (entry.existing as { fallbacks?: unknown }).fallbacks;
runtimeConfig.patchForm(entry.path, {
primary: modelId,
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
});
} else {
runtimeConfig.patchForm(entry.path, modelId);
}
// Clearing the primary must not delete authored agent fallbacks: the
// { fallbacks }-only shape stays representable.
stageModelShape(runtimeConfig, entry.path, modelId, existingModelParts(entry.existing).fallbacks);
}
/** Stage fallback-list edits, preserving the effective primary model shape. */
@@ -54,40 +84,20 @@ export function stageAgentModelFallbacks(
const config = currentConfigObject(runtimeConfig.state);
const normalized = normalizeStringEntries(fallbacks);
const resolved = resolveAgentConfig(config, agentId);
const primary =
resolveModelPrimary(resolved.entry?.model) ?? resolveModelPrimary(resolved.defaults?.model);
const effective = resolveEffectiveModelFallbacks(resolved.entry?.model, resolved.defaults?.model);
const existingTarget = runtimeConfig.agentEntry(agentId);
const target =
normalized.length > 0
? primary
? (existingTarget ?? runtimeConfig.agentEntry(agentId, { ensure: true }))
: null
: (effective?.length ?? 0) > 0 || existingTarget
? (existingTarget ?? runtimeConfig.agentEntry(agentId, { ensure: true }))
: null;
const mustWrite = normalized.length > 0 || (effective?.length ?? 0) > 0 || existingTarget;
const target = mustWrite
? (existingTarget ?? runtimeConfig.agentEntry(agentId, { ensure: true }))
: null;
if (!target) {
return;
}
const entry = modelEntry(target);
const currentPrimary =
typeof entry.existing === "string"
? entry.existing.trim()
: entry.existing &&
typeof entry.existing === "object" &&
typeof (entry.existing as { primary?: unknown }).primary === "string"
? (entry.existing as { primary: string }).primary.trim()
: "";
if (normalized.length === 0) {
if (currentPrimary || primary) {
runtimeConfig.patchForm(entry.path, currentPrimary || primary);
} else {
runtimeConfig.removeFormValue(entry.path);
}
} else if (currentPrimary || primary) {
runtimeConfig.patchForm(entry.path, {
primary: currentPrimary || primary,
fallbacks: normalized,
});
}
const primary =
existingModelParts(entry.existing).primary ??
resolveModelPrimary(resolved.entry?.model) ??
resolveModelPrimary(resolved.defaults?.model) ??
null;
stageModelShape(runtimeConfig, entry.path, primary, normalized.length > 0 ? normalized : null);
}