From 21ef8634269d41121c651c5728cfedb555c4f5d8 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Tue, 14 Jul 2026 13:23:50 +0200 Subject: [PATCH] feat(task-input): preselect server-side team/user default model Fetch the resolved AI run defaults (the user's per-project preference over the project default, resolved by the tasks backend) and preselect them in the task input picker ahead of the device-local last-used model. Changing the picker still affects only the current composition. Older servers without the endpoint, fetch failures, and defaults targeting the other runtime adapter all fall back to the existing behavior. --- .../api-client/src/posthog-client.test.ts | 67 ++++++++++++++++ packages/api-client/src/posthog-client.ts | 47 +++++++++++ .../task-detail/hooks/usePreviewConfig.ts | 77 +++++++++++++++---- 3 files changed, 176 insertions(+), 15 deletions(-) diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 8c29ded301..47c0e85128 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1695,4 +1695,71 @@ describe("PostHogAPIClient", () => { }); }); }); + + describe("getTaskRunDefaults", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; + return client; + } + + it("maps the resolved defaults into the client shape", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ai_run_preferences: {}, + resolved_ai_run_defaults: { + runtime_adapter: "claude", + model: "claude-opus-4-8", + reasoning_effort: "high", + source: "team", + }, + }), + }); + const client = makeClient(fetch); + + await expect(client.getTaskRunDefaults()).resolves.toEqual({ + runtimeAdapter: "claude", + model: "claude-opus-4-8", + reasoningEffort: "high", + source: "team", + }); + expect(fetch.mock.calls[0][0].path).toBe( + "/api/projects/123/tasks/my_config/", + ); + }); + + // Defaults are a nicety: an older server without the endpoint, or a project + // with nothing configured, must resolve to null rather than break task creation. + it.each([ + ["endpoint missing on older servers", { ok: false, status: 404 }], + [ + "no default configured", + { + ok: true, + json: async () => ({ + ai_run_preferences: {}, + resolved_ai_run_defaults: { + runtime_adapter: null, + model: null, + reasoning_effort: null, + source: "none", + }, + }), + }, + ], + ])("resolves to null when %s", async (_name, response) => { + const client = makeClient(vi.fn().mockResolvedValue(response)); + await expect(client.getTaskRunDefaults()).resolves.toBeNull(); + }); + }); }); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 18a2aa6da6..aa4ee60945 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -192,6 +192,15 @@ export type { export type Evaluation = Schemas.Evaluation; +/** Server-resolved default AI run configuration for the current user + project. */ +export interface TaskRunDefaults { + runtimeAdapter: string; + model: string; + reasoningEffort: string | null; + /** Which preference level supplied the default: the user's own, or the project's. */ + source: "user" | "team"; +} + export interface UserGitHubIntegration { id: string; kind: "github"; @@ -2279,6 +2288,44 @@ export class PostHogAPIClient { } } + /** + * The server-resolved default AI run triple for the current user in the current + * project: their per-project preference over the project-wide default, as + * resolved by the tasks backend. Returns null when no default is configured or + * the endpoint is unavailable (older servers) — defaults are a nicety and must + * never block task creation. + */ + async getTaskRunDefaults(): Promise { + const teamId = await this.getTeamId(); + const urlPath = `/api/projects/${teamId}/tasks/my_config/`; + const response = await this.api.fetcher.fetch({ + method: "get", + url: new URL(`${this.api.baseUrl}${urlPath}`), + path: urlPath, + }); + if (!response.ok) { + return null; + } + const data = (await response.json().catch(() => null)) as { + resolved_ai_run_defaults?: { + runtime_adapter?: string | null; + model?: string | null; + reasoning_effort?: string | null; + source?: string; + }; + } | null; + const resolved = data?.resolved_ai_run_defaults; + if (!resolved?.runtime_adapter || !resolved.model) { + return null; + } + return { + runtimeAdapter: resolved.runtime_adapter, + model: resolved.model, + reasoningEffort: resolved.reasoning_effort ?? null, + source: resolved.source === "user" ? "user" : "team", + }; + } + async getTasks(options?: { repository?: string; createdBy?: number; diff --git a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts index 2cc286788b..58cc8d5108 100644 --- a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts +++ b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts @@ -15,6 +15,7 @@ import { import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { logger } from "../../../shell/logger"; +import { useOptionalAuthenticatedClient } from "../../auth/authClient"; import { useAuthStateValue } from "../../auth/store"; import { useFeatureFlag } from "../../feature-flags/useFeatureFlag"; import { useSettingsStore } from "../../settings/settingsStore"; @@ -47,6 +48,7 @@ function getOptionByCategory( */ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { const hostClient = useHostTRPCClient(); + const apiClient = useOptionalAuthenticatedClient(); const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const apiHost = useMemo( @@ -78,9 +80,23 @@ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { // as the current selection while the new adapter's config is loading. setConfigOptions([]); - hostClient.agent.getPreviewConfigOptions - .query({ apiHost, adapter }, { signal: abort.signal }) - .then((serverOptions) => { + // The server-side team/user default is a per-project policy, so it wins over + // the device-local last-used model; failures resolve to null and fall back. + const serverDefaultsPromise = apiClient + ? apiClient.getTaskRunDefaults().catch((error: unknown) => { + log.warn("Failed to fetch task run defaults", { error }); + return null; + }) + : Promise.resolve(null); + + Promise.all([ + hostClient.agent.getPreviewConfigOptions.query( + { apiHost, adapter }, + { signal: abort.signal }, + ), + serverDefaultsPromise, + ]) + .then(([serverOptions, serverDefaults]) => { if (abort.signal.aborted) return; const options = glmEnabled @@ -106,12 +122,27 @@ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { adapter, ); - // The server always returns its default model as the current value, so - // without this the user's last (default-eligible) pick is lost on every - // refetch/remount. Restore it through applyConfigChange so the - // dependent effort options are recomputed for the restored model. + const changeSettings = { + defaultInitialTaskMode: "", + lastUsedInitialTaskMode: undefined, + defaultReasoningEffort, + lastUsedReasoningEffort, + }; + + // Resolution order for the preselected model: the server-side team/user + // default (when it targets this adapter and the gateway still offers the + // model), else the device-local last-used pick, else the gateway default. + // Changing the picker afterwards affects only the current composition. const modelOpt = getOptionByCategory(initial, "model"); - const restorableModel = defaultEligibleModel(lastUsedModel); + const serverDefaultModel = + serverDefaults?.runtimeAdapter === adapter && + serverDefaults.model && + modelOpt?.type === "select" && + flattenConfigValues(modelOpt).includes(serverDefaults.model) + ? serverDefaults.model + : null; + const restorableModel = + serverDefaultModel ?? defaultEligibleModel(lastUsedModel); if ( restorableModel && modelOpt?.type === "select" && @@ -124,15 +155,31 @@ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { value: restorableModel, effortOptions: getReasoningEffortOptions(adapter, restorableModel) ?? undefined, - settings: { - defaultInitialTaskMode: "", - lastUsedInitialTaskMode: undefined, - defaultReasoningEffort, - lastUsedReasoningEffort, - }, + settings: changeSettings, }); } + // The default triple's effort applies with its model; a stored effort the + // model doesn't support is simply not offered and stays on the recomputed + // default. + if (serverDefaultModel && serverDefaults?.reasoningEffort) { + const thoughtOpt = getOptionByCategory(initial, "thought_level"); + if ( + thoughtOpt?.type === "select" && + flattenConfigValues(thoughtOpt).includes( + serverDefaults.reasoningEffort, + ) + ) { + initial = applyConfigChange(initial, { + adapter, + configId: thoughtOpt.id, + value: serverDefaults.reasoningEffort, + effortOptions: undefined, + settings: changeSettings, + }); + } + } + setConfigOptions(initial); setIsLoading(false); }) @@ -145,7 +192,7 @@ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { return () => { abort.abort(); }; - }, [adapter, apiHost, hostClient, hasHydrated, glmEnabled]); + }, [adapter, apiHost, hostClient, apiClient, hasHydrated, glmEnabled]); const setConfigOption = useCallback( (configId: string, value: string) => {