Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1695,4 +1695,71 @@ describe("PostHogAPIClient", () => {
});
});
});

describe("getTaskRunDefaults", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
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();
});
});
});
47 changes: 47 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<TaskRunDefaults | null> {
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;
Expand Down
77 changes: 62 additions & 15 deletions packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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" &&
Expand All @@ -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);
})
Expand All @@ -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) => {
Expand Down
Loading