From d8a1c11c4ee87d3c63d9ec039153d4d2ddf53349 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 23:08:02 -0700 Subject: [PATCH 1/3] source loops model picker from preview config --- .../loops/components/LoopDetailView.tsx | 4 +- .../features/loops/components/LoopForm.tsx | 8 +- .../loops/components/LoopModelFields.tsx | 102 +++++---- .../loops/hooks/useLoopDisplayModel.ts | 31 --- .../loops/hooks/useLoopModelConfigOptions.ts | 31 +++ .../ui/src/features/loops/loopModels.test.ts | 209 ++++++++++++++++++ packages/ui/src/features/loops/loopModels.ts | 97 ++++++++ 7 files changed, 397 insertions(+), 85 deletions(-) delete mode 100644 packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts create mode 100644 packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts create mode 100644 packages/ui/src/features/loops/loopModels.test.ts create mode 100644 packages/ui/src/features/loops/loopModels.ts diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 2d7bae6ff8..e499442933 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -29,7 +29,6 @@ import { import { Flex, Text } from "@radix-ui/themes"; import { useRef, useState } from "react"; import { useLoop } from "../hooks/useLoop"; -import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel"; import { useDeleteLoop, useRunLoop, @@ -45,6 +44,7 @@ import { nextScheduleRun, summarizeNotificationDestinations, } from "../loopDisplay"; +import { formatLoopModel } from "../loopModels"; import { LoopLoadError } from "./LoopFallbacks"; import { LoopRunRow } from "./LoopRunRow"; @@ -320,7 +320,7 @@ function PausedNotice({ loop }: { loop: LoopSchemas.Loop }) { } function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { - const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model); + const displayModel = formatLoopModel(loop.runtime_adapter, loop.model); const { members, isLoading: membersLoading, diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index 93b1b3f485..14842f1b30 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -33,6 +33,7 @@ import { loopToFormValues, normalizeLoopFormValues, } from "../loopFormTypes"; +import { formatLoopModel } from "../loopModels"; import { LoopBehaviorFields } from "./LoopBehaviorFields"; import { LoopContextFields } from "./LoopContextFields"; import { Field } from "./LoopFormPrimitives"; @@ -548,9 +549,10 @@ function ReviewList({ /> {showContext ? ( { - const ids = new Set(); - for (const entry of catalog.models) { - ids.add(entry.model); - } - if (ids.size === 0) { - for (const level of Object.values(catalog.levels)) { - for (const id of level) { - ids.add(id); - } - } - } - if (model) { - ids.add(model); - } - const catalogOptions = Array.from(ids) - .sort((a, b) => a.localeCompare(b)) - .map((id) => ({ value: id, label: id })); - return [ + const modelOptions = useMemo( + () => [ { value: DEFAULT_MODEL_VALUE, label: "Default (recommended)" }, - ...catalogOptions, - ]; - }, [catalog, model]); + ...loopModelOptions(configOptions, { glmEnabled, pinnedModel: model }), + ], + [configOptions, glmEnabled, model], + ); + + const reasoningOptions = useMemo( + () => [ + { value: AUTO_REASONING_VALUE, label: "Auto" }, + ...loopReasoningEffortOptions(adapter, model), + ], + [adapter, model], + ); + + const handleAdapterChange = (value: string) => { + const nextAdapter = value as LoopSchemas.LoopRuntimeAdapterEnum; + onAdapterChange(nextAdapter); + // Adapters have disjoint model catalogs, so a pinned model can't carry over. + if (model) onModelChange(""); + const clamped = clampLoopReasoningEffort(nextAdapter, "", reasoningEffort); + if (clamped !== reasoningEffort) onReasoningEffortChange(clamped); + }; + + const handleModelChange = (value: string) => { + const nextModel = value === DEFAULT_MODEL_VALUE ? "" : value; + onModelChange(nextModel); + const clamped = clampLoopReasoningEffort( + adapter, + nextModel, + reasoningEffort, + ); + if (clamped !== reasoningEffort) onReasoningEffortChange(clamped); + }; return ( @@ -96,9 +103,7 @@ export function LoopModelFields({ value={model || DEFAULT_MODEL_VALUE} options={modelOptions} placeholder="Default (recommended)" - onValueChange={(value) => - onModelChange(value === DEFAULT_MODEL_VALUE ? "" : value) - } + onValueChange={handleModelChange} disabled={disabled} size="lg" ariaLabel="Model" @@ -110,9 +115,7 @@ export function LoopModelFields({ - onAdapterChange(value as LoopSchemas.LoopRuntimeAdapterEnum) - } + onValueChange={handleAdapterChange} disabled={disabled} size="lg" ariaLabel="Adapter" @@ -122,7 +125,8 @@ export function LoopModelFields({ onReasoningEffortChange( value === AUTO_REASONING_VALUE diff --git a/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts b/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts deleted file mode 100644 index a63d7660dc..0000000000 --- a/packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { LoopSchemas } from "@posthog/api-client/loops"; -import { - REPORT_MODEL_RESOLVER, - type ReportModelResolver, -} from "@posthog/core/inbox/identifiers"; -import { useService } from "@posthog/di/react"; -import { getCloudUrlFromRegion } from "@posthog/shared"; -import { useAuthStateValue } from "@posthog/ui/features/auth/store"; -import { useQuery } from "@tanstack/react-query"; - -export function useLoopDisplayModel( - adapter: LoopSchemas.LoopRuntimeAdapterEnum, - configuredModel: string, -): string { - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const modelResolver = useService(REPORT_MODEL_RESOLVER); - const { data } = useQuery({ - queryKey: ["loops", "default-model", cloudRegion, adapter], - queryFn: () => { - if (!cloudRegion) return undefined; - return modelResolver.resolveDefaultModel( - getCloudUrlFromRegion(cloudRegion), - adapter, - ); - }, - enabled: !configuredModel && !!cloudRegion, - staleTime: 5 * 60_000, - }); - - return configuredModel || data || "Default model"; -} diff --git a/packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts b/packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts new file mode 100644 index 0000000000..6a48ca1dd4 --- /dev/null +++ b/packages/ui/src/features/loops/hooks/useLoopModelConfigOptions.ts @@ -0,0 +1,31 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { LoopSchemas } from "@posthog/api-client/loops"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useQuery } from "@tanstack/react-query"; + +/** + * The per-adapter session config options (models, efforts) the main + * create-task picker is built from, for reuse by the loop form's static + * pickers. No agent session is created. + */ +export function useLoopModelConfigOptions( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, +): SessionConfigOption[] { + const hostClient = useHostTRPCClient(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const { data } = useQuery({ + queryKey: ["loops", "model-config-options", cloudRegion, adapter], + queryFn: ({ signal }) => { + if (!cloudRegion) return []; + return hostClient.agent.getPreviewConfigOptions.query( + { apiHost: getCloudUrlFromRegion(cloudRegion), adapter }, + { signal }, + ); + }, + enabled: !!cloudRegion, + staleTime: 5 * 60_000, + }); + return data ?? []; +} diff --git a/packages/ui/src/features/loops/loopModels.test.ts b/packages/ui/src/features/loops/loopModels.test.ts new file mode 100644 index 0000000000..a982790662 --- /dev/null +++ b/packages/ui/src/features/loops/loopModels.test.ts @@ -0,0 +1,209 @@ +import type { + SessionConfigOption, + SessionConfigSelectOptions, +} from "@agentclientprotocol/sdk"; +import type { LoopSchemas } from "@posthog/api-client/loops"; +import { restrictedModelMeta } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + clampLoopReasoningEffort, + LOOP_DEFAULT_MODELS, + loopModelOptions, + loopReasoningEffortOptions, +} from "./loopModels"; + +function modelConfigOption( + options: SessionConfigSelectOptions, +): SessionConfigOption[] { + return [ + { + type: "select", + id: "model", + name: "Model", + category: "model", + currentValue: "claude-sonnet-5", + options, + }, + ]; +} + +const claudeOptions = modelConfigOption([ + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "@cf/zai-org/glm-5.2", name: "GLM-5.2" }, +]); + +describe("loopModelOptions", () => { + it("maps served model options to value/label pairs", () => { + expect( + loopModelOptions(claudeOptions, { glmEnabled: true, pinnedModel: "" }), + ).toEqual([ + { value: "claude-sonnet-5", label: "Claude Sonnet 5" }, + { value: "@cf/zai-org/glm-5.2", label: "GLM-5.2" }, + ]); + }); + + it("flattens grouped select options", () => { + const grouped = modelConfigOption([ + { + group: "anthropic", + name: "Anthropic", + options: [{ value: "claude-sonnet-5", name: "Claude Sonnet 5" }], + }, + ]); + expect( + loopModelOptions(grouped, { glmEnabled: true, pinnedModel: "" }), + ).toEqual([{ value: "claude-sonnet-5", label: "Claude Sonnet 5" }]); + }); + + it("drops plan-restricted models", () => { + const withRestricted = modelConfigOption([ + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { + value: "claude-fable-5", + name: "Claude Fable 5", + _meta: restrictedModelMeta(), + }, + ]); + expect( + loopModelOptions(withRestricted, { glmEnabled: true, pinnedModel: "" }), + ).toEqual([{ value: "claude-sonnet-5", label: "Claude Sonnet 5" }]); + }); + + it.each([ + { + name: "hides GLM when the flag is off", + glmEnabled: false, + pinnedModel: "", + expectedValues: ["claude-sonnet-5"], + }, + { + name: "shows GLM when the flag is on", + glmEnabled: true, + pinnedModel: "", + expectedValues: ["claude-sonnet-5", "@cf/zai-org/glm-5.2"], + }, + { + name: "keeps a pinned GLM model visible with the flag off", + glmEnabled: false, + pinnedModel: "@cf/zai-org/glm-5.2", + expectedValues: ["claude-sonnet-5", "@cf/zai-org/glm-5.2"], + }, + ])("$name", ({ glmEnabled, pinnedModel, expectedValues }) => { + const values = loopModelOptions(claudeOptions, { + glmEnabled, + pinnedModel, + }).map((option) => option.value); + expect(values).toEqual(expectedValues); + }); + + it("keeps a pinned model that the catalog no longer serves", () => { + expect( + loopModelOptions(claudeOptions, { + glmEnabled: true, + pinnedModel: "claude-opus-4-6", + }), + ).toContainEqual({ value: "claude-opus-4-6", label: "claude-opus-4-6" }); + }); + + it("returns no options when the config has no model select", () => { + expect(loopModelOptions([], { glmEnabled: true, pinnedModel: "" })).toEqual( + [], + ); + }); +}); + +describe("LOOP_DEFAULT_MODELS", () => { + it("mirrors the backend loop defaults", () => { + expect(LOOP_DEFAULT_MODELS.claude.id).toBe("@cf/zai-org/glm-5.2"); + expect(LOOP_DEFAULT_MODELS.codex.id).toBe("gpt-5"); + }); +}); + +describe("loopReasoningEffortOptions", () => { + it.each<{ + adapter: LoopSchemas.LoopRuntimeAdapterEnum; + model: string; + expectedValues: LoopSchemas.LoopReasoningEffortEnum[]; + }>([ + { adapter: "claude", model: "", expectedValues: ["high", "max"] }, + { + adapter: "claude", + model: "claude-sonnet-5", + expectedValues: ["low", "medium", "high", "xhigh", "max"], + }, + { + adapter: "claude", + model: "@cf/zai-org/glm-5.2", + expectedValues: ["high", "max"], + }, + { adapter: "claude", model: "unknown-model", expectedValues: [] }, + { adapter: "codex", model: "", expectedValues: ["low", "medium", "high"] }, + { + adapter: "codex", + model: "gpt-5.5", + expectedValues: ["low", "medium", "high", "xhigh"], + }, + { + adapter: "codex", + model: "gpt-5.6-sol", + expectedValues: ["low", "medium", "high", "xhigh", "max"], + }, + ])( + "$adapter with model '$model' offers $expectedValues", + ({ adapter, model, expectedValues }) => { + expect( + loopReasoningEffortOptions(adapter, model).map( + (option) => option.value, + ), + ).toEqual(expectedValues); + }, + ); +}); + +describe("clampLoopReasoningEffort", () => { + it.each<{ + name: string; + adapter: LoopSchemas.LoopRuntimeAdapterEnum; + model: string; + effort: LoopSchemas.LoopReasoningEffortEnum | null; + expected: LoopSchemas.LoopReasoningEffortEnum | null; + }>([ + { + name: "keeps a supported effort", + adapter: "claude", + model: "claude-sonnet-5", + effort: "low", + expected: "low", + }, + { + name: "clears an effort the model doesn't support", + adapter: "claude", + model: "@cf/zai-org/glm-5.2", + effort: "low", + expected: null, + }, + { + name: "clears an effort the default model doesn't support", + adapter: "claude", + model: "", + effort: "medium", + expected: null, + }, + { + name: "keeps auto as auto", + adapter: "codex", + model: "", + effort: null, + expected: null, + }, + { + name: "clears max on a codex model without it", + adapter: "codex", + model: "gpt-5", + effort: "max", + expected: null, + }, + ])("$name", ({ adapter, model, effort, expected }) => { + expect(clampLoopReasoningEffort(adapter, model, effort)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/loops/loopModels.ts b/packages/ui/src/features/loops/loopModels.ts new file mode 100644 index 0000000000..20a40c228d --- /dev/null +++ b/packages/ui/src/features/loops/loopModels.ts @@ -0,0 +1,97 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; +import type { LoopSchemas } from "@posthog/api-client/loops"; +import { flattenSelectOptions, isRestrictedModelOption } from "@posthog/shared"; + +export interface LoopModelOption { + value: string; + label: string; +} + +// Mirrors DEFAULT_MODEL_BY_RUNTIME_ADAPTER in posthog's +// products/tasks/backend/temporal/process_task/utils.py: the model a loop +// fires with when none is pinned, and the one the serializer validates a +// blank-model loop's reasoning effort against. +export const LOOP_DEFAULT_MODELS: Record< + LoopSchemas.LoopRuntimeAdapterEnum, + { id: string; label: string } +> = { + claude: { id: "@cf/zai-org/glm-5.2", label: "GLM-5.2" }, + codex: { id: "gpt-5", label: "GPT-5" }, +}; + +function isGlmModelId(modelId: string): boolean { + return modelId.toLowerCase().includes("glm"); +} + +/** The model a loop's runs use, for display: the pinned id, or the adapter's + * loop default (which differs from the live-session default the + * ReportModelResolver serves, so it can't be resolved from there). */ +export function formatLoopModel( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + configuredModel: string, +): string { + return configuredModel || `${LOOP_DEFAULT_MODELS[adapter].label} (default)`; +} + +/** + * Pinnable models for a loop, derived from the same per-adapter preview + * config that feeds the main create-task picker, so the loops picker offers + * exactly the ids the loops API accepts. Restricted (plan-locked) models are + * dropped, GLM is flag-gated like the main picker, and the currently pinned + * model always stays selectable so an existing loop's model never drops out. + */ +export function loopModelOptions( + configOptions: SessionConfigOption[], + { glmEnabled, pinnedModel }: { glmEnabled: boolean; pinnedModel: string }, +): LoopModelOption[] { + const modelOption = configOptions.find( + (option) => option.category === "model" || option.id === "model", + ); + const served = + modelOption?.type === "select" + ? flattenSelectOptions(modelOption.options) + : []; + const options = served + .filter((option) => !isRestrictedModelOption(option._meta)) + .filter( + (option) => + glmEnabled || + option.value === pinnedModel || + !isGlmModelId(option.value), + ) + .map((option) => ({ + value: option.value, + label: option.name ?? option.value, + })); + if (pinnedModel && !options.some((option) => option.value === pinnedModel)) { + options.push({ value: pinnedModel, label: pinnedModel }); + } + return options; +} + +/** Efforts the loops API accepts for the model that would actually run: + * the pinned model, or the adapter's default when the loop leaves it unset. */ +export function loopReasoningEffortOptions( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + model: string, +): { value: LoopSchemas.LoopReasoningEffortEnum; label: string }[] { + const effectiveModel = model || LOOP_DEFAULT_MODELS[adapter].id; + const options = getReasoningEffortOptions(adapter, effectiveModel) ?? []; + return options.map((option) => ({ value: option.value, label: option.name })); +} + +/** The effort unchanged when the effective model supports it, else null + * (auto), so an adapter or model switch never leaves an invalid combo. */ +export function clampLoopReasoningEffort( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + model: string, + effort: LoopSchemas.LoopReasoningEffortEnum | null, +): LoopSchemas.LoopReasoningEffortEnum | null { + if (effort === null) return null; + return loopReasoningEffortOptions(adapter, model).some( + (option) => option.value === effort, + ) + ? effort + : null; +} From 2d33d9e597698e22092536f731c1a5369bd9c983 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 23:10:53 -0700 Subject: [PATCH 2/3] keep claude sonnet 5 as the loop default --- packages/ui/src/features/loops/loopModels.test.ts | 12 ++++++++---- packages/ui/src/features/loops/loopModels.ts | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/loops/loopModels.test.ts b/packages/ui/src/features/loops/loopModels.test.ts index a982790662..1bfcd6bf5b 100644 --- a/packages/ui/src/features/loops/loopModels.test.ts +++ b/packages/ui/src/features/loops/loopModels.test.ts @@ -114,7 +114,7 @@ describe("loopModelOptions", () => { describe("LOOP_DEFAULT_MODELS", () => { it("mirrors the backend loop defaults", () => { - expect(LOOP_DEFAULT_MODELS.claude.id).toBe("@cf/zai-org/glm-5.2"); + expect(LOOP_DEFAULT_MODELS.claude.id).toBe("claude-sonnet-5"); expect(LOOP_DEFAULT_MODELS.codex.id).toBe("gpt-5"); }); }); @@ -125,7 +125,11 @@ describe("loopReasoningEffortOptions", () => { model: string; expectedValues: LoopSchemas.LoopReasoningEffortEnum[]; }>([ - { adapter: "claude", model: "", expectedValues: ["high", "max"] }, + { + adapter: "claude", + model: "", + expectedValues: ["low", "medium", "high", "xhigh", "max"], + }, { adapter: "claude", model: "claude-sonnet-5", @@ -184,9 +188,9 @@ describe("clampLoopReasoningEffort", () => { }, { name: "clears an effort the default model doesn't support", - adapter: "claude", + adapter: "codex", model: "", - effort: "medium", + effort: "xhigh", expected: null, }, { diff --git a/packages/ui/src/features/loops/loopModels.ts b/packages/ui/src/features/loops/loopModels.ts index 20a40c228d..3f1b3cc699 100644 --- a/packages/ui/src/features/loops/loopModels.ts +++ b/packages/ui/src/features/loops/loopModels.ts @@ -16,7 +16,7 @@ export const LOOP_DEFAULT_MODELS: Record< LoopSchemas.LoopRuntimeAdapterEnum, { id: string; label: string } > = { - claude: { id: "@cf/zai-org/glm-5.2", label: "GLM-5.2" }, + claude: { id: "claude-sonnet-5", label: "Claude Sonnet 5" }, codex: { id: "gpt-5", label: "GPT-5" }, }; From 83efe25ad866eaea45612679fd9480f29e41974f Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 23:27:19 -0700 Subject: [PATCH 3/3] fall back to known models while catalog loads --- .../loops/components/LoopModelFields.tsx | 7 +- .../ui/src/features/loops/loopModels.test.ts | 72 ++++++++++++++++--- packages/ui/src/features/loops/loopModels.ts | 49 +++++++++---- 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopModelFields.tsx b/packages/ui/src/features/loops/components/LoopModelFields.tsx index 7a956e6138..2c23454bbf 100644 --- a/packages/ui/src/features/loops/components/LoopModelFields.tsx +++ b/packages/ui/src/features/loops/components/LoopModelFields.tsx @@ -60,9 +60,12 @@ export function LoopModelFields({ const modelOptions = useMemo( () => [ { value: DEFAULT_MODEL_VALUE, label: "Default (recommended)" }, - ...loopModelOptions(configOptions, { glmEnabled, pinnedModel: model }), + ...loopModelOptions(adapter, configOptions, { + glmEnabled, + pinnedModel: model, + }), ], - [configOptions, glmEnabled, model], + [adapter, configOptions, glmEnabled, model], ); const reasoningOptions = useMemo( diff --git a/packages/ui/src/features/loops/loopModels.test.ts b/packages/ui/src/features/loops/loopModels.test.ts index 1bfcd6bf5b..26bed1649e 100644 --- a/packages/ui/src/features/loops/loopModels.test.ts +++ b/packages/ui/src/features/loops/loopModels.test.ts @@ -35,7 +35,10 @@ const claudeOptions = modelConfigOption([ describe("loopModelOptions", () => { it("maps served model options to value/label pairs", () => { expect( - loopModelOptions(claudeOptions, { glmEnabled: true, pinnedModel: "" }), + loopModelOptions("claude", claudeOptions, { + glmEnabled: true, + pinnedModel: "", + }), ).toEqual([ { value: "claude-sonnet-5", label: "Claude Sonnet 5" }, { value: "@cf/zai-org/glm-5.2", label: "GLM-5.2" }, @@ -51,7 +54,10 @@ describe("loopModelOptions", () => { }, ]); expect( - loopModelOptions(grouped, { glmEnabled: true, pinnedModel: "" }), + loopModelOptions("claude", grouped, { + glmEnabled: true, + pinnedModel: "", + }), ).toEqual([{ value: "claude-sonnet-5", label: "Claude Sonnet 5" }]); }); @@ -65,7 +71,10 @@ describe("loopModelOptions", () => { }, ]); expect( - loopModelOptions(withRestricted, { glmEnabled: true, pinnedModel: "" }), + loopModelOptions("claude", withRestricted, { + glmEnabled: true, + pinnedModel: "", + }), ).toEqual([{ value: "claude-sonnet-5", label: "Claude Sonnet 5" }]); }); @@ -89,7 +98,7 @@ describe("loopModelOptions", () => { expectedValues: ["claude-sonnet-5", "@cf/zai-org/glm-5.2"], }, ])("$name", ({ glmEnabled, pinnedModel, expectedValues }) => { - const values = loopModelOptions(claudeOptions, { + const values = loopModelOptions("claude", claudeOptions, { glmEnabled, pinnedModel, }).map((option) => option.value); @@ -98,17 +107,62 @@ describe("loopModelOptions", () => { it("keeps a pinned model that the catalog no longer serves", () => { expect( - loopModelOptions(claudeOptions, { + loopModelOptions("claude", claudeOptions, { glmEnabled: true, pinnedModel: "claude-opus-4-6", }), ).toContainEqual({ value: "claude-opus-4-6", label: "claude-opus-4-6" }); }); - it("returns no options when the config has no model select", () => { - expect(loopModelOptions([], { glmEnabled: true, pinnedModel: "" })).toEqual( - [], - ); + it.each<{ + name: string; + adapter: LoopSchemas.LoopRuntimeAdapterEnum; + glmEnabled: boolean; + expectedValues: string[]; + }>([ + { + name: "falls back to the known claude models when the config has no model select", + adapter: "claude", + glmEnabled: true, + expectedValues: [ + "claude-sonnet-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-fable-5", + "@cf/zai-org/glm-5.2", + ], + }, + { + name: "falls back to the known codex models when the config has no model select", + adapter: "codex", + glmEnabled: true, + expectedValues: [ + "gpt-5", + "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ], + }, + { + name: "applies the GLM flag to the fallback list", + adapter: "claude", + glmEnabled: false, + expectedValues: [ + "claude-sonnet-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-fable-5", + ], + }, + ])("$name", ({ adapter, glmEnabled, expectedValues }) => { + const values = loopModelOptions(adapter, [], { + glmEnabled, + pinnedModel: "", + }).map((option) => option.value); + expect(values).toEqual(expectedValues); }); }); diff --git a/packages/ui/src/features/loops/loopModels.ts b/packages/ui/src/features/loops/loopModels.ts index 3f1b3cc699..0c16f6c978 100644 --- a/packages/ui/src/features/loops/loopModels.ts +++ b/packages/ui/src/features/loops/loopModels.ts @@ -24,6 +24,31 @@ function isGlmModelId(modelId: string): boolean { return modelId.toLowerCase().includes("glm"); } +// Served-catalog stand-in while the preview config loads or when the request +// fails, so the picker never collapses to "Default" alone. Matches the +// backend's per-adapter catalogs in process_task/utils.py minus client-blocked +// models; the served catalog stays authoritative once it arrives. +const FALLBACK_MODEL_OPTIONS: Record< + LoopSchemas.LoopRuntimeAdapterEnum, + LoopModelOption[] +> = { + claude: [ + { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { value: "claude-opus-4-7", label: "Claude Opus 4.7" }, + { value: "claude-opus-4-8", label: "Claude Opus 4.8" }, + { value: "claude-sonnet-5", label: "Claude Sonnet 5" }, + { value: "claude-fable-5", label: "Claude Fable 5" }, + { value: "@cf/zai-org/glm-5.2", label: "GLM-5.2" }, + ], + codex: [ + { value: "gpt-5", label: "GPT-5" }, + { value: "gpt-5.5", label: "GPT-5.5" }, + { value: "gpt-5.6-sol", label: "GPT-5.6 Sol" }, + { value: "gpt-5.6-terra", label: "GPT-5.6 Terra" }, + { value: "gpt-5.6-luna", label: "GPT-5.6 Luna" }, + ], +}; + /** The model a loop's runs use, for display: the pinned id, or the adapter's * loop default (which differs from the live-session default the * ReportModelResolver serves, so it can't be resolved from there). */ @@ -42,6 +67,7 @@ export function formatLoopModel( * model always stays selectable so an existing loop's model never drops out. */ export function loopModelOptions( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, configOptions: SessionConfigOption[], { glmEnabled, pinnedModel }: { glmEnabled: boolean; pinnedModel: string }, ): LoopModelOption[] { @@ -51,19 +77,18 @@ export function loopModelOptions( const served = modelOption?.type === "select" ? flattenSelectOptions(modelOption.options) + .filter((option) => !isRestrictedModelOption(option._meta)) + .map((option) => ({ + value: option.value, + label: option.name ?? option.value, + })) : []; - const options = served - .filter((option) => !isRestrictedModelOption(option._meta)) - .filter( - (option) => - glmEnabled || - option.value === pinnedModel || - !isGlmModelId(option.value), - ) - .map((option) => ({ - value: option.value, - label: option.name ?? option.value, - })); + const options = ( + served.length > 0 ? served : FALLBACK_MODEL_OPTIONS[adapter] + ).filter( + (option) => + glmEnabled || option.value === pinnedModel || !isGlmModelId(option.value), + ); if (pinnedModel && !options.some((option) => option.value === pinnedModel)) { options.push({ value: pinnedModel, label: pinnedModel }); }