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) => {