From 2521d7d60528ea6ac305987b221f357e679388bc Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 22 Jul 2026 23:20:13 -0700 Subject: [PATCH 1/2] align loop form with glm default model --- .../loops/components/LoopDetailView.tsx | 6 +- .../features/loops/components/LoopForm.tsx | 4 +- .../loops/components/LoopModelFields.tsx | 75 ++++++++++++++----- .../loops/hooks/useLoopDisplayModel.ts | 31 -------- .../src/features/loops/loopBuilderPrompt.ts | 2 +- .../features/loops/loopModelDefaults.test.ts | 36 +++++++++ .../src/features/loops/loopModelDefaults.ts | 46 ++++++++++++ 7 files changed, 146 insertions(+), 54 deletions(-) delete mode 100644 packages/ui/src/features/loops/hooks/useLoopDisplayModel.ts create mode 100644 packages/ui/src/features/loops/loopModelDefaults.test.ts create mode 100644 packages/ui/src/features/loops/loopModelDefaults.ts diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index c2f5ee7cfc..8b57a04363 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -27,7 +27,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, @@ -41,6 +40,7 @@ import { nextScheduleRun, summarizeNotificationDestinations, } from "../loopDisplay"; +import { loopEffectiveModel } from "../loopModelDefaults"; import { LoopLoadError } from "./LoopFallbacks"; import { LoopRunRow } from "./LoopRunRow"; @@ -265,7 +265,9 @@ function loopStatusBadgeVariant( } function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { - const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model); + const displayModel = loop.model + ? loop.model + : `${loopEffectiveModel(loop.runtime_adapter, "")} (default)`; 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 72e7899ac7..81e6cdb542 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -30,6 +30,7 @@ import { loopToFormValues, normalizeLoopFormValues, } from "../loopFormTypes"; +import { loopEffectiveModel } from "../loopModelDefaults"; import { LoopBehaviorFields } from "./LoopBehaviorFields"; import { LoopContextFields } from "./LoopContextFields"; import { Field } from "./LoopFormPrimitives"; @@ -521,7 +522,8 @@ function ReviewList({ values }: { values: LoopFormValues }) { = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Max", +}; interface LoopModelFieldsProps { adapter: LoopSchemas.LoopRuntimeAdapterEnum; @@ -46,7 +46,9 @@ interface LoopModelFieldsProps { * `UnifiedModelSelector`/`ReasoningLevelSelector` (which read a session's * `SessionConfigOption`) don't apply here, so this presents the same choices * as a dropdown against the served model catalog instead. The server validates - * the final value against the catalog in `process_task/utils.py`. + * the final value against the catalog in `process_task/utils.py`. The catalog + * is deliberately not filtered by the session composer's GLM feature flag: + * GLM is the loop fire-time default, so hiding it here would hide the default. */ export function LoopModelFields({ adapter, @@ -86,6 +88,37 @@ export function LoopModelFields({ ]; }, [catalog, model]); + // The current effort stays selectable even when the effective model no longer + // supports it (a loop saved under an older default), same as the model list. + const reasoningEffortOptions = useMemo(() => { + const supported = loopSupportedReasoningEfforts(adapter, model); + const efforts = + reasoningEffort && !supported.includes(reasoningEffort) + ? [...supported, reasoningEffort] + : supported; + return [ + { value: AUTO_REASONING_VALUE, label: "Auto" }, + ...efforts.map((effort) => ({ + value: effort, + label: REASONING_EFFORT_LABELS[effort], + })), + ]; + }, [adapter, model, reasoningEffort]); + + const clearUnsupportedEffort = ( + nextAdapter: LoopSchemas.LoopRuntimeAdapterEnum, + nextModel: string, + ) => { + if ( + reasoningEffort && + !loopSupportedReasoningEfforts(nextAdapter, nextModel).includes( + reasoningEffort, + ) + ) { + onReasoningEffortChange(null); + } + }; + return ( - onModelChange(value === DEFAULT_MODEL_VALUE ? "" : value) - } + onValueChange={(value) => { + const nextModel = value === DEFAULT_MODEL_VALUE ? "" : value; + onModelChange(nextModel); + clearUnsupportedEffort(adapter, nextModel); + }} disabled={disabled} ariaLabel="Model" /> @@ -109,9 +144,11 @@ export function LoopModelFields({ - onAdapterChange(value as LoopSchemas.LoopRuntimeAdapterEnum) - } + onValueChange={(value) => { + const nextAdapter = value as LoopSchemas.LoopRuntimeAdapterEnum; + onAdapterChange(nextAdapter); + clearUnsupportedEffort(nextAdapter, model); + }} disabled={disabled} ariaLabel="Adapter" /> @@ -120,7 +157,7 @@ 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/loopBuilderPrompt.ts b/packages/ui/src/features/loops/loopBuilderPrompt.ts index 9ac25f977d..04e995b76c 100644 --- a/packages/ui/src/features/loops/loopBuilderPrompt.ts +++ b/packages/ui/src/features/loops/loopBuilderPrompt.ts @@ -54,7 +54,7 @@ In the config you assemble, set \`context_target\` to {"folder_id": ${JSON.strin - Whether it may open pull requests, and how I want to hear about runs (in-app, email, or Slack). - A short name. 4. If the loop works on a repository, resolve its GitHub integration by calling \`integrations-list\` for THIS project and use that integration's real \`github_integration_id\`. Never invent or reuse an id from memory. If this project has no GitHub integration, do NOT attach a repository or guess an id: tell me to connect GitHub for this project first, or build a report-only loop if that fits what I asked for. -5. As soon as you have a working draft and the essentials, call the PostHog MCP \`loops-review\` tool with the full assembled configuration (the same fields \`loops-create\` takes: name, instructions, runtime_adapter, triggers, behaviors, notifications, and so on). Unless a context or I say otherwise, make it a personal loop. +5. As soon as you have a working draft and the essentials, call the PostHog MCP \`loops-review\` tool with the full assembled configuration (the same fields \`loops-create\` takes: name, instructions, runtime_adapter, triggers, behaviors, notifications, and so on). Leave \`model\` and \`reasoning_effort\` unset unless I ask for a specific model; unset means PostHog picks the default model for each run. Unless a context or I say otherwise, make it a personal loop. The \`loops-review\` card IS the review surface: it renders the whole loop for me to read and gives me a Create button. Do NOT review the loop as plain text. Never paste the drafted config into a message and ask "does this look right?", and never just narrate that it's ready and stop. The moment you have enough, call \`loops-review\`. If I ask for changes, call \`loops-review\` again with the updated config. Never call \`loops-create\` yourself: the card's Create button creates the loop once I confirm.`; } diff --git a/packages/ui/src/features/loops/loopModelDefaults.test.ts b/packages/ui/src/features/loops/loopModelDefaults.test.ts new file mode 100644 index 0000000000..220bf3c32f --- /dev/null +++ b/packages/ui/src/features/loops/loopModelDefaults.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + loopEffectiveModel, + loopSupportedReasoningEfforts, +} from "./loopModelDefaults"; + +describe("loopEffectiveModel", () => { + it.each([ + ["claude", "", "@cf/zai-org/glm-5.2"], + ["codex", "", "gpt-5"], + ["claude", "claude-opus-4-8", "claude-opus-4-8"], + ["codex", "gpt-5.5", "gpt-5.5"], + ] as const)( + "resolves adapter %s with model %j to %s", + (adapter, model, expected) => { + expect(loopEffectiveModel(adapter, model)).toBe(expected); + }, + ); +}); + +describe("loopSupportedReasoningEfforts", () => { + it.each([ + ["claude", "", ["high", "max"]], + ["claude", "@cf/zai-org/glm-5.2", ["high", "max"]], + ["claude", "claude-sonnet-4-6", ["low", "medium", "high"]], + ["claude", "claude-opus-4-8", ["low", "medium", "high", "xhigh", "max"]], + ["codex", "", ["low", "medium", "high"]], + ["codex", "gpt-5.5", ["low", "medium", "high", "xhigh"]], + ["codex", "gpt-5.6-sol", ["low", "medium", "high", "xhigh", "max"]], + ] as const)( + "adapter %s with model %j supports %j", + (adapter, model, expected) => { + expect(loopSupportedReasoningEfforts(adapter, model)).toEqual(expected); + }, + ); +}); diff --git a/packages/ui/src/features/loops/loopModelDefaults.ts b/packages/ui/src/features/loops/loopModelDefaults.ts new file mode 100644 index 0000000000..35a07d0b1e --- /dev/null +++ b/packages/ui/src/features/loops/loopModelDefaults.ts @@ -0,0 +1,46 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; + +// Mirrors DEFAULT_MODEL_BY_RUNTIME_ADAPTER and the per-model effort tables in posthog's products/tasks/backend/temporal/process_task/utils.py. +export const LOOP_DEFAULT_MODEL_BY_ADAPTER: Record< + LoopSchemas.LoopRuntimeAdapterEnum, + string +> = { + claude: "@cf/zai-org/glm-5.2", + codex: "gpt-5", +}; + +const ALL_EFFORTS: LoopSchemas.LoopReasoningEffortEnum[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +]; +const LOW_TO_HIGH: LoopSchemas.LoopReasoningEffortEnum[] = [ + "low", + "medium", + "high", +]; + +const EFFORTS_BY_MODEL: Record = + { + "@cf/zai-org/glm-5.2": ["high", "max"], + "claude-opus-4-5": LOW_TO_HIGH, + "claude-sonnet-4-6": LOW_TO_HIGH, + "gpt-5": LOW_TO_HIGH, + "gpt-5.5": ["low", "medium", "high", "xhigh"], + }; + +export function loopEffectiveModel( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + model: string, +): string { + return model || LOOP_DEFAULT_MODEL_BY_ADAPTER[adapter]; +} + +export function loopSupportedReasoningEfforts( + adapter: LoopSchemas.LoopRuntimeAdapterEnum, + model: string, +): LoopSchemas.LoopReasoningEffortEnum[] { + return EFFORTS_BY_MODEL[loopEffectiveModel(adapter, model)] ?? ALL_EFFORTS; +} From 4df7f259c24d086657c8f50b64693b4c997b86f0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 22 Jul 2026 23:37:27 -0700 Subject: [PATCH 2/2] clamp legacy loop efforts to auto on hydrate --- .../loops/components/LoopModelFields.tsx | 13 +++----- .../src/features/loops/loopFormTypes.test.ts | 33 +++++++++++++++++++ .../ui/src/features/loops/loopFormTypes.ts | 19 +++++++++-- .../features/loops/loopModelDefaults.test.ts | 2 ++ .../src/features/loops/loopModelDefaults.ts | 7 +++- 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopModelFields.tsx b/packages/ui/src/features/loops/components/LoopModelFields.tsx index df4c12d3bb..9b2aef374d 100644 --- a/packages/ui/src/features/loops/components/LoopModelFields.tsx +++ b/packages/ui/src/features/loops/components/LoopModelFields.tsx @@ -88,22 +88,17 @@ export function LoopModelFields({ ]; }, [catalog, model]); - // The current effort stays selectable even when the effective model no longer - // supports it (a loop saved under an older default), same as the model list. + // An unsupported saved effort can't reach this select: `normalizeLoopFormValues` + // clamps it to auto when the form hydrates. const reasoningEffortOptions = useMemo(() => { - const supported = loopSupportedReasoningEfforts(adapter, model); - const efforts = - reasoningEffort && !supported.includes(reasoningEffort) - ? [...supported, reasoningEffort] - : supported; return [ { value: AUTO_REASONING_VALUE, label: "Auto" }, - ...efforts.map((effort) => ({ + ...loopSupportedReasoningEfforts(adapter, model).map((effort) => ({ value: effort, label: REASONING_EFFORT_LABELS[effort], })), ]; - }, [adapter, model, reasoningEffort]); + }, [adapter, model]); const clearUnsupportedEffort = ( nextAdapter: LoopSchemas.LoopRuntimeAdapterEnum, diff --git a/packages/ui/src/features/loops/loopFormTypes.test.ts b/packages/ui/src/features/loops/loopFormTypes.test.ts index 320493c533..30a003ee7e 100644 --- a/packages/ui/src/features/loops/loopFormTypes.test.ts +++ b/packages/ui/src/features/loops/loopFormTypes.test.ts @@ -163,6 +163,39 @@ describe("normalizeLoopFormValues", () => { const values = validFormValues(); expect(normalizeLoopFormValues(values)).toBe(values); }); + + it.each([ + { + name: "unpinned claude with an effort the default model lacks", + overrides: { model: "", reasoningEffort: "medium" }, + expected: null, + }, + { + name: "unpinned claude with an effort the default model supports", + overrides: { model: "", reasoningEffort: "high" }, + expected: "high", + }, + { + name: "pinned model supporting the effort", + overrides: { model: "claude-sonnet-5", reasoningEffort: "medium" }, + expected: "medium", + }, + { + name: "unpinned codex with an effort the default model lacks", + overrides: { + runtimeAdapter: "codex", + model: "", + reasoningEffort: "max", + }, + expected: null, + }, + ] as const)( + "clamps an unsupported effort to auto: $name", + ({ overrides, expected }) => { + const values = { ...validFormValues(), ...overrides }; + expect(normalizeLoopFormValues(values).reasoningEffort).toBe(expected); + }, + ); }); describe("formValuesToLoopWrite", () => { diff --git a/packages/ui/src/features/loops/loopFormTypes.ts b/packages/ui/src/features/loops/loopFormTypes.ts index 4825680804..4fd5c357f3 100644 --- a/packages/ui/src/features/loops/loopFormTypes.ts +++ b/packages/ui/src/features/loops/loopFormTypes.ts @@ -1,5 +1,6 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; import { systemTimezone } from "@posthog/ui/primitives/timezone"; +import { loopSupportedReasoningEfforts } from "./loopModelDefaults"; /** * A trigger row in the create/edit form. `key` is a client-only stable @@ -131,10 +132,22 @@ export function emptyLoopFormValues(): LoopFormValues { export function normalizeLoopFormValues( values: LoopFormValues, ): LoopFormValues { - if (values.contextTarget && values.visibility !== "team") { - return { ...values, visibility: "team" }; + let normalized = values; + if (normalized.contextTarget && normalized.visibility !== "team") { + normalized = { ...normalized, visibility: "team" }; + } + // A stored effort can predate a fire-time default change; carrying it into + // the form would make every save 400. It fires as auto anyway, so show that. + if ( + normalized.reasoningEffort && + !loopSupportedReasoningEfforts( + normalized.runtimeAdapter, + normalized.model, + ).includes(normalized.reasoningEffort) + ) { + normalized = { ...normalized, reasoningEffort: null }; } - return values; + return normalized; } export function loopToFormValues(loop: LoopSchemas.Loop): LoopFormValues { diff --git a/packages/ui/src/features/loops/loopModelDefaults.test.ts b/packages/ui/src/features/loops/loopModelDefaults.test.ts index 220bf3c32f..911d1a1ae0 100644 --- a/packages/ui/src/features/loops/loopModelDefaults.test.ts +++ b/packages/ui/src/features/loops/loopModelDefaults.test.ts @@ -27,6 +27,8 @@ describe("loopSupportedReasoningEfforts", () => { ["codex", "", ["low", "medium", "high"]], ["codex", "gpt-5.5", ["low", "medium", "high", "xhigh"]], ["codex", "gpt-5.6-sol", ["low", "medium", "high", "xhigh", "max"]], + ["codex", "gpt-unknown", ["low", "medium", "high"]], + ["claude", "claude-unknown", ["low", "medium", "high", "xhigh", "max"]], ] as const)( "adapter %s with model %j supports %j", (adapter, model, expected) => { diff --git a/packages/ui/src/features/loops/loopModelDefaults.ts b/packages/ui/src/features/loops/loopModelDefaults.ts index 35a07d0b1e..02bd1d2eef 100644 --- a/packages/ui/src/features/loops/loopModelDefaults.ts +++ b/packages/ui/src/features/loops/loopModelDefaults.ts @@ -29,6 +29,9 @@ const EFFORTS_BY_MODEL: Record = "claude-sonnet-4-6": LOW_TO_HIGH, "gpt-5": LOW_TO_HIGH, "gpt-5.5": ["low", "medium", "high", "xhigh"], + "gpt-5.6-sol": ALL_EFFORTS, + "gpt-5.6-terra": ALL_EFFORTS, + "gpt-5.6-luna": ALL_EFFORTS, }; export function loopEffectiveModel( @@ -42,5 +45,7 @@ export function loopSupportedReasoningEfforts( adapter: LoopSchemas.LoopRuntimeAdapterEnum, model: string, ): LoopSchemas.LoopReasoningEffortEnum[] { - return EFFORTS_BY_MODEL[loopEffectiveModel(adapter, model)] ?? ALL_EFFORTS; + const efforts = EFFORTS_BY_MODEL[loopEffectiveModel(adapter, model)]; + if (efforts) return efforts; + return adapter === "codex" ? LOW_TO_HIGH : ALL_EFFORTS; }