From 484a6c7b88e6578dbe5dbdd74b6cc1753900a985 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:17:22 +0300 Subject: [PATCH 1/9] refactor(shared): extract task contracts and model policy Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../agent/src/adapters/reasoning-effort.ts | 45 +- packages/agent/src/gateway-models.test.ts | 252 +----------- packages/agent/src/gateway-models.ts | 274 ++----------- packages/agent/src/utils/gateway.ts | 23 +- packages/api-client/src/posthog-client.ts | 2 +- packages/shared/src/cloud-task-models.test.ts | 200 +++++++++ packages/shared/src/cloud-task-models.ts | 385 ++++++++++++++++++ packages/shared/src/domain-types.test.ts | 19 +- packages/shared/src/domain-types.ts | 34 +- packages/shared/src/index.ts | 74 +++- packages/shared/src/reasoning-effort.test.ts | 20 + packages/shared/src/reasoning-effort.ts | 77 ++++ packages/shared/src/sessions.ts | 2 +- packages/shared/src/task-automation.test.ts | 66 +++ packages/shared/src/task-automation.ts | 58 +++ packages/shared/src/task.test.ts | 53 +++ packages/shared/src/task.ts | 103 +---- .../src/services/agent/agent.ts | 117 +----- 18 files changed, 1035 insertions(+), 769 deletions(-) create mode 100644 packages/shared/src/cloud-task-models.test.ts create mode 100644 packages/shared/src/cloud-task-models.ts create mode 100644 packages/shared/src/reasoning-effort.test.ts create mode 100644 packages/shared/src/reasoning-effort.ts create mode 100644 packages/shared/src/task-automation.test.ts create mode 100644 packages/shared/src/task-automation.ts create mode 100644 packages/shared/src/task.test.ts diff --git a/packages/agent/src/adapters/reasoning-effort.ts b/packages/agent/src/adapters/reasoning-effort.ts index 590bb3be86..2cc1da50f8 100644 --- a/packages/agent/src/adapters/reasoning-effort.ts +++ b/packages/agent/src/adapters/reasoning-effort.ts @@ -1,39 +1,6 @@ -import type { Adapter } from "@posthog/shared"; -import { getEffortOptions as getClaudeEffortOptions } from "./claude/session/models"; -import { getReasoningEffortOptions as getCodexReasoningEffortOptions } from "./codex-app-server/models"; - -export type SupportedReasoningEffort = - | "low" - | "medium" - | "high" - | "xhigh" - | "max"; - -export interface ReasoningEffortOption { - value: SupportedReasoningEffort; - name: string; -} - -export function getReasoningEffortOptions( - adapter: Adapter, - modelId: string, -): ReasoningEffortOption[] | null { - const options = - adapter === "codex" - ? getCodexReasoningEffortOptions(modelId) - : getClaudeEffortOptions(modelId); - - return options as ReasoningEffortOption[] | null; -} - -export function isSupportedReasoningEffort( - adapter: Adapter, - modelId: string, - value: string, -): value is SupportedReasoningEffort { - return ( - getReasoningEffortOptions(adapter, modelId)?.some( - (option) => option.value === value, - ) ?? false - ); -} +export { + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "@posthog/shared"; diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index 5aa7438a8d..8c988ec1e9 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -1,163 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - compareModelsForPicker, - fetchGatewayModels, - fetchModelsList, - formatGatewayModelName, - type GatewayModel, - getClaudeModelRecency, - isAnthropicModel, - isBlockedModelId, - isCloudflareModel, - pickAllowedModel, -} from "./gateway-models"; - -const model = (id: string, owned_by = ""): GatewayModel => ({ - id, - owned_by, - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, -}); - -describe("formatGatewayModelName", () => { - it("keeps Claude models in friendly title case", () => { - expect( - formatGatewayModelName({ - id: "claude-opus-4-8", - owned_by: "anthropic", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("Claude Opus 4.8"); - }); - - it("uppercases the GPT acronym in OpenAI model ids", () => { - expect( - formatGatewayModelName({ - id: "GPT-5.5", - owned_by: "openai", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("GPT-5.5"); - }); - - it("strips the openai/ prefix, uppercases GPT, and title-cases the suffix", () => { - expect( - formatGatewayModelName({ - id: "openai/gpt-5.6-sol", - owned_by: "openai", - context_window: 200000, - supports_streaming: true, - supports_vision: true, - allowed: true, - }), - ).toBe("GPT-5.6 Sol"); - }); - - it("formats Cloudflare models as the final path segment with GLM uppercased", () => { - expect( - formatGatewayModelName({ - id: "@cf/zai-org/glm-5.2", - owned_by: "cloudflare", - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, - }), - ).toBe("GLM-5.2"); - }); - - it("leaves non-acronym Cloudflare models lowercase", () => { - expect( - formatGatewayModelName({ - id: "@cf/meta/llama-3.1-8b-instruct", - owned_by: "cloudflare", - context_window: 128000, - supports_streaming: true, - supports_vision: false, - allowed: true, - }), - ).toBe("llama-3.1-8b-instruct"); - }); - - it("blocks deprecated Claude gateway models", () => { - expect(isBlockedModelId("claude-opus-4-5")).toBe(true); - expect(isBlockedModelId("claude-opus-4-6")).toBe(true); - expect(isBlockedModelId("claude-sonnet-4-5")).toBe(true); - expect(isBlockedModelId("claude-haiku-4-5")).toBe(true); - expect(isBlockedModelId("ANTHROPIC/CLAUDE-HAIKU-4-5")).toBe(true); - }); - - it("blocks deprecated Codex gateway models", () => { - expect(isBlockedModelId("gpt-5.2")).toBe(true); - expect(isBlockedModelId("gpt-5.3")).toBe(true); - expect(isBlockedModelId("gpt-5.3-codex")).toBe(true); - expect(isBlockedModelId("openai/gpt-5.2")).toBe(true); - expect(isBlockedModelId("OPENAI/GPT-5.3")).toBe(true); - expect(isBlockedModelId("OPENAI/GPT-5.3-CODEX")).toBe(true); - }); -}); - -describe("getClaudeModelRecency", () => { - it.each([ - ["claude-haiku-4-5", 4005], - ["claude-sonnet-4-6", 4006], - ["claude-opus-4-7", 4007], - ["claude-opus-4-8", 4008], - ["claude-opus-5", 5000], - ["claude-sonnet-5", 5000], - ["claude-fable-5", 5000], - ])("ranks %s by its embedded version (%i)", (modelId, rank) => { - expect(getClaudeModelRecency(modelId)).toBe(rank); - }); - - it("ignores a trailing date suffix when reading the version", () => { - expect(getClaudeModelRecency("claude-haiku-4-5-20251001")).toBe(4005); - }); - - it("ranks a model with no recognisable version as newest", () => { - expect(getClaudeModelRecency("claude-mystery")).toBe( - Number.MAX_SAFE_INTEGER, - ); - expect(getClaudeModelRecency("claude-mystery")).toBeGreaterThan( - getClaudeModelRecency("claude-fable-5"), - ); - }); -}); - -describe("compareModelsForPicker", () => { - it("groups models by family least capable first, newest version first", () => { - // The picker opens upward, so least-capable-first DOM order puts the most - // capable family (Fable) nearest the trigger — the visual top of the menu. - // Models as the gateway might return them — arbitrary order. - const gatewayOrder = [ - "claude-fable-5", - "claude-opus-4-7", - "claude-mystery", - "claude-sonnet-5", - "claude-haiku-4-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - ]; - const displayed = [...gatewayOrder].sort(compareModelsForPicker); - expect(displayed).toEqual([ - "claude-haiku-4-5", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-fable-5", - "claude-mystery", - ]); - }); -}); +import { fetchGatewayModels, fetchModelsList } from "./gateway-models"; describe("gateway model fetch timeout", () => { afterEach(() => { @@ -242,96 +84,4 @@ describe("gateway models cache", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(cached[0]?.allowed).toBe(false); }); - - it("corrects stale GLM 5.2 context-window metadata", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - object: "list", - data: [ - { - id: "@cf/zai-org/glm-5.2", - owned_by: "cloudflare", - context_window: 128_000, - supports_streaming: true, - supports_vision: false, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - - const models = await fetchGatewayModels({ - gatewayUrl: "https://gateway.glm-context-test", - }); - - expect(models[0]?.context_window).toBe(1_000_000); - }); -}); - -describe("isCloudflareModel", () => { - it.each([ - { id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare", expected: true }, - { id: "claude-opus-4-8", owned_by: "anthropic", expected: false }, - { id: "@cf/zai-org/glm-5.2", owned_by: "", expected: true }, - { id: "gpt-5.5", owned_by: "", expected: false }, - // A Cloudflare-served model can report an upstream owner; the `@cf/` prefix still wins. - { id: "@cf/openai/gpt-oss", owned_by: "openai", expected: true }, - ])( - "isCloudflareModel($id, owned_by=$owned_by) → $expected", - ({ id, owned_by, expected }) => { - expect(isCloudflareModel(model(id, owned_by))).toBe(expected); - }, - ); - - it("does not classify Cloudflare models as Anthropic", () => { - // The Claude adapter accepts both, but they must stay distinguishable. - const glm = model("@cf/zai-org/glm-5.2", "cloudflare"); - expect(isCloudflareModel(glm)).toBe(true); - expect(isAnthropicModel(glm)).toBe(false); - }); -}); - -describe("pickAllowedModel", () => { - const entry = (id: string, allowed: boolean) => ({ id, allowed }); - - it.each([ - [ - "keeps an allowed preferred model", - [entry("claude-opus-4-8", true)], - "claude-opus-4-8", - "claude-opus-4-8", - ], - [ - "keeps a preferred model absent from the list", - [entry("claude-opus-4-8", true)], - "claude-sonnet-5", - "claude-sonnet-5", - ], - [ - "moves a restricted preferred model to the newest allowed one", - [ - entry("claude-opus-4-8", false), - entry("claude-sonnet-4-6", true), - entry("@cf/zai-org/glm-5.2", true), - ], - "claude-opus-4-8", - "@cf/zai-org/glm-5.2", - ], - [ - "keeps the preferred model when everything is restricted", - [entry("claude-opus-4-8", false)], - "claude-opus-4-8", - "claude-opus-4-8", - ], - [ - "keeps the preferred model when the list is empty", - [], - "claude-opus-4-8", - "claude-opus-4-8", - ], - ] as const)("%s", (_name, models, preferred, expected) => { - expect(pickAllowedModel(models, preferred)).toBe(expected); - }); }); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 116d9bd7d1..35ae10df5a 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -1,20 +1,28 @@ -export interface GatewayModel { - id: string; - owned_by: string; - context_window: number; - supports_streaming: boolean; - supports_vision: boolean; - // Free-tier model gate: authenticated fetches mark models outside the - // caller's plan `allowed: false`. Anonymous fetches and older gateways - // don't mark, so absence means allowed. - allowed: boolean; - restriction_reason?: string | null; -} - -interface GatewayModelsResponse { - object: "list"; - data: Array & { allowed?: boolean }>; -} +import { + type GatewayModel, + normalizeGatewayModelsResponse, +} from "@posthog/shared"; + +export { + BLOCKED_GATEWAY_MODEL_IDS, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + type CloudTaskConfigSelectOption, + compareModelsForPicker, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + formatGatewayModelName, + formatModelId, + type GatewayModel, + getClaudeModelRecency, + getProviderName, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isCloudflareModelId, + isOpenAIModel, + pickAllowedModel, +} from "@posthog/shared"; export interface FetchGatewayModelsOptions { gatewayUrl: string; @@ -22,47 +30,6 @@ export interface FetchGatewayModelsOptions { authToken?: string; } -export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; - -export const DEFAULT_CODEX_MODEL = "gpt-5.5"; - -const BLOCKED_MODELS = new Set([ - "gpt-5-mini", - "openai/gpt-5-mini", - "gpt-5.2", - "openai/gpt-5.2", - "gpt-5.3", - "openai/gpt-5.3", - "gpt-5.3-codex", - "openai/gpt-5.3-codex", - "claude-opus-4-5", - "anthropic/claude-opus-4-5", - "claude-opus-4-6", - "anthropic/claude-opus-4-6", - "claude-sonnet-4-5", - "anthropic/claude-sonnet-4-5", - "claude-haiku-4-5", - "anthropic/claude-haiku-4-5", -]); - -export function isBlockedModelId(modelId: string): boolean { - return BLOCKED_MODELS.has(modelId.toLowerCase()); -} - -interface ModelsListEntry { - id?: string; - owned_by?: string; - allowed?: boolean; - restriction_reason?: string | null; -} - -type ModelsListResponse = - | { - data?: ModelsListEntry[]; - models?: ModelsListEntry[]; - } - | ModelsListEntry[]; - const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // Bound the gateway /v1/models request so a stalled connection cannot hold up @@ -71,10 +38,6 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // the callers fall through to `return []`. const GATEWAY_FETCH_TIMEOUT_MS = 10_000; -const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { - "@cf/zai-org/glm-5.2": 1_000_000, -}; - // Restriction marks are identity-scoped (free-tier marks are authed-only and // differ per org), so cache entries are keyed on the exact token — an org // switch in the same process must never be served the old org's marks. A @@ -125,17 +88,7 @@ export async function fetchGatewayModels( return []; } - const data = (await response.json()) as GatewayModelsResponse; - const models = (data.data ?? []) - .filter((m) => !isBlockedModelId(m.id)) - .map((m) => ({ - ...m, - context_window: Math.max( - m.context_window, - MODEL_CONTEXT_WINDOW_OVERRIDES[m.id] ?? 0, - ), - allowed: m.allowed !== false, - })); + const models = normalizeGatewayModelsResponse(await response.json()); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, @@ -148,36 +101,6 @@ export async function fetchGatewayModels( } } -export function isAnthropicModel(model: GatewayModel): boolean { - if (model.owned_by) { - return model.owned_by === "anthropic"; - } - return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); -} - -export function isOpenAIModel(model: GatewayModel): boolean { - if (model.owned_by) { - return model.owned_by === "openai"; - } - return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); -} - -// Cloudflare Workers AI model ids carry the `@cf/` path prefix (e.g. `@cf/zai-org/glm-5.2`). Kept as -// a standalone id-only check so callers that only have a model id (not a full GatewayModel) — like the -// Claude adapter's desync guard — share one source of truth with `isCloudflareModel`. -export function isCloudflareModelId(modelId: string): boolean { - return modelId.startsWith("@cf/"); -} - -// Cloudflare Workers AI models (e.g. `@cf/zai-org/glm-5.2`). The gateway serves these over both its -// OpenAI and Anthropic-Messages surfaces (it translates the `@cf/` path), so the Claude adapter can -// drive them just like an Anthropic model. The `@cf/` path prefix is the structural, always-present -// signal, so honour it regardless of `owned_by` — a Cloudflare-served model can report an upstream -// owner (e.g. `@cf/openai/...` with `owned_by: "openai"`) and must still classify as Cloudflare. -export function isCloudflareModel(model: GatewayModel): boolean { - return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; -} - export interface ModelInfo { id: string; owned_by?: string; @@ -208,22 +131,14 @@ export async function fetchModelsList( if (!response.ok) { return []; } - const data = (await response.json()) as ModelsListResponse; - const models = Array.isArray(data) - ? data - : (data.data ?? data.models ?? []); - const results: ModelInfo[] = []; - for (const model of models) { - const id = model?.id ? String(model.id) : ""; - if (!id) continue; - if (isBlockedModelId(id)) continue; - results.push({ - id, - owned_by: model?.owned_by, - allowed: model?.allowed !== false, - restriction_reason: model?.restriction_reason ?? null, - }); - } + const results = normalizeGatewayModelsResponse(await response.json()).map( + (model) => ({ + id: model.id, + owned_by: model.owned_by || undefined, + allowed: model.allowed, + restriction_reason: model.restriction_reason, + }), + ); modelsListCache = { models: results, expiry: Date.now() + CACHE_TTL, @@ -235,124 +150,3 @@ export async function fetchModelsList( return []; } } - -/** - * The model a session should start on: the preferred id when present and - * allowed, else the newest allowed model — a free-tier org must not default - * onto a model that 403s its first message. Falls back to the preferred id - * when the list is empty (fetch failed) or nothing is allowed (all locked — - * the picker gate communicates that state better than a silent swap). - */ -export function pickAllowedModel( - models: ReadonlyArray>, - preferred: string, -): string { - if (models.length === 0) return preferred; - const preferredEntry = models.find((m) => m.id === preferred); - if (!preferredEntry || preferredEntry.allowed) return preferred; - const allowed = models.filter((m) => m.allowed); - if (allowed.length === 0) return preferred; - return allowed.reduce((best, candidate) => - getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) - ? candidate - : best, - ).id; -} - -const PROVIDER_NAMES: Record = { - anthropic: "Anthropic", - openai: "OpenAI", - "google-vertex": "Gemini", -}; - -export function getProviderName(ownedBy: string): string { - return PROVIDER_NAMES[ownedBy] ?? ownedBy; -} - -// Version embedded in the model id, e.g. "claude-opus-4-8" -> 4008. Ids with no -// recognisable version rank newest. A trailing date suffix is ignored. -export function getClaudeModelRecency(modelId: string): number { - const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); - if (!match) return Number.MAX_SAFE_INTEGER; - const major = Number(match[1]); - const minor = match[2] ? Number(match[2]) : 0; - return major * 1000 + minor; -} - -// Families ordered least-capable first. The picker opens upward (side="top") -// from the composer, so items later in this list render nearer the trigger and -// read as the top of the menu — this puts the most-capable family (Fable) on -// top. Unknown families sort after all known ones. -const MODEL_FAMILY_ORDER = ["haiku", "sonnet", "opus", "fable"]; - -function getModelFamilyRank(modelId: string): number { - const id = modelId.toLowerCase(); - const index = MODEL_FAMILY_ORDER.findIndex((family) => id.includes(family)); - return index === -1 ? MODEL_FAMILY_ORDER.length : index; -} - -// Group by family, then newest version first within each family. -export function compareModelsForPicker(a: string, b: string): number { - const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); - if (familyDiff !== 0) return familyDiff; - return getClaudeModelRecency(b) - getClaudeModelRecency(a); -} - -const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; - -const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); - -// For a known acronym, uppercase it, keep the version attached, and title-case -// any suffix: "gpt-5.6-sol" -> "GPT-5.6 Sol", "glm-5.2" -> "GLM-5.2". Other ids -// stay lowercase to avoid mangling ordinary names (e.g. "llama-3.1-8b"). -function formatProviderModelName(modelId: string): string { - const [acronym, version, ...suffix] = modelId.split("-"); - if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); - const head = version - ? `${acronym.toUpperCase()}-${version}` - : acronym.toUpperCase(); - const tail = suffix.map( - (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), - ); - return [head, ...tail].join(" "); -} - -export function formatGatewayModelName(model: GatewayModel): string { - if (isCloudflareModel(model)) { - return formatProviderModelName(model.id.split("/").pop() ?? model.id); - } - - if (isOpenAIModel(model)) { - return formatProviderModelName(stripProviderPrefix(model.id)); - } - - return formatModelId(model.id); -} - -function stripProviderPrefix(modelId: string): string { - for (const prefix of PROVIDER_PREFIXES) { - if (modelId.startsWith(prefix)) { - return modelId.slice(prefix.length); - } - } - return modelId; -} - -export function formatModelId(modelId: string): string { - let cleanId = modelId; - for (const prefix of PROVIDER_PREFIXES) { - if (cleanId.startsWith(prefix)) { - cleanId = cleanId.slice(prefix.length); - break; - } - } - - cleanId = cleanId.replace(/(\d)-(\d)/g, "$1.$2"); - - const words = cleanId.split(/[-_]/).map((word) => { - if (word.match(/^[0-9.]+$/)) return word; - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }); - - return words.join(" "); -} diff --git a/packages/agent/src/utils/gateway.ts b/packages/agent/src/utils/gateway.ts index b258086070..5e97841742 100644 --- a/packages/agent/src/utils/gateway.ts +++ b/packages/agent/src/utils/gateway.ts @@ -1,3 +1,5 @@ +import { getCloudTaskGatewayUrl } from "@posthog/shared"; + export type GatewayProduct = | "posthog_code" | "background_agents" @@ -37,26 +39,7 @@ export { } from "@posthog/shared/posthog-property-headers"; function getGatewayBaseUrl(posthogHost: string): string { - const url = new URL(posthogHost); - const hostname = url.hostname; - - if (hostname === "localhost" || hostname === "127.0.0.1") { - return `${url.protocol}//localhost:3308`; - } - - if (hostname === "host.docker.internal") { - return `${url.protocol}//host.docker.internal:3308`; - } - - // The hosted dev environment runs its own LLM gateway with its own auth DB, - // so a dev-minted `pha_` token can't be routed to the US gateway — that's - // a different DB and returns 401 Authentication required. - if (hostname === "app.dev.posthog.dev") { - return "https://gateway.dev.posthog.dev"; - } - - const region = hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; - return `https://gateway.${region}.posthog.com`; + return getCloudTaskGatewayUrl(posthogHost).replace(/\/posthog_code$/, ""); } export function getLlmGatewayUrl( diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 00bbbc7700..906e77f290 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,5 +1,4 @@ import "./generated.augment"; -import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { Adapter, CloudMcpServerImport, @@ -15,6 +14,7 @@ import type { import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + isSupportedReasoningEffort, resolveCloudInitialPermissionMode, } from "@posthog/shared"; import type { diff --git a/packages/shared/src/cloud-task-models.test.ts b/packages/shared/src/cloud-task-models.test.ts new file mode 100644 index 0000000000..31418a7775 --- /dev/null +++ b/packages/shared/src/cloud-task-models.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { + buildCloudTaskConfigOptions, + compareModelsForPicker, + formatGatewayModelName, + type GatewayModel, + getClaudeModelRecency, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; + +const model = ( + id: string, + owned_by = "anthropic", + allowed = true, +): GatewayModel => ({ + id, + owned_by, + context_window: 128000, + supports_streaming: true, + supports_vision: false, + allowed, +}); + +describe("formatGatewayModelName", () => { + it.each([ + [model("claude-opus-4-8"), "Claude Opus 4.8"], + [model("GPT-5.5", "openai"), "GPT-5.5"], + [model("openai/gpt-5.6-sol", "openai"), "GPT-5.6 Sol"], + [model("@cf/zai-org/glm-5.2", "cloudflare"), "GLM-5.2"], + [ + model("@cf/meta/llama-3.1-8b-instruct", "cloudflare"), + "llama-3.1-8b-instruct", + ], + ])("formats $id", (gatewayModel, expected) => { + expect(formatGatewayModelName(gatewayModel)).toBe(expected); + }); +}); + +describe("normalizeGatewayModelsResponse", () => { + it("corrects stale GLM 5.2 context-window metadata", () => { + const models = normalizeGatewayModelsResponse([ + model("@cf/zai-org/glm-5.2", "cloudflare"), + ]); + + expect(models[0]?.context_window).toBe(1_000_000); + }); +}); + +describe("isBlockedModelId", () => { + it.each([ + "claude-opus-4-5", + "claude-opus-4-6", + "claude-sonnet-4-5", + "ANTHROPIC/CLAUDE-HAIKU-4-5", + "gpt-5.2", + "gpt-5.3", + "gpt-5.3-codex", + "OPENAI/GPT-5.3-CODEX", + ])("blocks %s", (modelId) => { + expect(isBlockedModelId(modelId)).toBe(true); + }); +}); + +describe("getClaudeModelRecency", () => { + it.each([ + ["claude-haiku-4-5", 4005], + ["claude-sonnet-4-6", 4006], + ["claude-opus-4-7", 4007], + ["claude-opus-4-8", 4008], + ["claude-sonnet-5", 5000], + ])("ranks %s", (modelId, expected) => { + expect(getClaudeModelRecency(modelId)).toBe(expected); + }); + + it("ignores trailing dates and ranks unknown versions newest", () => { + expect(getClaudeModelRecency("claude-haiku-4-5-20251001")).toBe(4005); + expect(getClaudeModelRecency("claude-mystery")).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); +}); + +describe("compareModelsForPicker", () => { + it("groups by capability and sorts newest first", () => { + const displayed = [ + "claude-fable-5", + "claude-opus-4-7", + "claude-mystery", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-sonnet-4-6", + "claude-opus-4-8", + ].sort(compareModelsForPicker); + + expect(displayed).toEqual([ + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "claude-mystery", + ]); + }); +}); + +describe("model classification", () => { + it("keeps Cloudflare models distinct from Anthropic", () => { + const gatewayModel = model("@cf/openai/gpt-oss", "openai"); + expect(isCloudflareModel(gatewayModel)).toBe(true); + expect(isAnthropicModel(gatewayModel)).toBe(false); + }); +}); + +describe("pickAllowedModel", () => { + const entry = (id: string, allowed: boolean) => ({ id, allowed }); + + it.each([ + [[entry("claude-opus-4-8", true)], "claude-opus-4-8", "claude-opus-4-8"], + [[entry("claude-opus-4-8", true)], "claude-sonnet-5", "claude-sonnet-5"], + [ + [ + entry("claude-opus-4-8", false), + entry("claude-sonnet-4-6", true), + entry("@cf/zai-org/glm-5.2", true), + ], + "claude-opus-4-8", + "@cf/zai-org/glm-5.2", + ], + [[entry("claude-opus-4-8", false)], "claude-opus-4-8", "claude-opus-4-8"], + [[], "claude-opus-4-8", "claude-opus-4-8"], + ] as const)("selects an allowed default", (models, preferred, expected) => { + expect(pickAllowedModel(models, preferred)).toBe(expected); + }); +}); + +describe("buildCloudTaskConfigOptions", () => { + it("builds Claude options with restrictions and reasoning policy", () => { + const options = buildCloudTaskConfigOptions( + [ + model("gpt-5.5", "openai"), + model("claude-opus-4-7", "anthropic"), + model("claude-opus-4-8", "anthropic", false), + model("@cf/zai-org/glm-5.2", "cloudflare"), + ], + "claude", + ); + + expect(options).toMatchObject([ + { id: "mode", currentValue: "plan" }, + { + id: "model", + currentValue: "@cf/zai-org/glm-5.2", + options: [ + { value: "claude-opus-4-7" }, + { + value: "claude-opus-4-8", + _meta: { "posthog.code/restrictedModel": true }, + }, + { value: "@cf/zai-org/glm-5.2" }, + ], + }, + ]); + expect(options.map((option) => option.id)).toEqual(["mode", "model"]); + }); + + it("builds Codex options with the shared default and reasoning levels", () => { + const options = buildCloudTaskConfigOptions( + [ + model("claude-opus-4-8"), + model("gpt-5.6", "openai"), + model("gpt-5.5", "openai"), + ], + "codex", + ); + + expect(options).toMatchObject([ + { id: "mode", currentValue: "auto" }, + { + id: "model", + currentValue: "gpt-5.5", + options: [{ value: "gpt-5.6" }, { value: "gpt-5.5" }], + }, + { + id: "reasoning_effort", + currentValue: "high", + options: [ + { value: "low" }, + { value: "medium" }, + { value: "high" }, + { value: "xhigh" }, + ], + }, + ]); + }); +}); diff --git a/packages/shared/src/cloud-task-models.ts b/packages/shared/src/cloud-task-models.ts new file mode 100644 index 0000000000..a0fc5f4477 --- /dev/null +++ b/packages/shared/src/cloud-task-models.ts @@ -0,0 +1,385 @@ +import type { Adapter } from "./adapter"; +import { CODEX_MODE_PRESETS } from "./execution-modes"; +import { restrictedModelMeta } from "./models"; +import { getReasoningEffortOptions } from "./reasoning-effort"; + +export interface GatewayModel { + id: string; + owned_by: string; + context_window: number; + supports_streaming: boolean; + supports_vision: boolean; + allowed: boolean; + restriction_reason?: string | null; +} + +interface GatewayModelsResponse { + data?: unknown[]; + models?: unknown[]; +} + +export interface CloudTaskConfigSelectOption { + value: string; + name: string; + description?: string; + _meta?: Record; +} + +export interface CloudTaskConfigOption { + id: string; + name: string; + type: "select"; + currentValue: string; + options: CloudTaskConfigSelectOption[]; + category: "mode" | "model" | "thought_level"; + description: string; +} + +export interface CloudTaskModePreset { + id: string; + name: string; + description: string; +} + +export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; + +export const DEFAULT_CODEX_MODEL = "gpt-5.5"; + +export const BLOCKED_GATEWAY_MODEL_IDS = [ + "gpt-5-mini", + "openai/gpt-5-mini", + "gpt-5.2", + "openai/gpt-5.2", + "gpt-5.3", + "openai/gpt-5.3", + "gpt-5.3-codex", + "openai/gpt-5.3-codex", + "claude-opus-4-5", + "anthropic/claude-opus-4-5", + "claude-opus-4-6", + "anthropic/claude-opus-4-6", + "claude-sonnet-4-5", + "anthropic/claude-sonnet-4-5", + "claude-haiku-4-5", + "anthropic/claude-haiku-4-5", +] as const; + +const BLOCKED_GATEWAY_MODELS = new Set(BLOCKED_GATEWAY_MODEL_IDS); + +const CLAUDE_MODE_PRESETS: readonly CloudTaskModePreset[] = [ + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Auto-accept all permission requests", + }, + { + id: "auto", + name: "Auto Mode", + description: "Auto-approve file edits and shell commands", + }, +]; + +const PROVIDER_NAMES: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + "google-vertex": "Gemini", +}; + +const MODEL_FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"]; +const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; +const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); +const MODEL_CONTEXT_WINDOW_OVERRIDES: Readonly> = { + "@cf/zai-org/glm-5.2": 1_000_000, +}; + +export function getCloudTaskGatewayUrl(posthogHost: string): string { + const url = new URL(posthogHost); + let gatewayBaseUrl: string; + + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + gatewayBaseUrl = `${url.protocol}//localhost:3308`; + } else if (url.hostname === "host.docker.internal") { + gatewayBaseUrl = `${url.protocol}//host.docker.internal:3308`; + } else if (url.hostname === "app.dev.posthog.dev") { + gatewayBaseUrl = "https://gateway.dev.posthog.dev"; + } else { + const region = url.hostname.match(/^(us|eu)\.posthog\.com$/)?.[1] ?? "us"; + gatewayBaseUrl = `https://gateway.${region}.posthog.com`; + } + + return `${gatewayBaseUrl}/posthog_code`; +} + +function isGatewayModel(value: unknown): value is Partial & { + id: string; +} { + return ( + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" + ); +} + +export function normalizeGatewayModelsResponse(value: unknown): GatewayModel[] { + const response = value as GatewayModelsResponse; + const entries = Array.isArray(value) + ? value + : Array.isArray(response?.data) + ? response.data + : Array.isArray(response?.models) + ? response.models + : []; + + return entries + .filter(isGatewayModel) + .filter((model) => !isBlockedModelId(model.id)) + .map((model) => ({ + id: model.id, + owned_by: model.owned_by ?? "", + context_window: Math.max( + model.context_window ?? 0, + MODEL_CONTEXT_WINDOW_OVERRIDES[model.id] ?? 0, + ), + supports_streaming: model.supports_streaming ?? false, + supports_vision: model.supports_vision ?? false, + allowed: model.allowed !== false, + restriction_reason: model.restriction_reason ?? null, + })); +} + +export function isBlockedModelId(modelId: string): boolean { + return BLOCKED_GATEWAY_MODELS.has(modelId.toLowerCase()); +} + +export function isAnthropicModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "anthropic"; + } + return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); +} + +export function isOpenAIModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "openai"; + } + return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); +} + +export function isCloudflareModelId(modelId: string): boolean { + return modelId.startsWith("@cf/"); +} + +export function isGlmModelId(modelId: string): boolean { + return modelId.toLowerCase().includes("glm"); +} + +export function isCloudflareModel(model: GatewayModel): boolean { + return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; +} + +export function pickAllowedModel( + models: ReadonlyArray>, + preferred: string, +): string { + if (models.length === 0) return preferred; + const preferredEntry = models.find((model) => model.id === preferred); + if (!preferredEntry || preferredEntry.allowed) return preferred; + const allowed = models.filter((model) => model.allowed); + if (allowed.length === 0) return preferred; + return allowed.reduce((best, candidate) => + getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) + ? candidate + : best, + ).id; +} + +export function getProviderName(ownedBy: string): string { + return PROVIDER_NAMES[ownedBy] ?? ownedBy; +} + +export function getClaudeModelRecency(modelId: string): number { + const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); + if (!match) return Number.MAX_SAFE_INTEGER; + const major = Number(match[1]); + const minor = match[2] ? Number(match[2]) : 0; + return major * 1000 + minor; +} + +function getModelFamilyRank(modelId: string): number { + const normalizedModelId = modelId.toLowerCase(); + const index = MODEL_FAMILY_ORDER.findIndex((family) => + normalizedModelId.includes(family), + ); + return index === -1 ? MODEL_FAMILY_ORDER.length : index; +} + +export function compareModelsForPicker(a: string, b: string): number { + const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); + if (familyDiff !== 0) return familyDiff; + return getClaudeModelRecency(b) - getClaudeModelRecency(a); +} + +function stripProviderPrefix(modelId: string): string { + for (const prefix of PROVIDER_PREFIXES) { + if (modelId.startsWith(prefix)) { + return modelId.slice(prefix.length); + } + } + return modelId; +} + +function formatProviderModelName(modelId: string): string { + const [acronym, version, ...suffix] = modelId.split("-"); + if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); + const head = version + ? `${acronym.toUpperCase()}-${version}` + : acronym.toUpperCase(); + const tail = suffix.map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + return [head, ...tail].join(" "); +} + +export function formatGatewayModelName(model: GatewayModel): string { + if (isCloudflareModel(model)) { + return formatProviderModelName(model.id.split("/").pop() ?? model.id); + } + if (isOpenAIModel(model)) { + return formatProviderModelName(stripProviderPrefix(model.id)); + } + return formatModelId(model.id); +} + +export function formatModelId(modelId: string): string { + const cleanId = stripProviderPrefix(modelId).replace(/(\d)-(\d)/g, "$1.$2"); + return cleanId + .split(/[-_]/) + .map((word) => { + if (/^[0-9.]+$/.test(word)) return word; + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(" "); +} + +function getAdapterModels( + models: readonly GatewayModel[], + adapter: Adapter, +): GatewayModel[] { + return models.filter((model) => + adapter === "codex" + ? isOpenAIModel(model) + : isAnthropicModel(model) || isCloudflareModel(model), + ); +} + +function getModeOptions( + adapter: Adapter, + modePresets?: readonly CloudTaskModePreset[], +): CloudTaskConfigSelectOption[] { + const modes = + modePresets ?? + (adapter === "codex" ? CODEX_MODE_PRESETS : CLAUDE_MODE_PRESETS); + return modes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })); +} + +export function buildCloudTaskConfigOptions( + models: readonly GatewayModel[], + adapter: Adapter, + modePresets?: readonly CloudTaskModePreset[], +): CloudTaskConfigOption[] { + const adapterModels = getAdapterModels(models, adapter); + const modelOptions: CloudTaskConfigSelectOption[] = adapterModels.map( + (model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), + }), + ); + + if (adapter === "claude") { + modelOptions.sort( + (a, b) => getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), + ); + } + + const defaultModel = + adapter === "codex" + ? (modelOptions.find((option) => option.value === DEFAULT_CODEX_MODEL) + ?.value ?? + modelOptions[0]?.value ?? + "") + : DEFAULT_GATEWAY_MODEL; + const preferredModelId = modelOptions.some( + (option) => option.value === defaultModel, + ) + ? defaultModel + : (modelOptions[0]?.value ?? defaultModel); + const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); + + if (!modelOptions.some((option) => option.value === resolvedModelId)) { + modelOptions.unshift({ + value: resolvedModelId, + name: resolvedModelId, + description: "Custom model", + }); + } + + const configOptions: CloudTaskConfigOption[] = [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: adapter === "codex" ? "auto" : "plan", + options: getModeOptions(adapter, modePresets), + category: "mode", + description: "Choose an approval and sandboxing preset for your session", + }, + { + id: "model", + name: "Model", + type: "select", + currentValue: resolvedModelId, + options: modelOptions, + category: "model", + description: "Choose which model the agent should use", + }, + ]; + + const reasoningOptions = getReasoningEffortOptions(adapter, resolvedModelId); + if (reasoningOptions) { + configOptions.push({ + id: adapter === "codex" ? "reasoning_effort" : "effort", + name: adapter === "codex" ? "Reasoning Level" : "Effort", + type: "select", + currentValue: "high", + options: reasoningOptions, + category: "thought_level", + description: + adapter === "codex" + ? "Controls how much reasoning effort the model uses" + : "Controls how much effort Claude puts into its response", + }); + } + + return configOptions; +} diff --git a/packages/shared/src/domain-types.test.ts b/packages/shared/src/domain-types.test.ts index f9b060cbed..1264ae0462 100644 --- a/packages/shared/src/domain-types.test.ts +++ b/packages/shared/src/domain-types.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vitest"; -import { isContentlessTask } from "./domain-types"; +import { + isContentlessTask, + isTerminalStatus, + TERMINAL_STATUSES, +} from "./domain-types"; + +describe("task run statuses", () => { + it.each(TERMINAL_STATUSES)("identifies %s as terminal", (status) => { + expect(isTerminalStatus(status)).toBe(true); + }); + + it.each(["not_started", "queued", "in_progress", "unknown", null, undefined])( + "identifies %s as non-terminal", + (status) => { + expect(isTerminalStatus(status)).toBe(false); + }, + ); +}); describe("isContentlessTask", () => { it.each([ diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 4aa90fd2c7..3600423dae 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -4,6 +4,7 @@ import type { AgentRuntime } from "./agent-runtime"; import type { DismissalReasonOptionValue } from "./dismissal-reasons"; import type { StoredLogEntry } from "./session-events"; import type { TaskRunArtifact } from "./task"; +import type { UploadableSkillSource } from "./skills"; // Execution mode schema and type - shared between main and renderer export const executionModeSchema = z.enum([ @@ -192,6 +193,37 @@ export type TaskRunStatus = | "failed" | "cancelled"; +export type TaskRunEnvironment = "local" | "cloud"; + +export type ArtifactType = + | "plan" + | "context" + | "reference" + | "output" + | "artifact" + | "user_attachment" + | "skill_bundle"; + +export interface TaskRunArtifactMetadata { + skill_name: string; + skill_source: UploadableSkillSource; + content_sha256: string; + bundle_format: "zip"; + schema_version: number; +} + +export interface TaskRunArtifact { + id?: string; + name: string; + type: ArtifactType; + source?: string; + size?: number; + content_type?: string; + metadata?: TaskRunArtifactMetadata; + storage_path?: string; + uploaded_at?: string; +} + export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; export function isTerminalStatus( @@ -220,7 +252,7 @@ export interface TaskRun { model?: string | null; reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; stage?: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment?: "local" | "cloud"; + environment?: TaskRunEnvironment; status: TaskRunStatus; log_url: string; error_message: string | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ff03602e7e..5809830aba 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -71,6 +71,30 @@ export { promptBlocksToText, serializeCloudPrompt, } from "./cloud-prompt"; +export { + BLOCKED_GATEWAY_MODEL_IDS, + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + type CloudTaskConfigSelectOption, + type CloudTaskModePreset, + compareModelsForPicker, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + formatGatewayModelName, + formatModelId, + type GatewayModel, + getClaudeModelRecency, + getCloudTaskGatewayUrl, + getProviderName, + isAnthropicModel, + isBlockedModelId, + isCloudflareModel, + isCloudflareModelId, + isGlmModelId, + isOpenAIModel, + normalizeGatewayModelsResponse, + pickAllowedModel, +} from "./cloud-task-models"; export { buildInboxDeeplink, buildScoutDeeplink, @@ -87,9 +111,28 @@ export { export { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + dismissalReasonLabel, isDismissalReasonSnooze, } from "./dismissal-reasons"; -export type { SignalReportPriority, Task } from "./domain-types"; +export { + type ArtifactType, + type CloudPermissionOption, + type CloudTaskErrorUpdate, + type CloudTaskLogsUpdate, + type CloudTaskPermissionRequestUpdate, + type CloudTaskSnapshotUpdate, + type CloudTaskStatusUpdate, + type CloudTaskUpdatePayload, + isTerminalStatus, + type SignalReportPriority, + type Task, + type TaskRun, + type TaskRunArtifact, + type TaskRunArtifactMetadata, + type TaskRunEnvironment, + type TaskRunStatus, + TERMINAL_STATUSES, +} from "./domain-types"; export * from "./enrichment"; export { classifyGatewayLimitError, @@ -213,6 +256,13 @@ export { isPrivateIpv4Octets, isPrivateIpv6Literal, } from "./private-network"; +export { + DEFAULT_REASONING_EFFORT, + getReasoningEffortOptions, + isSupportedReasoningEffort, + type ReasoningEffortOption, + type SupportedReasoningEffort, +} from "./reasoning-effort"; export { type CloudRegion, formatRegionBadge, @@ -276,15 +326,19 @@ export { serializeSkillMarkdown, stripFrontmatter, } from "./skills"; -export type { - ArtifactType, - PostHogAPIConfig, - TaskRun, - TaskRunArtifact, - TaskRunArtifactMetadata, - TaskRunEnvironment, - TaskRunStatus, -} from "./task"; +export type { PostHogAPIConfig } from "./task"; +export { + type CreateTaskAutomationOptions, + createTaskAutomationSchema, + type TaskAutomation, + type TaskAutomationList, + type TaskAutomationValidationErrorDetails, + taskAutomationListSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + type UpdateTaskAutomationOptions, + updateTaskAutomationSchema, +} from "./task-automation"; export type { TaskCreationInput, TaskCreationOutput, diff --git a/packages/shared/src/reasoning-effort.test.ts b/packages/shared/src/reasoning-effort.test.ts new file mode 100644 index 0000000000..5b1e301dd3 --- /dev/null +++ b/packages/shared/src/reasoning-effort.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isSupportedReasoningEffort } from "./reasoning-effort"; + +describe("isSupportedReasoningEffort", () => { + it.each([ + ["codex", "gpt-5.5", "xhigh", true], + ["codex", "gpt-5.6-sol", "max", true], + ["codex", "gpt-5.4", "max", false], + ["claude", "claude-opus-4-8", "xhigh", true], + ["claude", "claude-sonnet-4-6", "xhigh", false], + ["claude", "claude-opus-4-8", "minimal", false], + ] as const)( + "validates %s %s effort %s", + (adapter, modelId, effort, expected) => { + expect(isSupportedReasoningEffort(adapter, modelId, effort)).toBe( + expected, + ); + }, + ); +}); diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts new file mode 100644 index 0000000000..15f534a612 --- /dev/null +++ b/packages/shared/src/reasoning-effort.ts @@ -0,0 +1,77 @@ +import type { Adapter } from "./adapter"; + +export type SupportedReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export const DEFAULT_REASONING_EFFORT: SupportedReasoningEffort = "high"; + +export interface ReasoningEffortOption { + value: SupportedReasoningEffort; + name: string; +} + +const BASE_OPTIONS: ReasoningEffortOption[] = [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, +]; + +const CLAUDE_MODELS_WITH_EFFORT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-fable-5", +]); + +const CLAUDE_MODELS_WITH_XHIGH_EFFORT = new Set([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-fable-5", +]); + +export function getReasoningEffortOptions( + adapter: Adapter, + modelId: string, +): ReasoningEffortOption[] | null { + if (adapter === "claude" && !CLAUDE_MODELS_WITH_EFFORT.has(modelId)) { + return null; + } + + const options = [...BASE_OPTIONS]; + const normalizedModelId = modelId.toLowerCase(); + const supportsXhigh = + adapter === "claude" + ? CLAUDE_MODELS_WITH_XHIGH_EFFORT.has(modelId) + : normalizedModelId.includes("gpt-5.5") || + normalizedModelId.includes("gpt-5.6"); + + if (supportsXhigh) { + options.push({ value: "xhigh", name: "Extra High" }); + } + if ( + (adapter === "claude" && supportsXhigh) || + (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) + ) { + options.push({ value: "max", name: "Max" }); + } + + return options; +} + +export function isSupportedReasoningEffort( + adapter: Adapter, + modelId: string, + value: string, +): value is SupportedReasoningEffort { + return ( + getReasoningEffortOptions(adapter, modelId)?.some( + (option) => option.value === value, + ) ?? false + ); +} diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index 76a49cd736..181ff36c20 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -8,9 +8,9 @@ import type { } from "@agentclientprotocol/sdk"; import type { Adapter } from "./adapter"; import type { SkillButtonId } from "./analytics-events"; +import type { TaskRunArtifact, TaskRunStatus } from "./domain-types"; import type { ExecutionMode } from "./exec-types"; import type { AcpMessage } from "./session-events"; -import type { TaskRunArtifact, TaskRunStatus } from "./task"; export type { Adapter }; diff --git a/packages/shared/src/task-automation.test.ts b/packages/shared/src/task-automation.test.ts new file mode 100644 index 0000000000..57e6fcaa97 --- /dev/null +++ b/packages/shared/src/task-automation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + type CreateTaskAutomationOptions, + createTaskAutomationSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + type UpdateTaskAutomationOptions, + updateTaskAutomationSchema, +} from "./task-automation"; + +describe("task automation contracts", () => { + it("normalizes optional automation response fields", () => { + expect( + taskAutomationSchema.parse({ + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "0 9 * * *", + last_run_at: null, + last_run_status: null, + last_task_id: null, + last_task_run_id: null, + last_error: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + }), + ).toMatchObject({ + github_integration: null, + timezone: null, + template_id: null, + enabled: true, + }); + }); + + it("keeps create fields required and update fields partial", () => { + const create = createTaskAutomationSchema.parse({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "0 9 * * *", + timezone: "Europe/London", + }); + const update = updateTaskAutomationSchema.parse({ enabled: false }); + + expect(create.timezone).toBe("Europe/London"); + expect(update).toEqual({ enabled: false }); + expectTypeOf(create).toEqualTypeOf(); + expectTypeOf(update).toEqualTypeOf(); + }); + + it("preserves backend validation field attribution", () => { + expect( + taskAutomationValidationErrorSchema.parse({ + type: "validation_error", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }), + ).toEqual({ + type: "validation_error", + code: "invalid_input", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }); + }); +}); diff --git a/packages/shared/src/task-automation.ts b/packages/shared/src/task-automation.ts new file mode 100644 index 0000000000..0f3e4a4bde --- /dev/null +++ b/packages/shared/src/task-automation.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +export const taskAutomationSchema = z.object({ + id: z.string(), + name: z.string(), + prompt: z.string(), + repository: z.string(), + github_integration: z.number().nullable().default(null), + cron_expression: z.string(), + timezone: z.string().nullable().default(null), + template_id: z.string().nullable().default(null), + enabled: z.boolean().default(true), + last_run_at: z.string().nullable(), + last_run_status: z.string().nullable(), + last_task_id: z.string().nullable(), + last_task_run_id: z.string().nullable(), + last_error: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); +export type TaskAutomation = z.infer; + +export const taskAutomationListSchema = z.object({ + count: z.number(), + next: z.string().nullable().optional(), + previous: z.string().nullable().optional(), + results: z.array(taskAutomationSchema), +}); +export type TaskAutomationList = z.infer; + +export const createTaskAutomationSchema = z.object({ + name: z.string(), + prompt: z.string(), + repository: z.string(), + github_integration: z.number().nullable().optional(), + cron_expression: z.string(), + timezone: z.string(), + template_id: z.string().nullable().optional(), + enabled: z.boolean().optional(), +}); +export type CreateTaskAutomationOptions = z.infer< + typeof createTaskAutomationSchema +>; + +export const updateTaskAutomationSchema = createTaskAutomationSchema.partial(); +export type UpdateTaskAutomationOptions = z.infer< + typeof updateTaskAutomationSchema +>; + +export const taskAutomationValidationErrorSchema = z.object({ + type: z.string().optional(), + code: z.string().default("invalid_input"), + detail: z.string(), + attr: z.string().nullable().default(null), +}); +export type TaskAutomationValidationErrorDetails = z.infer< + typeof taskAutomationValidationErrorSchema +>; diff --git a/packages/shared/src/task.test.ts b/packages/shared/src/task.test.ts new file mode 100644 index 0000000000..8d666f2b11 --- /dev/null +++ b/packages/shared/src/task.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { + Task, + TaskRun, + TaskRunArtifact, + TaskRunStatus, +} from "./domain-types"; +import { + type CloudPermissionOption, + type CloudTaskUpdatePayload, + isTerminalStatus, + type Task as RootTask, + type TaskRun as RootTaskRun, + type TaskRunArtifact as RootTaskRunArtifact, + type TaskRunStatus as RootTaskRunStatus, + TERMINAL_STATUSES, +} from "./index"; +import type { + Task as LegacyTask, + TaskRun as LegacyTaskRun, + TaskRunArtifact as LegacyTaskRunArtifact, + TaskRunStatus as LegacyTaskRunStatus, +} from "./task"; + +describe("cloud task contract exports", () => { + it("keeps legacy and root task exports canonical", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("exports cloud permission and update payload contracts from the root", () => { + expectTypeOf().toMatchTypeOf<{ + kind: string; + optionId: string; + name: string; + }>(); + expectTypeOf().toEqualTypeOf< + "logs" | "status" | "snapshot" | "error" | "permission_request" + >(); + }); + + it("exports terminal status helpers from the root", () => { + expect(TERMINAL_STATUSES).toEqual(["completed", "failed", "cancelled"]); + expect(isTerminalStatus("completed")).toBe(true); + expect(isTerminalStatus("in_progress")).toBe(false); + }); +}); diff --git a/packages/shared/src/task.ts b/packages/shared/src/task.ts index b93c6e96d7..f6593d21bf 100644 --- a/packages/shared/src/task.ts +++ b/packages/shared/src/task.ts @@ -1,97 +1,12 @@ -// PostHog Task model (matches the desktop task API's OpenAPI schema) -import type { AgentRuntime } from "./agent-runtime"; -import type { UploadableSkillSource } from "./skills"; - -export interface Task { - id: string; - task_number?: number; - slug?: string; - title: string; - description: string; - origin_product: - | "error_tracking" - | "eval_clusters" - | "user_created" - | "support_queue" - | "session_summaries" - | "signal_report" - | "signals_scout" - | "slack"; - signal_report?: string | null; // Inbox report UUID when origin_product is "signal_report" - github_integration?: number | null; - repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") - json_schema?: Record | null; // JSON schema for task output validation - internal?: boolean; - runtime?: AgentRuntime; - created_at: string; - updated_at: string; - created_by?: { - id: number; - uuid: string; - distinct_id: string; - first_name: string; - email: string; - }; - latest_run?: TaskRun; -} - -export type ArtifactType = - | "plan" - | "context" - | "reference" - | "output" - | "artifact" - | "user_attachment" - | "skill_bundle"; - -export interface TaskRunArtifactMetadata { - skill_name: string; - skill_source: UploadableSkillSource; - content_sha256: string; - bundle_format: "zip"; - schema_version: number; -} - -export interface TaskRunArtifact { - id?: string; - name: string; - type: ArtifactType; - source?: string; - size?: number; - content_type?: string; - metadata?: TaskRunArtifactMetadata; - storage_path?: string; - uploaded_at?: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -export type TaskRunEnvironment = "local" | "cloud"; - -// TaskRun model - represents individual execution runs of tasks -export interface TaskRun { - id: string; - task: string; // Task ID - team: number; - branch: string | null; - stage: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment: TaskRunEnvironment; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - output: Record | null; // Structured output (PR URL, commit SHA, etc.) - state: Record; // Intermediate run state (defaults to {}, never null) - artifacts?: TaskRunArtifact[]; - created_at: string; - updated_at: string; - completed_at: string | null; -} +export type { + ArtifactType, + Task, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunEnvironment, + TaskRunStatus, +} from "./domain-types"; export interface PostHogAPIConfig { apiUrl: string; diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9654cb30b7..bd0821e254 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -20,24 +20,16 @@ import { } from "@posthog/agent"; import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; -import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; import { Agent } from "@posthog/agent/agent"; import { getAvailableCodexModes, getAvailableModes, } from "@posthog/agent/execution-mode"; import { - DEFAULT_CODEX_MODEL, - DEFAULT_GATEWAY_MODEL, fetchGatewayModels, formatGatewayModelName, - type GatewayModel, getClaudeModelRecency, getProviderName, - isAnthropicModel, - isCloudflareModel, - isOpenAIModel, - pickAllowedModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { @@ -72,10 +64,10 @@ import { import { type AcpMessage, type Adapter, + buildCloudTaskConfigOptions, type ExecutionMode, isAuthError, resolveCloudInitialPermissionMode, - restrictedModelMeta, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -2395,111 +2387,14 @@ For git operations while detached: adapter: Adapter = "claude", ): Promise { const gatewayUrl = getLlmGatewayUrl(apiHost); - // Authenticated so the gateway can mark plan-restricted models; falls - // back to an anonymous fetch (everything allowed) without auth. const gatewayModels = await fetchGatewayModels({ gatewayUrl, authToken: (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, }); - - // The Claude adapter can also drive Cloudflare `@cf/` models the gateway serves over its - // Anthropic-Messages surface, so the preview/default-model path must offer them too — otherwise an - // advertised `@cf/*` model is dropped here and the pre-session run falls back to Opus. - const modelFilter = - adapter === "codex" - ? isOpenAIModel - : (model: GatewayModel) => - isAnthropicModel(model) || isCloudflareModel(model); - - const adapterModels = gatewayModels.filter((model) => modelFilter(model)); - const modelOptions = adapterModels.map((model) => ({ - value: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - // Locked models stay listed so the picker can gate them instead of - // silently dropping them. - ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), - })); - - // The gateway returns models in an arbitrary order. Sort Claude models - // oldest-to-newest so the picker is deterministic and the newest model - // lands at the end of the list, closest to the trigger. - if (adapter === "claude") { - modelOptions.sort( - (a, b) => - getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), - ); - } - - const defaultModel = - adapter === "codex" - ? (modelOptions.find((o) => o.value === DEFAULT_CODEX_MODEL)?.value ?? - modelOptions[0]?.value ?? - "") - : DEFAULT_GATEWAY_MODEL; - - const preferredModelId = modelOptions.some((o) => o.value === defaultModel) - ? defaultModel - : (modelOptions[0]?.value ?? defaultModel); - // Never preselect a model the org's plan can't use — it would 403 on the - // first message. - const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); - - if (!modelOptions.some((o) => o.value === resolvedModelId)) { - modelOptions.unshift({ - value: resolvedModelId, - name: resolvedModelId, - description: "Custom model", - }); - } - - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const modeOptions = modes.map((mode) => ({ - value: mode.id, - name: mode.name, - description: mode.description ?? undefined, - })); - const defaultMode = adapter === "codex" ? "auto" : "plan"; - - const configOptions: SessionConfigOption[] = [ - { - id: "mode", - name: "Approval Preset", - type: "select", - currentValue: defaultMode, - options: modeOptions, - category: "mode", - description: - "Choose an approval and sandboxing preset for your session", - }, - { - id: "model", - name: "Model", - type: "select", - currentValue: resolvedModelId, - options: modelOptions, - category: "model", - description: "Choose which model Claude should use", - }, - ]; - - const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); - if (effortOpts) { - configOptions.push({ - id: adapter === "codex" ? "reasoning_effort" : "effort", - name: adapter === "codex" ? "Reasoning Level" : "Effort", - type: "select", - currentValue: "high", - options: effortOpts, - category: "thought_level", - description: - adapter === "codex" - ? "Controls how much reasoning effort the model uses" - : "Controls how much effort Claude puts into its response", - }); - } - - return configOptions; + return buildCloudTaskConfigOptions( + gatewayModels, + adapter, + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(), + ) as SessionConfigOption[]; } } From ec5fb80c4e293a8a3f29a5bbfadb1559320cfb6c Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:45:36 +0300 Subject: [PATCH 2/9] fix(models): preserve GLM effort policy Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/shared/src/cloud-task-models.test.ts | 11 ++++- packages/shared/src/reasoning-effort.test.ts | 3 ++ packages/shared/src/reasoning-effort.ts | 48 ++++++++++--------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/cloud-task-models.test.ts b/packages/shared/src/cloud-task-models.test.ts index 31418a7775..afbad989f8 100644 --- a/packages/shared/src/cloud-task-models.test.ts +++ b/packages/shared/src/cloud-task-models.test.ts @@ -164,8 +164,17 @@ describe("buildCloudTaskConfigOptions", () => { { value: "@cf/zai-org/glm-5.2" }, ], }, + { + id: "effort", + currentValue: "high", + options: [{ value: "high" }, { value: "max" }], + }, + ]); + expect(options.map((option) => option.id)).toEqual([ + "mode", + "model", + "effort", ]); - expect(options.map((option) => option.id)).toEqual(["mode", "model"]); }); it("builds Codex options with the shared default and reasoning levels", () => { diff --git a/packages/shared/src/reasoning-effort.test.ts b/packages/shared/src/reasoning-effort.test.ts index 5b1e301dd3..2ac4a9bbff 100644 --- a/packages/shared/src/reasoning-effort.test.ts +++ b/packages/shared/src/reasoning-effort.test.ts @@ -8,6 +8,9 @@ describe("isSupportedReasoningEffort", () => { ["codex", "gpt-5.4", "max", false], ["claude", "claude-opus-4-8", "xhigh", true], ["claude", "claude-sonnet-4-6", "xhigh", false], + ["claude", "@cf/zai-org/glm-5.2", "high", true], + ["claude", "@cf/zai-org/glm-5.2", "max", true], + ["claude", "@cf/zai-org/glm-5.2", "medium", false], ["claude", "claude-opus-4-8", "minimal", false], ] as const)( "validates %s %s effort %s", diff --git a/packages/shared/src/reasoning-effort.ts b/packages/shared/src/reasoning-effort.ts index 15f534a612..2fe12b7232 100644 --- a/packages/shared/src/reasoning-effort.ts +++ b/packages/shared/src/reasoning-effort.ts @@ -20,44 +20,46 @@ const BASE_OPTIONS: ReasoningEffortOption[] = [ { value: "high", name: "High" }, ]; -const CLAUDE_MODELS_WITH_EFFORT = new Set([ - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-4-6", - "claude-sonnet-5", - "claude-fable-5", -]); +const CLAUDE_MODEL_EFFORTS: Readonly< + Record +> = { + "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-4-6": ["low", "medium", "high"], + "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], + "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], + "@cf/zai-org/glm-5.2": ["high", "max"], +}; -const CLAUDE_MODELS_WITH_XHIGH_EFFORT = new Set([ - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-fable-5", -]); +const EFFORT_NAMES: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", +}; export function getReasoningEffortOptions( adapter: Adapter, modelId: string, ): ReasoningEffortOption[] | null { - if (adapter === "claude" && !CLAUDE_MODELS_WITH_EFFORT.has(modelId)) { - return null; + if (adapter === "claude") { + const efforts = CLAUDE_MODEL_EFFORTS[modelId]; + return ( + efforts?.map((value) => ({ value, name: EFFORT_NAMES[value] })) ?? null + ); } const options = [...BASE_OPTIONS]; const normalizedModelId = modelId.toLowerCase(); const supportsXhigh = - adapter === "claude" - ? CLAUDE_MODELS_WITH_XHIGH_EFFORT.has(modelId) - : normalizedModelId.includes("gpt-5.5") || - normalizedModelId.includes("gpt-5.6"); + normalizedModelId.includes("gpt-5.5") || + normalizedModelId.includes("gpt-5.6"); if (supportsXhigh) { options.push({ value: "xhigh", name: "Extra High" }); } - if ( - (adapter === "claude" && supportsXhigh) || - (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) - ) { + if (adapter === "codex" && normalizedModelId.includes("gpt-5.6")) { options.push({ value: "max", name: "Max" }); } From fa2dda48da93a111d7ddca4081ce3763433da716 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 01:55:53 +0300 Subject: [PATCH 3/9] test(ui): wait for async content Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../components/session-update/PlanApprovalView.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index ef705ba77c..dd83ad5f6b 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -110,7 +110,7 @@ describe("PlanApprovalView", () => { ).toBeInTheDocument(); }); - it("uses updated content instead of stale raw input while streaming", () => { + it("uses updated content instead of stale raw input while streaming", async () => { renderView({ toolCall: makeToolCall({ status: "in_progress", @@ -124,7 +124,7 @@ describe("PlanApprovalView", () => { }), }); - expect(screen.getByText("Updated plan")).toBeInTheDocument(); + expect(await screen.findByText("Updated plan")).toBeInTheDocument(); expect(screen.queryByText("Initial plan")).not.toBeInTheDocument(); }); From 0960bc378d53c5e5e3cdb261eb619953bb69f18a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 02:06:34 +0300 Subject: [PATCH 4/9] test(ui): isolate plan approval presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../components/session-update/PlanApprovalView.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index dd83ad5f6b..3bd1add8e8 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -2,9 +2,13 @@ import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { PlanApprovalView } from "./PlanApprovalView"; +vi.mock("../../../permissions/PlanContent", () => ({ + PlanContent: ({ plan }: { plan: string }) =>
{plan}
, +})); + const PLAN_MARKER = "Sentinel plan body for testing"; function makeToolCall(overrides: Partial = {}): ToolCall { @@ -124,7 +128,7 @@ describe("PlanApprovalView", () => { }), }); - expect(await screen.findByText("Updated plan")).toBeInTheDocument(); + expect(screen.getByText("Updated plan")).toBeInTheDocument(); expect(screen.queryByText("Initial plan")).not.toBeInTheDocument(); }); From 3f608f21ce8b2a1cf0a4277ed6fd77832371ee8f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 28 Jul 2026 12:05:07 +0300 Subject: [PATCH 5/9] fix(shared): Preserve canonical task artifacts Keep the extracted domain declaration as the single source after rebasing onto current main. Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/shared/src/domain-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 3600423dae..8f1e8dc56d 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -3,7 +3,6 @@ import type { Adapter } from "./adapter"; import type { AgentRuntime } from "./agent-runtime"; import type { DismissalReasonOptionValue } from "./dismissal-reasons"; import type { StoredLogEntry } from "./session-events"; -import type { TaskRunArtifact } from "./task"; import type { UploadableSkillSource } from "./skills"; // Execution mode schema and type - shared between main and renderer From 9c4235c3f9f0b9ca26c0eb2aac4f547288b57029 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 28 Jul 2026 12:27:36 +0300 Subject: [PATCH 6/9] test(agent): match canonical model picker order Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/agent/src/adapters/claude/session/model-config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent/src/adapters/claude/session/model-config.test.ts b/packages/agent/src/adapters/claude/session/model-config.test.ts index 22fd0bcf69..5561454baa 100644 --- a/packages/agent/src/adapters/claude/session/model-config.test.ts +++ b/packages/agent/src/adapters/claude/session/model-config.test.ts @@ -26,8 +26,8 @@ describe("applyAvailableModelsAllowlist", () => { "claude-opus-4-8", ]).options, ).toEqual([ - { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, ]); }); From b69d17d464d63c87e1b054625fbd1fd4b16cc9f6 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:18:07 +0300 Subject: [PATCH 7/9] refactor(api-client): extract cloud task transport Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/api-client/package.json | 1 - packages/api-client/src/fetcher.test.ts | 39 ++ packages/api-client/src/fetcher.ts | 25 +- packages/api-client/src/index.ts | 6 +- .../src/posthog-client.automations.test.ts | 192 +++++++++ .../api-client/src/posthog-client.test.ts | 349 ++++++++++++++++- packages/api-client/src/posthog-client.ts | 368 +++++++++++++++--- .../api-client/src/task-normalization.test.ts | 115 ++++++ packages/api-client/src/task-normalization.ts | 203 ++++++++++ pnpm-lock.yaml | 12 +- 10 files changed, 1244 insertions(+), 66 deletions(-) create mode 100644 packages/api-client/src/posthog-client.automations.test.ts create mode 100644 packages/api-client/src/task-normalization.test.ts create mode 100644 packages/api-client/src/task-normalization.ts diff --git a/packages/api-client/package.json b/packages/api-client/package.json index bee0ebc547..3a592020a4 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -25,7 +25,6 @@ "src/**/*" ], "dependencies": { - "@posthog/agent": "workspace:*", "@posthog/shared": "workspace:*" } } diff --git a/packages/api-client/src/fetcher.test.ts b/packages/api-client/src/fetcher.test.ts index c205949418..6e1e17a351 100644 --- a/packages/api-client/src/fetcher.test.ts +++ b/packages/api-client/src/fetcher.test.ts @@ -53,6 +53,45 @@ describe("buildApiFetcher", () => { expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe( "Bearer my-token", ); + expect(mockFetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + "posthog/desktop.hog.dev; version: test", + ); + }); + + it("uses an injected fetch implementation and custom user agent", async () => { + const injectedFetch = vi.fn().mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "1.2.3", + fetch: injectedFetch, + userAgent: "posthog/mobile; version: 1.2.3", + }); + + await fetcher.fetch(mockInput); + + expect(injectedFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + expect(injectedFetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + "posthog/mobile; version: 1.2.3", + ); + }); + + it("omits the user agent when explicitly disabled", async () => { + const injectedFetch = vi.fn().mockResolvedValueOnce(ok()); + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken: vi.fn().mockResolvedValue("new-token"), + appVersion: "1.2.3", + fetch: injectedFetch, + userAgent: null, + }); + + await fetcher.fetch(mockInput); + + expect(injectedFetch.mock.calls[0][1].headers.has("User-Agent")).toBe( + false, + ); }); it("retries once with a freshly fetched token on 401", async () => { diff --git a/packages/api-client/src/fetcher.ts b/packages/api-client/src/fetcher.ts index 6bf59aa9f8..bc061a3030 100644 --- a/packages/api-client/src/fetcher.ts +++ b/packages/api-client/src/fetcher.ts @@ -1,9 +1,16 @@ import type { createApiClient } from "./generated"; +export type FetchImplementation = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + export type ApiFetcherConfig = { getAccessToken: () => Promise; refreshAccessToken: () => Promise; appVersion: string; + fetch?: FetchImplementation; + userAgent?: string | null; }; /** @@ -13,11 +20,13 @@ export type ApiFetcherConfig = { */ export class ApiRequestError extends Error { readonly status: number; + readonly body: unknown; - constructor(status: number, serializedBody: string) { + constructor(status: number, serializedBody: string, body?: unknown) { super(`Failed request: [${status}] ${serializedBody}`); this.name = "ApiRequestError"; this.status = status; + this.body = body; } } @@ -29,7 +38,11 @@ export function requestErrorStatus(error: unknown): number | undefined { export const buildApiFetcher: ( config: ApiFetcherConfig, ) => Parameters[0] = (config) => { - const userAgent = `posthog/desktop.hog.dev; version: ${config.appVersion}`; + const fetchImpl = config.fetch ?? globalThis.fetch; + const userAgent = + config.userAgent === undefined + ? `posthog/desktop.hog.dev; version: ${config.appVersion}` + : config.userAgent; const makeRequest = async ( input: Parameters[0]["fetch"]>[0], @@ -38,7 +51,9 @@ export const buildApiFetcher: ( const headers = new Headers(); headers.set("Authorization", `Bearer ${token}`); headers.set("Content-Type", "application/json"); - headers.set("User-Agent", userAgent); + if (userAgent) { + headers.set("User-Agent", userAgent); + } if (input.urlSearchParams) { input.url.search = input.urlSearchParams.toString(); @@ -59,7 +74,7 @@ export const buildApiFetcher: ( } try { - const response = await fetch(input.url, { + const response = await fetchImpl(input.url, { method: input.method.toUpperCase(), ...(body && { body }), headers, @@ -114,6 +129,7 @@ export const buildApiFetcher: ( throw new ApiRequestError( response.status, JSON.stringify(errorResponse), + errorResponse, ); } } @@ -128,6 +144,7 @@ export const buildApiFetcher: ( throw new ApiRequestError( response.status, JSON.stringify(errorResponse), + errorResponse, ); } diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index e6b7c3c639..a18d6ff4b0 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,6 +1,10 @@ import "./generated.augment"; -export { type ApiFetcherConfig, buildApiFetcher } from "./fetcher"; +export { + type ApiFetcherConfig, + buildApiFetcher, + type FetchImplementation, +} from "./fetcher"; export { createApiClient, type Schemas } from "./generated"; export { createLoop, diff --git a/packages/api-client/src/posthog-client.automations.test.ts b/packages/api-client/src/posthog-client.automations.test.ts new file mode 100644 index 0000000000..3d3386ef3a --- /dev/null +++ b/packages/api-client/src/posthog-client.automations.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PostHogAPIClient, + TaskAutomationValidationError, +} from "./posthog-client"; + +const automationPayload = { + id: "automation-1", + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + last_run_at: null, + last_run_status: null, + last_task_id: null, + last_task_run_id: null, + last_error: null, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("PostHogAPIClient task automations", () => { + const fetch = vi.fn(); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "access-token", + async () => "refreshed-token", + 42, + { appVersion: "test", fetch }, + ); + + beforeEach(() => { + fetch.mockReset(); + }); + + it("lists automations and normalizes optional response fields", async () => { + const minimalPayload = { + ...automationPayload, + github_integration: undefined, + timezone: undefined, + template_id: undefined, + enabled: undefined, + }; + fetch.mockResolvedValueOnce( + jsonResponse({ + count: 1, + next: null, + previous: null, + results: [minimalPayload], + }), + ); + + await expect(client.listTaskAutomations()).resolves.toEqual([ + expect.objectContaining({ + id: "automation-1", + github_integration: null, + timezone: null, + template_id: null, + enabled: true, + }), + ]); + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/task_automations/?limit=500", + ), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("gets and creates automations through generated endpoints", async () => { + fetch + .mockResolvedValueOnce(jsonResponse(automationPayload)) + .mockResolvedValueOnce(jsonResponse(automationPayload, 201)); + + await expect(client.getTaskAutomation("automation-1")).resolves.toEqual( + automationPayload, + ); + await expect( + client.createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + }), + ).resolves.toEqual(automationPayload); + + expect(fetch).toHaveBeenNthCalledWith( + 2, + new URL("https://app.posthog.test/api/projects/42/task_automations/"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + github_integration: 7, + cron_expression: "0 9 * * *", + timezone: "Europe/London", + template_id: "llm-skill:daily-prs", + enabled: true, + }), + }), + ); + }); + + it("updates, deletes, and runs automations", async () => { + fetch + .mockResolvedValueOnce( + jsonResponse({ ...automationPayload, enabled: false }), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse(automationPayload)); + + await expect( + client.updateTaskAutomation("automation-1", { enabled: false }), + ).resolves.toMatchObject({ enabled: false }); + await expect( + client.deleteTaskAutomation("automation-1"), + ).resolves.toBeUndefined(); + await expect(client.runTaskAutomation("automation-1")).resolves.toEqual( + automationPayload, + ); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + new URL( + "https://app.posthog.test/api/projects/42/task_automations/automation-1/", + ), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ enabled: false }), + }), + ); + expect(fetch).toHaveBeenNthCalledWith( + 3, + new URL( + "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", + ), + expect.objectContaining({ method: "POST" }), + ); + expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined(); + }); + + it("preserves validation detail, code, and field attribution", async () => { + fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + type: "validation_error", + code: "invalid_input", + detail: "Enter a valid cron expression.", + attr: "cron_expression", + }), + { + status: 400, + statusText: "Bad Request", + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const request = client.createTaskAutomation({ + name: "Daily PRs", + prompt: "Check PRs", + repository: "posthog/posthog", + cron_expression: "not a cron", + timezone: "Europe/London", + }); + + await expect(request).rejects.toBeInstanceOf(TaskAutomationValidationError); + await expect(request).rejects.toMatchObject({ + status: 400, + code: "invalid_input", + attr: "cron_expression", + message: "Enter a valid cron expression.", + }); + }); +}); diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index 42a36681cb..da93bc7960 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,8 +1,337 @@ import { describe, expect, it, vi } from "vitest"; import { ApiRequestError } from "./fetcher"; -import { PostHogAPIClient } from "./posthog-client"; +import { CloudCommandError, PostHogAPIClient } from "./posthog-client"; describe("PostHogAPIClient", () => { + it.each([ + "user_message", + "permission_response", + "set_config_option", + "cancel", + ] as const)("sends the %s cloud run command", async (method) => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ result: { accepted: true } }), { + status: 200, + }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendCloudRunCommand("task-1", "run-1", method, { + value: "payload", + }), + ).resolves.toEqual({ accepted: true }); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/command/", + ), + expect.objectContaining({ + method: "POST", + body: expect.any(String), + }), + ); + const request = fetch.mock.calls[0][1] as RequestInit; + expect(JSON.parse(request.body as string)).toMatchObject({ + jsonrpc: "2.0", + method, + params: { value: "payload" }, + }); + }); + + it("throws structured cloud command errors for HTTP failures", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ error: "No active sandbox for this run" }), + { + status: 409, + statusText: "Conflict", + }, + ), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + const error = await client + .sendCloudRunCommand("task-1", "run-1", "user_message") + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + name: "CloudCommandError", + method: "user_message", + status: 409, + backendError: "No active sandbox for this run", + }); + expect(error).toBeInstanceOf(CloudCommandError); + expect((error as CloudCommandError).isSandboxInactive()).toBe(true); + }); + + it("throws structured cloud command errors for JSON-RPC failures", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ error: { message: "Permission request expired" } }), + { status: 200 }, + ), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendCloudRunCommand("task-1", "run-1", "permission_response"), + ).rejects.toMatchObject({ + method: "permission_response", + status: 200, + backendError: "Permission request expired", + }); + }); + + it("preserves the legacy sendRunCommand result contract", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Run is unavailable" }), { + status: 503, + }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.sendRunCommand("task-1", "run-1", "set_config_option"), + ).resolves.toEqual({ + success: false, + error: "Cloud command 'set_config_option' failed: 503 Run is unavailable", + }); + }); + + it("cancels a cloud task run with an optional reason", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ status: "cancelled" }), { status: 200 }), + ); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect( + client.cancelTaskRun("task-1", "run-1", "user requested"), + ).resolves.toEqual({ status: "cancelled" }); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "https://app.posthog.test/api/projects/42/tasks/task-1/runs/run-1/cancel/", + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ reason: "user requested" }), + }), + ); + }); + + it("cancels a cloud task run with an empty body by default", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const client = new PostHogAPIClient( + "https://app.posthog.test", + async () => "token", + async () => "token", + 42, + { fetch }, + ); + + await expect(client.cancelTaskRun("task-1", "run-1")).resolves.toEqual({}); + + const request = fetch.mock.calls[0][1] as RequestInit; + expect(request.body).toBe(JSON.stringify({})); + }); + + it("builds cloud task config from the authenticated gateway catalog", async () => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + id: "claude-opus-4-8", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + allowed: true, + }, + { + id: "claude-fable-5", + owned_by: "anthropic", + context_window: 200000, + supports_streaming: true, + supports_vision: true, + allowed: false, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const client = new PostHogAPIClient( + "https://eu.posthog.com", + async () => "token", + async () => "token", + 123, + { fetch }, + ); + + const options = await client.getCloudTaskConfigOptions("claude"); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://gateway.eu.posthog.com/posthog_code/v1/models"), + expect.objectContaining({ method: "GET" }), + ); + expect(options.find((option) => option.category === "model")).toMatchObject( + { + currentValue: "claude-opus-4-8", + options: [ + expect.objectContaining({ value: "claude-opus-4-8" }), + expect.objectContaining({ + value: "claude-fable-5", + _meta: expect.any(Object), + }), + ], + }, + ); + }); + + it("uses the configured fetch implementation for task log URLs", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response( + '{"type":"notification","timestamp":"2026-07-21T00:00:00Z"}\n', + { status: 200 }, + ), + ); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + { fetch }, + ); + vi.spyOn(client, "getTask").mockResolvedValue({ + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task", + description: "Task", + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + origin_product: "user_created", + latest_run: { + id: "run-1", + task: "task-1", + team: 123, + branch: null, + status: "in_progress", + log_url: "https://logs.posthog.test/run-1.jsonl", + error_message: null, + output: null, + state: {}, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:00:00Z", + completed_at: null, + }, + }); + + await expect(client.getTaskLogs("task-1")).resolves.toHaveLength(1); + expect(fetch).toHaveBeenCalledWith("https://logs.posthog.test/run-1.jsonl"); + }); + + it.each([ + { + label: "desktop default", + options: undefined, + expectedConnectFrom: "posthog_code", + expectedUserAgent: "posthog/desktop.hog.dev; version: unknown", + }, + { + label: "mobile configuration", + options: { + appVersion: "1.2.3", + userAgent: "posthog/mobile; version: 1.2.3", + githubConnectFrom: "posthog_mobile", + }, + expectedConnectFrom: "posthog_mobile", + expectedUserAgent: "posthog/mobile; version: 1.2.3", + }, + ])( + "uses $label identity for GitHub connections", + async ({ options, expectedConnectFrom, expectedUserAgent }) => { + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ install_url: "https://github.com/login/oauth" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + { ...options, fetch }, + ); + + await expect(client.startGithubUserIntegrationConnect()).resolves.toEqual( + { + install_url: "https://github.com/login/oauth", + }, + ); + + expect(fetch).toHaveBeenCalledWith( + new URL( + "http://localhost:8000/api/users/@me/integrations/github/start/", + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + team_id: 123, + connect_from: expectedConnectFrom, + }), + }), + ); + expect(fetch.mock.calls[0][1].headers.get("User-Agent")).toBe( + expectedUserAgent, + ); + }, + ); + it("sends supported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", @@ -257,7 +586,13 @@ describe("PostHogAPIClient", () => { reasoningLevel: "high", initialPermissionMode: "auto", }), - ).resolves.toEqual({ id: "run-123", environment: "cloud" }); + ).resolves.toMatchObject({ + id: "run-123", + task: "task-123", + team: 123, + environment: "cloud", + status: "not_started", + }); expect(fetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -435,7 +770,15 @@ describe("PostHogAPIClient", () => { pendingUserMessage: "Read the attached file first", pendingUserArtifactIds: ["artifact-1"], }), - ).resolves.toEqual({ id: "task-123", latest_run: { id: "run-123" } }); + ).resolves.toMatchObject({ + id: "task-123", + latest_run: { + id: "run-123", + task: "task-123", + team: 123, + status: "not_started", + }, + }); expect(fetch).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 906e77f290..b2ef9fa8a8 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4,18 +4,30 @@ import type { CloudMcpServerImport, CloudMcpServerRelayDesignation, CloudRunSource, + CreateTaskAutomationOptions, ExecutionMode, PrAuthorshipMode, SourceProduct, SourceType, StoredLogEntry, + TaskAutomation, TaskRunArtifactMetadata, + UpdateTaskAutomationOptions, } from "@posthog/shared"; import { + buildCloudTaskConfigOptions, + type CloudTaskConfigOption, + createTaskAutomationSchema, DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, + getCloudTaskGatewayUrl, isSupportedReasoningEffort, + normalizeGatewayModelsResponse, resolveCloudInitialPermissionMode, + taskAutomationListSchema, + taskAutomationSchema, + taskAutomationValidationErrorSchema, + updateTaskAutomationSchema, } from "@posthog/shared"; import type { AgentAnalyticsData, @@ -101,9 +113,18 @@ import { type HogQLGrid, shapeAgentAnalytics, } from "./agent-analytics"; -import { buildApiFetcher, requestErrorStatus } from "./fetcher"; +import { + ApiRequestError, + buildApiFetcher, + type FetchImplementation, + requestErrorStatus, +} from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; import type { SpendAnalysisResponse } from "./spend-analysis"; +import { + normalizeTaskResponse, + normalizeTaskRunResponse, +} from "./task-normalization"; export interface ApiClientLogger { warn(...args: unknown[]): void; } @@ -122,6 +143,13 @@ export function setPosthogApiClientAppVersion(version: string): void { clientAppVersion = version; } +export interface PostHogAPIClientOptions { + fetch?: FetchImplementation; + appVersion?: string; + userAgent?: string | null; + githubConnectFrom?: string; +} + export function getPosthogApiClientAppVersion(): string { return clientAppVersion; } @@ -163,6 +191,36 @@ export class CloudUsageLimitError extends Error { } } +export class TaskAutomationValidationError extends Error { + readonly status = 400; + readonly code: string; + readonly attr: string | null; + + constructor(details: { + detail: string; + code: string; + attr: string | null; + }) { + super(details.detail); + this.name = "TaskAutomationValidationError"; + this.code = details.code; + this.attr = details.attr; + } +} + +function rethrowTaskAutomationError(error: unknown): never { + if (error instanceof ApiRequestError && error.status === 400) { + const validationError = taskAutomationValidationErrorSchema.safeParse( + error.body, + ); + if (validationError.success) { + throw new TaskAutomationValidationError(validationError.data); + } + } + + throw error; +} + export const MCP_CATEGORIES = [ { id: "all", label: "All" }, { id: "business", label: "Business Operations" }, @@ -581,7 +639,7 @@ export interface FinalizedTaskArtifactUpload { uploaded_at?: string; } -interface CloudRunOptions { +export interface CloudRunOptions { adapter?: Adapter; model?: string; reasoningLevel?: string; @@ -602,6 +660,56 @@ interface CloudRunOptions { relayedMcpServers?: CloudMcpServerRelayDesignation[]; } +export type CloudRunCommandMethod = + | "user_message" + | "permission_response" + | "set_config_option" + | "cancel" + | "close"; + +export class CloudCommandError extends Error { + readonly status: number; + readonly backendError: string | null; + readonly method: CloudRunCommandMethod; + + constructor( + method: CloudRunCommandMethod, + status: number, + backendError: string | null, + message: string, + ) { + super(message); + this.name = "CloudCommandError"; + this.method = method; + this.status = status; + this.backendError = backendError; + } + + isSandboxInactive(): boolean { + const backendError = this.backendError?.toLowerCase(); + return ( + this.status === 404 || + backendError?.includes("no active sandbox") === true || + backendError?.includes("returned 404") === true + ); + } +} + +function cloudCommandBackendError(payload: unknown): string | null { + if (typeof payload === "string") return payload || null; + if (!payload || typeof payload !== "object") return null; + + const error = "error" in payload ? payload.error : null; + if (typeof error === "string") return error || null; + if (error && typeof error === "object" && "message" in error) { + return typeof error.message === "string" ? error.message : null; + } + if ("message" in payload && typeof payload.message === "string") { + return payload.message; + } + return null; +} + interface CreateTaskRunOptions extends CloudRunOptions { environment?: "local" | "cloud"; mode?: "interactive" | "background"; @@ -1342,19 +1450,28 @@ function previewTokenHeader( export class PostHogAPIClient { private api: ReturnType; private _teamId: number | null = null; + private githubConnectFrom: string; + private readonly fetch: FetchImplementation; + private readonly apiHost: string; constructor( apiHost: string, getAccessToken: () => Promise, refreshAccessToken: () => Promise, teamId?: number, + options: PostHogAPIClientOptions = {}, ) { const baseUrl = apiHost.endsWith("/") ? apiHost.slice(0, -1) : apiHost; + this.apiHost = baseUrl; + this.githubConnectFrom = options.githubConnectFrom ?? "posthog_code"; + this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis); this.api = createApiClient( buildApiFetcher({ getAccessToken, refreshAccessToken, - appVersion: clientAppVersion, + appVersion: options.appVersion ?? clientAppVersion, + fetch: options.fetch, + userAgent: options.userAgent, }), baseUrl, ); @@ -1391,6 +1508,21 @@ export class PostHogAPIClient { return data; } + async getCloudTaskConfigOptions( + adapter: Adapter = "claude", + ): Promise { + const url = new URL(`${getCloudTaskGatewayUrl(this.apiHost)}/v1/models`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: url.pathname, + }); + return buildCloudTaskConfigOptions( + normalizeGatewayModelsResponse(await response.json()), + adapter, + ); + } + // Desktop file system — the backend surface that backs canvas channels // (top-level folders) and dashboards. These routes aren't in the generated // OpenAPI client, so we use the raw fetcher. @@ -1755,7 +1887,10 @@ export class PostHogAPIClient { url, path: urlPath, overrides: { - body: JSON.stringify({ team_id: id, connect_from: "posthog_code" }), + body: JSON.stringify({ + team_id: id, + connect_from: this.githubConnectFrom, + }), }, }); if (!response.ok) { @@ -2257,7 +2392,7 @@ export class PostHogAPIClient { originProduct?: string; internal?: boolean; channel?: string; - }) { + }): Promise { const teamId = await this.getTeamId(); const params: Record = { limit: 500, @@ -2288,7 +2423,9 @@ export class PostHogAPIClient { query: params, }); - return data.results ?? []; + return (data.results ?? []).map((task) => + normalizeTaskResponse(task, { teamId }), + ); } async getTaskSummaries(ids: string[]) { @@ -2330,7 +2467,102 @@ export class PostHogAPIClient { const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, }); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); + } + + async listTaskAutomations(options?: { + limit?: number; + offset?: number; + }): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + query: { + limit: options?.limit ?? 500, + ...(options?.offset === undefined ? {} : { offset: options.offset }), + }, + }, + ); + + return taskAutomationListSchema.parse(data).results; + } + + async getTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const data = await this.api.get( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + }, + ); + + return taskAutomationSchema.parse(data); + } + + async createTaskAutomation( + options: CreateTaskAutomationOptions, + ): Promise { + const teamId = await this.getTeamId(); + const body = createTaskAutomationSchema.parse(options); + + try { + const data = await this.api.post( + `/api/projects/{project_id}/task_automations/`, + { + path: { project_id: teamId.toString() }, + body: body as Schemas.TaskAutomation, + }, + ); + return taskAutomationSchema.parse(data); + } catch (error) { + rethrowTaskAutomationError(error); + } + } + + async updateTaskAutomation( + automationId: string, + updates: UpdateTaskAutomationOptions, + ): Promise { + const teamId = await this.getTeamId(); + const body = updateTaskAutomationSchema.parse(updates); + + try { + const data = await this.api.patch( + `/api/projects/{project_id}/task_automations/{id}/`, + { + path: { project_id: teamId.toString(), id: automationId }, + body, + }, + ); + return taskAutomationSchema.parse(data); + } catch (error) { + rethrowTaskAutomationError(error); + } + } + + async deleteTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + await this.api.delete(`/api/projects/{project_id}/task_automations/{id}/`, { + path: { project_id: teamId.toString(), id: automationId }, + }); + } + + async runTaskAutomation(automationId: string): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/task_automations/${automationId}/run/`; + + try { + const response = await this.api.fetcher.fetch({ + method: "post", + path, + url: new URL(`${this.api.baseUrl}${path}`), + }); + return taskAutomationSchema.parse(await response.json()); + } catch (error) { + rethrowTaskAutomationError(error); + } } async createTask( @@ -2357,7 +2589,7 @@ export class PostHogAPIClient { pending_user_artifact_ids?: string[]; auto_publish?: boolean; }, - ) { + ): Promise { const teamId = await this.getTeamId(); const { origin_product: originProduct, ...taskOptions } = options; @@ -2369,10 +2601,13 @@ export class PostHogAPIClient { } as unknown as Schemas.Task, }); - return data; + return normalizeTaskResponse(data, { teamId }); } - async updateTask(taskId: string, updates: Partial) { + async updateTask( + taskId: string, + updates: Partial, + ): Promise { const teamId = await this.getTeamId(); const data = await this.api.patch( `/api/projects/{project_id}/tasks/{id}/`, @@ -2382,7 +2617,7 @@ export class PostHogAPIClient { }, ); - return data; + return normalizeTaskResponse(data, { teamId }); } async deleteTask(taskId: string) { @@ -2681,9 +2916,28 @@ export class PostHogAPIClient { async sendRunCommand( taskId: string, runId: string, - method: "user_message" | "cancel" | "close", + method: CloudRunCommandMethod, params?: Record, ): Promise<{ success: boolean; result?: unknown; error?: string }> { + try { + return { + success: true, + result: await this.sendCloudRunCommand(taskId, runId, method, params), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async sendCloudRunCommand( + taskId: string, + runId: string, + method: CloudRunCommandMethod, + params: Record = {}, + ): Promise { const teamId = await this.getTeamId(); const url = new URL( `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, @@ -2691,7 +2945,7 @@ export class PostHogAPIClient { const body = { jsonrpc: "2.0", method, - params: params ?? {}, + params, id: `posthog-code-${Date.now()}`, }; @@ -2705,39 +2959,54 @@ export class PostHogAPIClient { }, }); - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Command failed: ${response.statusText}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = - errorJson.error?.message ?? errorJson.error ?? errorMessage; - } catch { - if (errorText) errorMessage = errorText; - } - return { success: false, error: errorMessage }; - } - const data = (await response.json()) as { - error?: { message?: string }; + error?: unknown; result?: unknown; }; if (data.error) { - return { - success: false, - error: data.error.message ?? JSON.stringify(data.error), - }; + const backendError = cloudCommandBackendError(data); + throw new CloudCommandError( + method, + response.status, + backendError, + `Cloud command '${method}' error: ${backendError ?? JSON.stringify(data.error)}`, + ); } - return { success: true, result: data.result }; + return data.result; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + if (error instanceof CloudCommandError) throw error; + if (error instanceof ApiRequestError) { + const backendError = cloudCommandBackendError(error.body); + throw new CloudCommandError( + method, + error.status, + backendError, + `Cloud command '${method}' failed: ${error.status}${backendError ? ` ${backendError}` : ""}`, + ); + } + throw error; } } + async cancelTaskRun( + taskId: string, + runId: string, + reason?: string, + ): Promise<{ status?: string }> { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/cancel/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + overrides: { + body: JSON.stringify(reason ? { reason } : {}), + }, + }); + return (await response.json().catch(() => ({}))) as { status?: string }; + } + async runTaskInCloud( taskId: string, branch?: string | null, @@ -2761,7 +3030,7 @@ export class PostHogAPIClient { }), ); - return data as unknown as Task; + return normalizeTaskResponse(data, { teamId }); } async warmTask(options: { @@ -3001,7 +3270,8 @@ export class PostHogAPIClient { throw new Error(`Failed to resume run in cloud: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async listTaskRuns(taskId: string): Promise { @@ -3019,8 +3289,11 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task runs: ${response.statusText}`); } - const data = (await response.json()) as { results?: TaskRun[] }; - return data.results ?? []; + const data = + (await response.json()) as Partial; + return (data.results ?? []).map((run) => + normalizeTaskRunResponse(run, { teamId, taskId }), + ); } async getTaskRun(taskId: string, runId: string): Promise { @@ -3038,7 +3311,8 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task run: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async createTaskRun( @@ -3070,7 +3344,8 @@ export class PostHogAPIClient { throw new Error(`Failed to create task run: ${response.statusText}`); } - return (await response.json()) as TaskRun; + const data = (await response.json()) as Schemas.TaskRunDetail; + return normalizeTaskRunResponse(data, { teamId, taskId }); } async startTaskRun( @@ -3100,7 +3375,8 @@ export class PostHogAPIClient { throw new Error(`Failed to start task run: ${response.statusText}`); } - return (await response.json()) as Task; + const data = (await response.json()) as Schemas.Task; + return normalizeTaskResponse(data, { teamId }); } async updateTaskRun( @@ -3125,7 +3401,7 @@ export class PostHogAPIClient { body: updates as Record, }, ); - return data as unknown as TaskRun; + return normalizeTaskRunResponse(data, { teamId, taskId }); } /** @@ -3215,14 +3491,14 @@ export class PostHogAPIClient { async getTaskLogs(taskId: string): Promise { try { - const task = (await this.getTask(taskId)) as unknown as Task; + const task = await this.getTask(taskId); const logUrl = task?.latest_run?.log_url; if (!logUrl) { return []; } - const response = await fetch(logUrl); + const response = await this.fetch(logUrl); if (!response.ok) { log.warn( diff --git a/packages/api-client/src/task-normalization.test.ts b/packages/api-client/src/task-normalization.test.ts new file mode 100644 index 0000000000..9c9739a51f --- /dev/null +++ b/packages/api-client/src/task-normalization.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeTaskResponse, + normalizeTaskRunResponse, +} from "./task-normalization"; + +describe("task response normalization", () => { + it("normalizes legacy started runs and nullable generated fields", () => { + expect( + normalizeTaskRunResponse( + { + id: "run-1", + task: "task-1", + status: "started", + branch: null, + stage: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + log_url: null, + error_message: null, + output: null, + state: null, + artifacts: [ + { + id: "artifact-1", + name: "result.txt", + type: "legacy_type", + storage_path: "tasks/result.txt", + uploaded_at: "2026-07-21T00:00:00Z", + }, + ], + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + completed_at: null, + }, + { teamId: 123 }, + ), + ).toEqual({ + id: "run-1", + task: "task-1", + team: 123, + branch: null, + stage: null, + runtime_adapter: null, + model: null, + reasoning_effort: null, + status: "in_progress", + log_url: "", + error_message: null, + output: null, + state: {}, + artifacts: [ + { + id: "artifact-1", + name: "result.txt", + type: "artifact", + storage_path: "tasks/result.txt", + uploaded_at: "2026-07-21T00:00:00Z", + }, + ], + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + completed_at: null, + }); + }); + + it("normalizes task responses and their generated latest-run records", () => { + expect( + normalizeTaskResponse( + { + id: "task-1", + task_number: null, + slug: "task-1", + repository: null, + github_integration: null, + github_user_integration: null, + json_schema: null, + signal_report: null, + channel: null, + latest_run: { + id: "run-1", + status: "started", + log_url: null, + }, + created_at: "2026-07-21T00:00:00Z", + updated_at: "2026-07-21T00:01:00Z", + }, + { teamId: 123 }, + ), + ).toMatchObject({ + id: "task-1", + task_number: null, + slug: "task-1", + title: "", + description: "", + origin_product: "", + repository: null, + github_integration: null, + github_user_integration: null, + json_schema: null, + signal_report: null, + channel: null, + latest_run: { + id: "run-1", + task: "task-1", + team: 123, + status: "in_progress", + log_url: "", + output: null, + state: {}, + }, + }); + }); +}); diff --git a/packages/api-client/src/task-normalization.ts b/packages/api-client/src/task-normalization.ts new file mode 100644 index 0000000000..9571a5b27e --- /dev/null +++ b/packages/api-client/src/task-normalization.ts @@ -0,0 +1,203 @@ +import type { + ArtifactType, + Task, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunStatus, +} from "@posthog/shared/domain-types"; +import type { Schemas } from "./generated"; + +type TaskRunResponseDTO = Partial< + Omit +> & { + id: string; + artifacts?: Array< + Schemas.TaskRunArtifactResponse & { metadata?: unknown } + > | null; + status?: Schemas.StatusA35Enum | "started" | null; + team?: number | null; +}; + +type TaskResponseDTO = Partial< + Omit +> & { + id: string; + channel?: string | null; + created_by?: Schemas.UserBasic | null; + github_user_integration?: string | null; + json_schema?: unknown | null; + latest_run?: Record | null; + runtime?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isTaskRunResponseDTO(value: unknown): value is TaskRunResponseDTO { + return isRecord(value) && typeof value.id === "string"; +} + +function normalizeTaskRunStatus(status: unknown): TaskRunStatus { + switch (status) { + case "started": + return "in_progress"; + case "not_started": + case "queued": + case "in_progress": + case "completed": + case "failed": + case "cancelled": + return status; + default: + return "not_started"; + } +} + +function normalizeArtifactType(type: string): ArtifactType { + switch (type) { + case "plan": + case "context": + case "reference": + case "output": + case "artifact": + case "user_attachment": + case "skill_bundle": + return type; + default: + return "artifact"; + } +} + +function normalizeArtifactMetadata( + value: unknown, +): TaskRunArtifactMetadata | undefined { + if ( + !isRecord(value) || + typeof value.skill_name !== "string" || + (value.skill_source !== "user" && + value.skill_source !== "repo" && + value.skill_source !== "marketplace" && + value.skill_source !== "codex") + ) { + return undefined; + } + if ( + typeof value.content_sha256 !== "string" || + value.bundle_format !== "zip" || + typeof value.schema_version !== "number" + ) { + return undefined; + } + + return { + skill_name: value.skill_name, + skill_source: value.skill_source, + content_sha256: value.content_sha256, + bundle_format: value.bundle_format, + schema_version: value.schema_version, + }; +} + +function normalizeTaskRunArtifact( + artifact: NonNullable[number], +): TaskRunArtifact { + const metadata = normalizeArtifactMetadata(artifact.metadata); + + return { + ...(artifact.id === undefined ? {} : { id: artifact.id }), + name: artifact.name, + type: normalizeArtifactType(artifact.type), + ...(artifact.source === undefined ? {} : { source: artifact.source }), + ...(artifact.size === undefined ? {} : { size: artifact.size }), + ...(artifact.content_type === undefined + ? {} + : { content_type: artifact.content_type }), + ...(metadata === undefined ? {} : { metadata }), + ...(artifact.storage_path === undefined + ? {} + : { storage_path: artifact.storage_path }), + ...(artifact.uploaded_at === undefined + ? {} + : { uploaded_at: artifact.uploaded_at }), + }; +} + +export function normalizeTaskRunResponse( + dto: TaskRunResponseDTO, + context: { teamId: number; taskId?: string }, +): TaskRun { + return { + id: dto.id, + task: dto.task ?? context.taskId ?? "", + team: dto.team ?? context.teamId, + branch: dto.branch ?? null, + ...(dto.runtime_adapter === undefined + ? {} + : { runtime_adapter: dto.runtime_adapter }), + ...(dto.model === undefined ? {} : { model: dto.model }), + ...(dto.reasoning_effort === undefined + ? {} + : { reasoning_effort: dto.reasoning_effort }), + ...(dto.stage === undefined ? {} : { stage: dto.stage }), + ...(dto.environment === undefined ? {} : { environment: dto.environment }), + status: normalizeTaskRunStatus(dto.status), + log_url: dto.log_url ?? "", + error_message: dto.error_message ?? null, + output: isRecord(dto.output) ? dto.output : null, + state: isRecord(dto.state) ? dto.state : {}, + ...(dto.artifacts == null + ? {} + : { artifacts: dto.artifacts.map(normalizeTaskRunArtifact) }), + created_at: dto.created_at ?? "", + updated_at: dto.updated_at ?? "", + completed_at: dto.completed_at ?? null, + }; +} + +export function normalizeTaskResponse( + dto: TaskResponseDTO, + context: { teamId: number }, +): Task { + const jsonSchema = isRecord(dto.json_schema) ? dto.json_schema : null; + const runtime = + dto.runtime === "acp" || dto.runtime === "pi" ? dto.runtime : undefined; + + const latestRun = isTaskRunResponseDTO(dto.latest_run) + ? normalizeTaskRunResponse(dto.latest_run, { + teamId: context.teamId, + taskId: dto.id, + }) + : undefined; + + return { + id: dto.id, + task_number: dto.task_number ?? null, + slug: dto.slug ?? "", + title: dto.title ?? "", + ...(dto.title_manually_set === undefined + ? {} + : { title_manually_set: dto.title_manually_set }), + description: dto.description ?? "", + created_at: dto.created_at ?? "", + updated_at: dto.updated_at ?? "", + ...(dto.created_by === undefined ? {} : { created_by: dto.created_by }), + origin_product: dto.origin_product ?? "", + ...(dto.repository === undefined ? {} : { repository: dto.repository }), + ...(dto.github_integration === undefined + ? {} + : { github_integration: dto.github_integration }), + ...(dto.github_user_integration === undefined + ? {} + : { github_user_integration: dto.github_user_integration }), + ...(dto.json_schema === undefined ? {} : { json_schema: jsonSchema }), + ...(dto.signal_report === undefined + ? {} + : { signal_report: dto.signal_report }), + ...(dto.internal === undefined ? {} : { internal: dto.internal }), + ...(runtime === undefined ? {} : { runtime }), + ...(dto.channel === undefined ? {} : { channel: dto.channel }), + ...(latestRun === undefined ? {} : { latest_run: latestRun }), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65ef2c8cbf..5cdfefdf93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,9 +869,6 @@ importers: packages/api-client: dependencies: - '@posthog/agent': - specifier: workspace:* - version: link:../agent '@posthog/shared': specifier: workspace:* version: link:../shared @@ -19215,13 +19212,6 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -22659,7 +22649,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': From 01aec6d6b84e9751dec936413b67bc88700bb125 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:18:39 +0300 Subject: [PATCH 8/9] refactor(core): extract cloud task policies Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../automations/automationSchedule.test.ts | 223 ++++++++++++++++++ .../src/automations/automationSchedule.ts | 216 +++++++++++++++++ .../src/automations/automationStatus.test.ts | 80 +++++++ .../core/src/automations/automationStatus.ts | 83 +++++++ packages/core/src/inbox/artefacts.test.ts | 159 ++++++++++++- packages/core/src/inbox/artefacts.ts | 86 +++++++ .../core/src/inbox/reportFiltering.test.ts | 10 + packages/core/src/inbox/reportFiltering.ts | 12 +- .../core/src/sessions/cloudSessionConfig.ts | 9 +- packages/core/src/sessions/executionModes.ts | 4 +- .../sessions/portableSessionEvents.test.ts | 71 ++++++ .../src/sessions/portableSessionEvents.ts | 98 ++++++++ .../core/src/sessions/sessionActivity.test.ts | 152 ++++++++++++ packages/core/src/sessions/sessionActivity.ts | 129 ++++++++++ packages/core/src/tasks/taskActivity.test.ts | 132 +++++++++++ packages/core/src/tasks/taskActivity.ts | 45 ++++ packages/core/src/tasks/taskArchive.test.ts | 44 ++++ packages/core/src/tasks/taskArchive.ts | 6 + .../src/tasks/taskStatusPresentation.test.ts | 69 ++++++ .../core/src/tasks/taskStatusPresentation.ts | 37 +++ 20 files changed, 1659 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/automations/automationSchedule.test.ts create mode 100644 packages/core/src/automations/automationSchedule.ts create mode 100644 packages/core/src/automations/automationStatus.test.ts create mode 100644 packages/core/src/automations/automationStatus.ts create mode 100644 packages/core/src/sessions/portableSessionEvents.test.ts create mode 100644 packages/core/src/sessions/portableSessionEvents.ts create mode 100644 packages/core/src/sessions/sessionActivity.test.ts create mode 100644 packages/core/src/sessions/sessionActivity.ts create mode 100644 packages/core/src/tasks/taskActivity.test.ts create mode 100644 packages/core/src/tasks/taskActivity.ts create mode 100644 packages/core/src/tasks/taskArchive.test.ts create mode 100644 packages/core/src/tasks/taskArchive.ts create mode 100644 packages/core/src/tasks/taskStatusPresentation.test.ts create mode 100644 packages/core/src/tasks/taskStatusPresentation.ts diff --git a/packages/core/src/automations/automationSchedule.test.ts b/packages/core/src/automations/automationSchedule.test.ts new file mode 100644 index 0000000000..5deb2487a0 --- /dev/null +++ b/packages/core/src/automations/automationSchedule.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + formatAutomationScheduleSummary, + formatScheduleSummary, + parseCronExpression, + sanitizeHour, + sanitizeMinute, + WEEKDAY_OPTIONS, +} from "./automationSchedule"; + +describe("automationSchedule", () => { + it("creates the default daily schedule draft", () => { + expect(createDefaultScheduleDraft()).toEqual({ + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }); + }); + + it("provides cron weekday values in display order", () => { + expect(WEEKDAY_OPTIONS).toEqual([ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, + ]); + }); + + it.each([ + ["", ""], + ["a", ""], + ["7", "07"], + ["09", "09"], + ["2x3", "23"], + ["24", "23"], + ["999", "23"], + ])("sanitizes hour input %j to %j", (input, expected) => { + expect(sanitizeHour(input)).toBe(expected); + }); + + it.each([ + ["", ""], + ["a", ""], + ["7", "07"], + ["09", "09"], + ["5x9", "59"], + ["60", "59"], + ["999", "59"], + ])("sanitizes minute input %j to %j", (input, expected) => { + expect(sanitizeMinute(input)).toBe(expected); + }); + + it.each<{ + name: string; + changes: Partial; + expected: string; + }>([ + { + name: "hourly", + changes: { mode: "hourly", minute: "15" }, + expected: "15 * * * *", + }, + { + name: "daily", + changes: { mode: "daily", hour: "09", minute: "15" }, + expected: "15 9 * * *", + }, + { + name: "weekdays", + changes: { mode: "weekdays", hour: "10", minute: "00" }, + expected: "0 10 * * 1-5", + }, + { + name: "weekly", + changes: { + mode: "weekly", + hour: "11", + minute: "30", + weekday: "4", + }, + expected: "30 11 * * 4", + }, + { + name: "weekly with a missing weekday", + changes: { mode: "weekly", weekday: "" }, + expected: "0 9 * * 1", + }, + { + name: "preset with missing time values", + changes: { mode: "daily", hour: "", minute: "" }, + expected: "0 9 * * *", + }, + { + name: "custom", + changes: { mode: "custom", rawCron: " */15 * * * * " }, + expected: "*/15 * * * *", + }, + ])("builds the $name cron expression", ({ changes, expected }) => { + expect( + buildCronExpression({ ...createDefaultScheduleDraft(), ...changes }), + ).toBe(expected); + }); + + it.each([ + [ + "15 * * * *", + { + mode: "hourly", + hour: "09", + minute: "15", + weekday: "*", + rawCron: "15 * * * *", + }, + ], + [ + "0 9 * * *", + { + mode: "daily", + hour: "09", + minute: "00", + weekday: "*", + rawCron: "0 9 * * *", + }, + ], + [ + "0 9 * * 1-5", + { + mode: "weekdays", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * 1-5", + }, + ], + [ + "30 14 * * 2", + { + mode: "weekly", + hour: "14", + minute: "30", + weekday: "2", + rawCron: "30 14 * * 2", + }, + ], + ] as const)("parses %s into a schedule draft", (cron, expected) => { + expect(parseCronExpression(cron)).toEqual(expected); + }); + + it.each(["*/15 * * * *", "0 9 1 * *", "0 9 * 1 *", "0 9 * * 1,3"])( + "keeps unsupported cron expression %s in custom mode", + (cron) => { + expect(parseCronExpression(cron)).toMatchObject({ + mode: "custom", + rawCron: cron, + }); + }, + ); + + it("normalizes surrounding and repeated cron whitespace", () => { + expect(parseCronExpression(" 5 8 * * * ")).toEqual({ + mode: "daily", + hour: "08", + minute: "05", + weekday: "*", + rawCron: "5 8 * * *", + }); + }); + + it("uses default draft fields for a cron expression with the wrong arity", () => { + expect(parseCronExpression("0 9 * *")).toEqual({ + mode: "custom", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * *", + }); + }); + + it("derives a compact name from the first non-empty prompt line", () => { + expect( + deriveAutomationName( + "\n Review every open PostHog PR for stale comments \nIgnore this line", + ), + ).toBe("Review every open PostHog PR for stale comments"); + }); + + it("returns an empty name for a blank prompt", () => { + expect(deriveAutomationName(" \n\t\n ")).toBe(""); + }); + + it("limits derived names to 80 characters", () => { + expect(deriveAutomationName("a".repeat(100))).toBe("a".repeat(80)); + }); + + it.each([ + ["15 * * * *", "Europe/London", "Every hour at :15 · Europe/London"], + ["0 9 * * *", null, "Daily at 09:00"], + ["0 9 * * 1-5", "UTC", "Weekdays at 09:00 · UTC"], + ["30 14 * * 2", undefined, "Tue at 14:30"], + ["30 14 * * 7", "UTC", "Weekly at 14:30 · UTC"], + ["*/15 * * * *", "UTC", "Custom schedule · UTC"], + ])("formats %s with timezone %j", (cronExpression, timezone, expected) => { + expect(formatScheduleSummary(cronExpression, timezone)).toBe(expected); + }); + + it("formats a schedule from an automation-shaped input", () => { + expect( + formatAutomationScheduleSummary({ + cron_expression: "0 18 * * 0", + timezone: "America/New_York", + }), + ).toBe("Sun at 18:00 · America/New_York"); + }); +}); diff --git a/packages/core/src/automations/automationSchedule.ts b/packages/core/src/automations/automationSchedule.ts new file mode 100644 index 0000000000..527e8da497 --- /dev/null +++ b/packages/core/src/automations/automationSchedule.ts @@ -0,0 +1,216 @@ +export type AutomationScheduleMode = + | "hourly" + | "daily" + | "weekdays" + | "weekly" + | "custom"; + +export interface AutomationScheduleDraft { + mode: AutomationScheduleMode; + hour: string; + minute: string; + weekday: string; + rawCron: string; +} + +export interface AutomationScheduleSummaryInput { + cron_expression: string; + timezone?: string | null; +} + +export const WEEKDAY_OPTIONS = [ + { value: "1", label: "Mon" }, + { value: "2", label: "Tue" }, + { value: "3", label: "Wed" }, + { value: "4", label: "Thu" }, + { value: "5", label: "Fri" }, + { value: "6", label: "Sat" }, + { value: "0", label: "Sun" }, +] as const; + +export function createDefaultScheduleDraft(): AutomationScheduleDraft { + return { + mode: "daily", + hour: "09", + minute: "00", + weekday: "1", + rawCron: "0 9 * * *", + }; +} + +function padTimePart(value: string): string { + return value.padStart(2, "0"); +} + +export function sanitizeHour(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(23, Number(digitsOnly))).padStart(2, "0"); +} + +export function sanitizeMinute(value: string): string { + const digitsOnly = value.replace(/\D/g, "").slice(0, 2); + if (!digitsOnly) { + return ""; + } + + return String(Math.min(59, Number(digitsOnly))).padStart(2, "0"); +} + +export function buildCronExpression(draft: AutomationScheduleDraft): string { + if (draft.mode === "custom") { + return draft.rawCron.trim(); + } + + const minute = draft.minute ? String(Number(draft.minute)) : "0"; + const hour = draft.hour ? String(Number(draft.hour)) : "9"; + + switch (draft.mode) { + case "hourly": + return `${minute} * * * *`; + case "weekdays": + return `${minute} ${hour} * * 1-5`; + case "weekly": + return `${minute} ${hour} * * ${draft.weekday || "1"}`; + default: + return `${minute} ${hour} * * *`; + } +} + +export function parseCronExpression( + cronExpression: string, +): AutomationScheduleDraft { + const normalized = cronExpression.trim(); + const parts = normalized.split(/\s+/); + + if (parts.length !== 5) { + return { + ...createDefaultScheduleDraft(), + mode: "custom", + rawCron: normalized, + }; + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + const isNumericMinute = /^\d{1,2}$/.test(minute); + const isNumericHour = /^\d{1,2}$/.test(hour); + const draftBase = { + hour: padTimePart(hour), + minute: padTimePart(minute), + weekday: dayOfWeek, + rawCron: normalized, + }; + + if ( + isNumericMinute && + hour === "*" && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "hourly", + hour: "09", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "*" + ) { + return { + ...draftBase, + mode: "daily", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + dayOfWeek === "1-5" + ) { + return { + ...draftBase, + mode: "weekdays", + weekday: "1", + }; + } + + if ( + isNumericMinute && + isNumericHour && + dayOfMonth === "*" && + month === "*" && + /^\d$/.test(dayOfWeek) + ) { + return { + ...draftBase, + mode: "weekly", + }; + } + + return { + ...draftBase, + mode: "custom", + }; +} + +export function deriveAutomationName(prompt: string): string { + const normalized = prompt + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + if (!normalized) { + return ""; + } + + return normalized.replace(/\s+/g, " ").slice(0, 80); +} + +function formatTime(hour: string, minute: string): string { + return `${padTimePart(hour)}:${padTimePart(minute)}`; +} + +export function formatScheduleSummary( + cronExpression: string, + timezone: string | null | undefined, +): string { + const draft = parseCronExpression(cronExpression); + const suffix = timezone ? ` · ${timezone}` : ""; + + switch (draft.mode) { + case "hourly": + return `Every hour at :${padTimePart(draft.minute)}${suffix}`; + case "weekdays": + return `Weekdays at ${formatTime(draft.hour, draft.minute)}${suffix}`; + case "weekly": { + const label = + WEEKDAY_OPTIONS.find((option) => option.value === draft.weekday) + ?.label ?? "Weekly"; + return `${label} at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } + case "custom": + return `Custom schedule${suffix}`; + default: + return `Daily at ${formatTime(draft.hour, draft.minute)}${suffix}`; + } +} + +export function formatAutomationScheduleSummary( + automation: AutomationScheduleSummaryInput, +): string { + return formatScheduleSummary( + automation.cron_expression, + automation.timezone ?? null, + ); +} diff --git a/packages/core/src/automations/automationStatus.test.ts b/packages/core/src/automations/automationStatus.test.ts new file mode 100644 index 0000000000..862fc3c0b5 --- /dev/null +++ b/packages/core/src/automations/automationStatus.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + type AutomationStatusPresentation, + type AutomationTaskRunStatus, + getAutomationStatusPresentation, +} from "./automationStatus"; + +describe("automationStatus", () => { + it.each<{ + status: AutomationTaskRunStatus; + expected: AutomationStatusPresentation | null; + }>([ + { + status: "not_started", + expected: { label: "Queued", tone: "warning", iconKind: "queued" }, + }, + { + status: "queued", + expected: { label: "Queued", tone: "warning", iconKind: "queued" }, + }, + { status: "started", expected: null }, + { status: "in_progress", expected: null }, + { + status: "completed", + expected: { label: "Success", tone: "success", iconKind: "success" }, + }, + { + status: "failed", + expected: { label: "Failed", tone: "error", iconKind: "failed" }, + }, + { + status: "cancelled", + expected: { label: "Failed", tone: "error", iconKind: "failed" }, + }, + ])( + "maps task-run status $status to renderer-neutral presentation data", + ({ status, expected }) => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "success", + lastTaskRunStatus: status, + }), + ).toEqual(expected); + }, + ); + + it.each([ + ["running", null], + ["success", { label: "Success", tone: "success", iconKind: "success" }], + ["failed", { label: "Failed", tone: "error", iconKind: "failed" }], + [null, { label: "Never run", tone: "neutral", iconKind: "never-run" }], + ["unknown", { label: "Never run", tone: "neutral", iconKind: "never-run" }], + ] as const)( + "falls back from automation status %j to semantic presentation data", + (lastRunStatus, expected) => { + expect(getAutomationStatusPresentation({ lastRunStatus })).toEqual( + expected, + ); + }, + ); + + it("prioritizes linked task-run detail over the automation-level status", () => { + expect( + getAutomationStatusPresentation({ + lastRunStatus: "failed", + lastTaskRunStatus: "completed", + }), + ).toEqual({ + label: "Success", + tone: "success", + iconKind: "success", + }); + }); + + it("does not expose renderer-specific class names", () => { + expect( + getAutomationStatusPresentation({ lastRunStatus: "success" }), + ).not.toHaveProperty("className"); + }); +}); diff --git a/packages/core/src/automations/automationStatus.ts b/packages/core/src/automations/automationStatus.ts new file mode 100644 index 0000000000..4bcc13735e --- /dev/null +++ b/packages/core/src/automations/automationStatus.ts @@ -0,0 +1,83 @@ +export type AutomationTaskRunStatus = + | "not_started" + | "queued" + | "started" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +export interface AutomationStatusInput { + lastRunStatus: string | null; + lastTaskRunStatus?: AutomationTaskRunStatus | null; +} + +export type AutomationStatusTone = "neutral" | "warning" | "success" | "error"; + +export type AutomationStatusIconKind = + | "queued" + | "success" + | "failed" + | "never-run"; + +export interface AutomationStatusPresentation { + label: string; + tone: AutomationStatusTone; + iconKind: AutomationStatusIconKind; +} + +export function getAutomationStatusPresentation({ + lastRunStatus, + lastTaskRunStatus, +}: AutomationStatusInput): AutomationStatusPresentation | null { + switch (lastTaskRunStatus) { + case "not_started": + case "queued": + return { + label: "Queued", + tone: "warning", + iconKind: "queued", + }; + case "started": + case "in_progress": + return null; + case "completed": + return { + label: "Success", + tone: "success", + iconKind: "success", + }; + case "failed": + case "cancelled": + return { + label: "Failed", + tone: "error", + iconKind: "failed", + }; + default: + break; + } + + switch (lastRunStatus) { + case "running": + return null; + case "success": + return { + label: "Success", + tone: "success", + iconKind: "success", + }; + case "failed": + return { + label: "Failed", + tone: "error", + iconKind: "failed", + }; + default: + return { + label: "Never run", + tone: "neutral", + iconKind: "never-run", + }; + } +} diff --git a/packages/core/src/inbox/artefacts.test.ts b/packages/core/src/inbox/artefacts.test.ts index a4e972613e..3d733ffbd3 100644 --- a/packages/core/src/inbox/artefacts.test.ts +++ b/packages/core/src/inbox/artefacts.test.ts @@ -1,11 +1,43 @@ -import type { SuggestedReviewer } from "@posthog/shared/types"; +import type { + AvailableSuggestedReviewer, + SuggestedReviewer, +} from "@posthog/shared/types"; import { describe, expect, it } from "vitest"; import { + buildReviewerOptions, extractSuggestedReviewers, + orderSuggestedReviewers, reviewerInitials, + reviewerMatchesAvailable, + reviewerOptionLabel, suggestedReviewerDisplayName, + toSuggestedReviewerWriteContent, } from "./artefacts"; +function makeReviewer( + partial: Partial = {}, +): SuggestedReviewer { + return { + github_login: "octocat", + github_name: "The Octocat", + relevant_commits: [], + user: null, + ...partial, + }; +} + +function makeAvailableReviewer( + partial: Partial = {}, +): AvailableSuggestedReviewer { + return { + uuid: "uuid-1", + name: "Ada Lovelace", + email: "ada@example.com", + github_login: "ada", + ...partial, + }; +} + describe("artefacts", () => { it("extracts suggested reviewers from artefacts", () => { const reviewers: SuggestedReviewer[] = [ @@ -46,4 +78,129 @@ describe("artefacts", () => { expect(reviewerInitials("Ben W.", null)).toBe("BW"); expect(reviewerInitials("", "ben@posthog.com")).toBe("BE"); }); + + it("moves the current user to the front", () => { + const reviewers = [ + makeReviewer({ + github_login: "a", + user: { + id: 1, + uuid: "uuid-a", + email: "a@posthog.com", + first_name: "a", + last_name: "", + }, + }), + makeReviewer({ + github_login: "me", + user: { + id: 2, + uuid: "uuid-me", + email: "me@posthog.com", + first_name: "me", + last_name: "", + }, + }), + ]; + + expect( + orderSuggestedReviewers(reviewers, "uuid-me").map( + (reviewer) => reviewer.github_login, + ), + ).toEqual(["me", "a"]); + }); + + it("deduplicates reviewer options and pins the current user first", () => { + const options = buildReviewerOptions( + [ + makeAvailableReviewer({ uuid: "b", name: "Bob" }), + makeAvailableReviewer({ uuid: "a", name: "Ada" }), + makeAvailableReviewer({ uuid: "a", name: "Ada duplicate" }), + ], + "b", + ); + + expect(options.map((option) => option.uuid)).toEqual(["b", "a"]); + }); + + it("labels the current reviewer", () => { + expect( + reviewerOptionLabel({ + uuid: "uuid-me", + name: "Ada", + email: "ada@example.com", + github_login: "ada", + isMe: true, + }), + ).toBe("Ada (Me)"); + }); + + it.each([ + { + name: "user uuid", + reviewer: makeReviewer({ + github_login: "", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: true, + }, + { + name: "case-insensitive GitHub login", + reviewer: makeReviewer({ github_login: "ADA" }), + expected: true, + }, + { + name: "different reviewer", + reviewer: makeReviewer(), + expected: false, + }, + ])("matches an available reviewer by $name", ({ reviewer, expected }) => { + expect(reviewerMatchesAvailable(reviewer, makeAvailableReviewer())).toBe( + expected, + ); + }); + + it.each([ + { + name: "GitHub login", + reviewer: makeReviewer({ + github_login: "ada", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: [{ github_login: "ada" }], + }, + { + name: "user uuid fallback", + reviewer: makeReviewer({ + github_login: "", + user: { + id: 1, + uuid: "uuid-1", + email: "", + first_name: "", + last_name: "", + }, + }), + expected: [{ user_uuid: "uuid-1" }], + }, + { + name: "unresolved reviewer", + reviewer: makeReviewer({ github_login: "" }), + expected: [], + }, + ])("builds write content from the $name", ({ reviewer, expected }) => { + expect(toSuggestedReviewerWriteContent([reviewer])).toEqual(expected); + }); }); diff --git a/packages/core/src/inbox/artefacts.ts b/packages/core/src/inbox/artefacts.ts index 1978f710bc..dc5b2eddb2 100644 --- a/packages/core/src/inbox/artefacts.ts +++ b/packages/core/src/inbox/artefacts.ts @@ -1,8 +1,18 @@ import type { + AvailableSuggestedReviewer, RepoSelectionArtefact, SuggestedReviewer, + SuggestedReviewerWriteEntry, } from "@posthog/shared/types"; +export interface ReviewerOption { + uuid: string; + name: string; + email: string; + github_login: string; + isMe: boolean; +} + function hasRepositoryContent( content: unknown, ): content is RepoSelectionArtefact["content"] { @@ -48,6 +58,82 @@ export function extractSuggestedReviewers( return artefact?.content ?? []; } +export function orderSuggestedReviewers( + reviewers: SuggestedReviewer[], + currentUserUuid: string | null | undefined, +): SuggestedReviewer[] { + if (!currentUserUuid) return reviewers; + const currentUserIndex = reviewers.findIndex( + (reviewer) => reviewer.user?.uuid === currentUserUuid, + ); + if (currentUserIndex <= 0) return reviewers; + return [ + reviewers[currentUserIndex], + ...reviewers.filter((_, index) => index !== currentUserIndex), + ]; +} + +export function buildReviewerOptions( + reviewers: AvailableSuggestedReviewer[], + currentUserUuid: string | undefined, +): ReviewerOption[] { + const seen = new Set(); + const options: ReviewerOption[] = []; + + for (const reviewer of reviewers) { + if (!reviewer.uuid || seen.has(reviewer.uuid)) continue; + seen.add(reviewer.uuid); + options.push({ + uuid: reviewer.uuid, + name: reviewer.name?.trim() || "", + email: reviewer.email?.trim() || "", + github_login: reviewer.github_login?.trim() || "", + isMe: reviewer.uuid === currentUserUuid, + }); + } + + options.sort((first, second) => { + if (first.isMe && !second.isMe) return -1; + if (!first.isMe && second.isMe) return 1; + return (first.name || first.email).localeCompare( + second.name || second.email, + ); + }); + + return options; +} + +export function reviewerOptionLabel(reviewer: ReviewerOption): string { + const base = reviewer.name || reviewer.email || "Unknown user"; + return reviewer.isMe ? `${base} (Me)` : base; +} + +export function reviewerMatchesAvailable( + reviewer: SuggestedReviewer, + available: AvailableSuggestedReviewer, +): boolean { + if (reviewer.user?.uuid && reviewer.user.uuid === available.uuid) { + return true; + } + return ( + !!reviewer.github_login && + !!available.github_login && + reviewer.github_login.toLowerCase() === available.github_login.toLowerCase() + ); +} + +export function toSuggestedReviewerWriteContent( + reviewers: SuggestedReviewer[], +): SuggestedReviewerWriteEntry[] { + return reviewers + .map((reviewer): SuggestedReviewerWriteEntry | null => { + if (reviewer.github_login) return { github_login: reviewer.github_login }; + if (reviewer.user?.uuid) return { user_uuid: reviewer.user.uuid }; + return null; + }) + .filter((entry): entry is SuggestedReviewerWriteEntry => entry !== null); +} + const AVATAR_PALETTE = [ "bg-(--orange-9) text-white", "bg-(--blue-9) text-white", diff --git a/packages/core/src/inbox/reportFiltering.test.ts b/packages/core/src/inbox/reportFiltering.test.ts index f2be318fac..3362ca5cde 100644 --- a/packages/core/src/inbox/reportFiltering.test.ts +++ b/packages/core/src/inbox/reportFiltering.test.ts @@ -6,8 +6,18 @@ import { buildSignalReportListOrdering, buildSuggestedReviewerFilterParam, filterReportsBySearch, + INBOX_PIPELINE_STATUS_FILTER, + INBOX_PIPELINE_STATUSES, } from "./reportFiltering"; +describe("inbox pipeline statuses", () => { + it("derives the API filter from the typed status list", () => { + expect(INBOX_PIPELINE_STATUS_FILTER).toBe( + INBOX_PIPELINE_STATUSES.join(","), + ); + }); +}); + function makeReport(overrides: Partial = {}): SignalReport { return { id: "1", diff --git a/packages/core/src/inbox/reportFiltering.ts b/packages/core/src/inbox/reportFiltering.ts index 06d36038b5..2a8271b2cb 100644 --- a/packages/core/src/inbox/reportFiltering.ts +++ b/packages/core/src/inbox/reportFiltering.ts @@ -5,12 +5,20 @@ import type { SignalReportStatus, } from "@posthog/shared/types"; +export const INBOX_PIPELINE_STATUSES = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +] as const satisfies readonly SignalReportStatus[]; + /** * Comma-separated statuses for the inbox query. We pull `failed` so the Runs * tab can surface failed runs in its Recently finished section. */ -export const INBOX_PIPELINE_STATUS_FILTER = - "potential,candidate,in_progress,ready,pending_input,failed"; +export const INBOX_PIPELINE_STATUS_FILTER = INBOX_PIPELINE_STATUSES.join(","); /** * Status filter for the Archive tab — the two terminal, not-in-inbox states: diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index ecb989b446..b6a9e4f8f4 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -1,6 +1,10 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { Adapter, StoredLogEntry } from "@posthog/shared"; -import { getAvailableCodexModes, getAvailableModes } from "./executionModes"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableCodexModes, + getAvailableModes, +} from "./executionModes"; /** * Pure derivations of cloud session config options. No store or host access — @@ -54,7 +58,8 @@ export function buildCloudDefaultConfigOptions( ): SessionConfigOption[] { const modes = adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const fallbackMode = adapter === "codex" ? "auto" : "plan"; + const fallbackMode = + adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; const currentMode = typeof initialMode === "string" && modes.some((mode) => mode.id === initialMode) diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 4a32413c12..2ccbadf354 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -1,4 +1,4 @@ -import { CODEX_MODE_PRESETS } from "@posthog/shared"; +import { CODEX_MODE_PRESETS, type ExecutionMode } from "@posthog/shared"; export interface ModeInfo { id: string; @@ -6,6 +6,8 @@ export interface ModeInfo { description: string; } +export const DEFAULT_CLAUDE_EXECUTION_MODE: ExecutionMode = "plan"; + const availableModes: ModeInfo[] = [ { id: "default", diff --git a/packages/core/src/sessions/portableSessionEvents.test.ts b/packages/core/src/sessions/portableSessionEvents.test.ts new file mode 100644 index 0000000000..d777a0ec8b --- /dev/null +++ b/packages/core/src/sessions/portableSessionEvents.test.ts @@ -0,0 +1,71 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { + convertStoredEntriesToPortableSessionEvents, + inferStoredLogEntryDirection, +} from "./portableSessionEvents"; + +describe("inferStoredLogEntryDirection", () => { + it.each([ + [ + "client requests", + { notification: { id: 1, method: "session/prompt" } }, + "client", + ], + ["agent responses", { notification: { id: 1, result: {} } }, "agent"], + [ + "agent notifications", + { notification: { method: "session/update" } }, + "agent", + ], + ["missing messages", {}, "agent"], + ] as const)("classifies %s", (_name, entry, expected) => { + expect(inferStoredLogEntryDirection(entry as StoredLogEntry)).toBe( + expected, + ); + }); +}); + +describe("convertStoredEntriesToPortableSessionEvents", () => { + it("projects session updates alongside their raw ACP message", () => { + const notification = { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hello" }, + }, + }; + const events = convertStoredEntriesToPortableSessionEvents([ + { + type: "notification", + timestamp: "2026-07-21T12:00:00.000Z", + notification: { method: "session/update", params: notification }, + }, + ]); + + expect(events).toEqual([ + { + type: "acp_message", + direction: "agent", + ts: 1_784_635_200_000, + message: { method: "session/update", params: notification }, + }, + { + type: "session_update", + ts: 1_784_635_200_000, + notification, + }, + ]); + }); + + it("uses the current time when an entry has no timestamp", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-21T12:00:00.000Z")); + + const events = convertStoredEntriesToPortableSessionEvents([ + { type: "response", notification: { id: 1, result: {} } }, + ]); + + expect(events[0]?.ts).toBe(1_784_635_200_000); + vi.useRealTimers(); + }); +}); diff --git a/packages/core/src/sessions/portableSessionEvents.ts b/packages/core/src/sessions/portableSessionEvents.ts new file mode 100644 index 0000000000..5ee36c7642 --- /dev/null +++ b/packages/core/src/sessions/portableSessionEvents.ts @@ -0,0 +1,98 @@ +import type { JsonRpcMessage, StoredLogEntry } from "@posthog/shared"; + +export type PortableSessionToolCallStatus = + | "pending" + | "in_progress" + | "completed" + | "failed" + | null; + +export interface PortableSessionUpdate { + sessionUpdate?: string; + content?: { type: string; text: string }; + attachments?: Array<{ + kind: "image" | "document"; + uri: string; + fileName: string; + mimeType?: string; + }>; + title?: string; + toolCallId?: string; + status?: PortableSessionToolCallStatus; + rawInput?: Record; + rawOutput?: unknown; + entries?: Array<{ + content: string; + status: "pending" | "in_progress" | "completed" | "failed"; + priority: string; + }>; + _meta?: { + claudeCode?: { + toolName?: string; + parentToolCallId?: string; + }; + }; +} + +export interface PortableSessionNotification { + update?: PortableSessionUpdate; +} + +export interface PortableSessionAcpMessage { + type: "acp_message"; + direction: "client" | "agent"; + ts: number; + message: JsonRpcMessage; +} + +export interface PortableSessionUpdateEvent { + type: "session_update"; + ts: number; + notification: PortableSessionNotification; +} + +export type PortableSessionEvent = + | PortableSessionAcpMessage + | PortableSessionUpdateEvent; + +export function inferStoredLogEntryDirection( + entry: StoredLogEntry, +): "client" | "agent" { + const message = entry.notification; + if (!message) return "agent"; + if (message.id !== undefined && message.method !== undefined) return "client"; + return "agent"; +} + +export function convertStoredEntriesToPortableSessionEvents( + entries: readonly StoredLogEntry[], +): PortableSessionEvent[] { + const events: PortableSessionEvent[] = []; + + for (const entry of entries) { + const ts = entry.timestamp + ? new Date(entry.timestamp).getTime() + : Date.now(); + + events.push({ + type: "acp_message", + direction: inferStoredLogEntryDirection(entry), + ts, + message: (entry.notification ?? {}) as JsonRpcMessage, + }); + + if ( + entry.type === "notification" && + entry.notification?.method === "session/update" && + entry.notification.params + ) { + events.push({ + type: "session_update", + ts, + notification: entry.notification.params as PortableSessionNotification, + }); + } + } + + return events; +} diff --git a/packages/core/src/sessions/sessionActivity.test.ts b/packages/core/src/sessions/sessionActivity.test.ts new file mode 100644 index 0000000000..db795f2118 --- /dev/null +++ b/packages/core/src/sessions/sessionActivity.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { + PortableSessionEvent, + PortableSessionToolCallStatus, +} from "./portableSessionEvents"; +import { + countUserMessages, + getSessionActivityPhase, + isSessionAwaitingUserInput, +} from "./sessionActivity"; + +function userMessage(ts = 1): PortableSessionEvent { + return { + type: "session_update", + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Yes" }, + }, + }, + }; +} + +function questionToolCall( + status: PortableSessionToolCallStatus, + sessionUpdate = "tool_call", +): PortableSessionEvent { + return { + type: "session_update", + ts: 1, + notification: { + update: { + sessionUpdate, + toolCallId: "question-1", + status, + rawInput: { questions: [{ question: "Proceed?", options: [] }] }, + _meta: { claudeCode: { toolName: "AskUserQuestion" } }, + }, + }, + }; +} + +function acpNotification(method: string): PortableSessionEvent { + return { + type: "acp_message", + direction: "agent", + ts: 1, + message: { method }, + }; +} + +describe("isSessionAwaitingUserInput", () => { + it("tracks question tools until a metadata-free completion update", () => { + const completion: PortableSessionEvent = { + type: "session_update", + ts: 2, + notification: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "question-1", + status: "completed", + }, + }, + }; + + expect( + isSessionAwaitingUserInput([questionToolCall("pending"), completion]), + ).toBe(false); + }); + + it("clears questions when the user responds", () => { + expect( + isSessionAwaitingUserInput([questionToolCall("pending"), userMessage(2)]), + ).toBe(false); + }); + + it("honors explicit waiting and terminal backend markers", () => { + expect( + isSessionAwaitingUserInput([ + acpNotification("_posthog/awaiting_user_input"), + ]), + ).toBe(true); + expect( + isSessionAwaitingUserInput([ + acpNotification("_posthog/awaiting_user_input"), + acpNotification("_posthog/turn_complete"), + ]), + ).toBe(false); + }); +}); + +describe("countUserMessages", () => { + it("counts only projected user message updates", () => { + expect( + countUserMessages([ + userMessage(), + questionToolCall("pending"), + userMessage(2), + ]), + ).toBe(2); + }); +}); + +describe("getSessionActivityPhase", () => { + it.each([ + ["retrying", true, undefined, "connecting"], + [ + "awaiting agent output", + false, + { isPromptPending: true, awaitingAgentOutput: true }, + "connecting", + ], + [ + "working", + false, + { isPromptPending: true, awaitingAgentOutput: false }, + "working", + ], + [ + "not pending", + false, + { isPromptPending: false, awaitingAgentOutput: false }, + "idle", + ], + [ + "terminal", + false, + { + isPromptPending: true, + awaitingAgentOutput: false, + terminalStatus: "completed" as const, + }, + "idle", + ], + [ + "waiting for user", + false, + { + isPromptPending: true, + awaitingAgentOutput: false, + events: [questionToolCall("pending")], + }, + "idle", + ], + ] as const)( + "returns the expected phase while %s", + (_name, retrying, session, expected) => { + expect(getSessionActivityPhase({ retrying, session })).toBe(expected); + }, + ); +}); diff --git a/packages/core/src/sessions/sessionActivity.ts b/packages/core/src/sessions/sessionActivity.ts new file mode 100644 index 0000000000..6e2804ff47 --- /dev/null +++ b/packages/core/src/sessions/sessionActivity.ts @@ -0,0 +1,129 @@ +import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; +import type { + PortableSessionEvent, + PortableSessionNotification, + PortableSessionToolCallStatus, +} from "./portableSessionEvents"; + +export type SessionActivityPhase = "idle" | "connecting" | "working"; + +export interface SessionActivityState { + isPromptPending?: boolean; + awaitingAgentOutput?: boolean; + terminalStatus?: "failed" | "completed"; + events?: readonly PortableSessionEvent[]; +} + +function isQuestionNotification( + notification: PortableSessionNotification, +): boolean { + const update = notification.update; + if (!update) return false; + + const rawToolName = update._meta?.claudeCode?.toolName; + if (typeof rawToolName === "string" && /question/i.test(rawToolName)) { + return true; + } + + const rawInput = update.rawInput; + if (!rawInput) return false; + if (Array.isArray(rawInput.questions)) return true; + + const nestedInput = rawInput.input; + return ( + typeof nestedInput === "object" && + nestedInput !== null && + Array.isArray((nestedInput as { questions?: unknown }).questions) + ); +} + +function isPendingQuestionStatus( + status: PortableSessionToolCallStatus | undefined, +): boolean { + return status === null || status === "pending" || status === "in_progress"; +} + +export function isSessionAwaitingUserInput( + events: readonly PortableSessionEvent[] = [], +): boolean { + let awaitingUserInput = false; + const questionStatuses = new Map< + string, + PortableSessionToolCallStatus | undefined + >(); + + for (const event of events) { + if (event.type === "session_update") { + const update = event.notification.update; + const sessionUpdate = update?.sessionUpdate; + + if (sessionUpdate === "user_message_chunk") { + awaitingUserInput = false; + questionStatuses.clear(); + continue; + } + + if ( + sessionUpdate === "tool_call" || + sessionUpdate === "tool_call_update" + ) { + const toolCallId = update?.toolCallId; + const isKnownQuestion = toolCallId + ? questionStatuses.has(toolCallId) + : false; + if (!isKnownQuestion && !isQuestionNotification(event.notification)) { + continue; + } + + questionStatuses.set( + toolCallId ?? `question-${event.ts}`, + update?.status, + ); + awaitingUserInput = [...questionStatuses.values()].some( + isPendingQuestionStatus, + ); + } + + continue; + } + + const method = "method" in event.message ? event.message.method : undefined; + if (method === "_posthog/awaiting_user_input") { + awaitingUserInput = true; + continue; + } + + if ( + isNotification(method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) || + isNotification(method, POSTHOG_NOTIFICATIONS.TASK_COMPLETE) || + isNotification(method, POSTHOG_NOTIFICATIONS.ERROR) + ) { + awaitingUserInput = false; + questionStatuses.clear(); + } + } + + return awaitingUserInput; +} + +export function countUserMessages( + events: readonly PortableSessionEvent[] = [], +): number { + return events.filter( + (event) => + event.type === "session_update" && + event.notification.update?.sessionUpdate === "user_message_chunk", + ).length; +} + +export function getSessionActivityPhase(args: { + retrying: boolean; + session?: SessionActivityState | null; +}): SessionActivityPhase { + const { retrying, session } = args; + + if (retrying) return "connecting"; + if (!session?.isPromptPending || session.terminalStatus) return "idle"; + if (isSessionAwaitingUserInput(session.events)) return "idle"; + return session.awaitingAgentOutput ? "connecting" : "working"; +} diff --git a/packages/core/src/tasks/taskActivity.test.ts b/packages/core/src/tasks/taskActivity.test.ts new file mode 100644 index 0000000000..35d1eadaff --- /dev/null +++ b/packages/core/src/tasks/taskActivity.test.ts @@ -0,0 +1,132 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { filterAndSortTasks, taskActivityTimestamp } from "./taskActivity"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "A real task", + description: "Do the thing", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + origin_product: "tasks", + ...overrides, + }; +} + +describe("taskActivityTimestamp", () => { + it("uses creation time in created mode", () => { + const task = makeTask({ + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }); + + expect(taskActivityTimestamp(task, "created")).toBe( + new Date("2026-01-01T00:00:00Z").getTime(), + ); + }); + + it("uses the latest task or run update in updated mode", () => { + const task = makeTask({ + updated_at: "2026-01-02T00:00:00Z", + latest_run: { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "completed", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-04T00:00:00Z", + completed_at: "2026-01-04T00:00:00Z", + }, + }); + + expect(taskActivityTimestamp(task, "updated")).toBe( + new Date("2026-01-04T00:00:00Z").getTime(), + ); + }); +}); + +describe("filterAndSortTasks", () => { + it.each([ + { title: "", description: "" }, + { title: " ", description: "\n\t" }, + ])("hides contentless placeholder tasks", ({ title, description }) => { + const placeholder = makeTask({ id: "placeholder", title, description }); + const realTask = makeTask({ id: "real" }); + + expect( + filterAndSortTasks([placeholder, realTask], "updated", false, "").map( + (task) => task.id, + ), + ).toEqual(["real"]); + }); + + it("selects internal or external tasks", () => { + const externalTask = makeTask({ id: "external", internal: false }); + const internalTask = makeTask({ id: "internal", internal: true }); + + expect( + filterAndSortTasks( + [externalTask, internalTask], + "updated", + false, + "", + ).map((task) => task.id), + ).toEqual(["external"]); + expect( + filterAndSortTasks([externalTask, internalTask], "updated", true, "").map( + (task) => task.id, + ), + ).toEqual(["internal"]); + }); + + it.each([ + ["title", { title: "Fix Login" }], + ["slug", { slug: "fix-login" }], + ["description", { description: "Fix Login" }], + ] as const)("matches a case-insensitive %s filter", (_field, overrides) => { + const matchingTask = makeTask({ id: "matching", ...overrides }); + const otherTask = makeTask({ id: "other", title: "Unrelated" }); + + expect( + filterAndSortTasks( + [otherTask, matchingTask], + "updated", + false, + "LOGIN", + ).map((task) => task.id), + ).toEqual(["matching"]); + }); + + it("sorts by the selected activity timestamp without mutating input", () => { + const olderCreated = makeTask({ + id: "older-created", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-04T00:00:00Z", + }); + const newerCreated = makeTask({ + id: "newer-created", + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-03T00:00:00Z", + }); + const tasks = [olderCreated, newerCreated]; + + expect( + filterAndSortTasks(tasks, "created", false, "").map((task) => task.id), + ).toEqual(["newer-created", "older-created"]); + expect( + filterAndSortTasks(tasks, "updated", false, "").map((task) => task.id), + ).toEqual(["older-created", "newer-created"]); + expect(tasks.map((task) => task.id)).toEqual([ + "older-created", + "newer-created", + ]); + }); +}); diff --git a/packages/core/src/tasks/taskActivity.ts b/packages/core/src/tasks/taskActivity.ts new file mode 100644 index 0000000000..6a52168e63 --- /dev/null +++ b/packages/core/src/tasks/taskActivity.ts @@ -0,0 +1,45 @@ +import { isContentlessTask, type Task } from "@posthog/shared/domain-types"; + +export type TaskActivitySortMode = "created" | "updated"; + +export function taskActivityTimestamp( + task: Pick, + sortMode: TaskActivitySortMode, +): number { + if (sortMode === "created") { + return new Date(task.created_at).getTime(); + } + + const runUpdatedAt = task.latest_run?.updated_at; + return Math.max( + runUpdatedAt ? new Date(runUpdatedAt).getTime() : 0, + new Date(task.updated_at ?? task.created_at).getTime(), + ); +} + +export function filterAndSortTasks( + tasks: readonly Task[], + sortMode: TaskActivitySortMode, + showInternal: boolean, + filter: string, +): Task[] { + const normalizedFilter = filter.toLowerCase(); + + return tasks + .filter((task) => !isContentlessTask(task)) + .filter((task) => + showInternal ? task.internal === true : task.internal !== true, + ) + .filter( + (task) => + !normalizedFilter || + task.title.toLowerCase().includes(normalizedFilter) || + task.slug.toLowerCase().includes(normalizedFilter) || + task.description?.toLowerCase().includes(normalizedFilter), + ) + .sort( + (firstTask, secondTask) => + taskActivityTimestamp(secondTask, sortMode) - + taskActivityTimestamp(firstTask, sortMode), + ); +} diff --git a/packages/core/src/tasks/taskArchive.test.ts b/packages/core/src/tasks/taskArchive.test.ts new file mode 100644 index 0000000000..ab22bbd459 --- /dev/null +++ b/packages/core/src/tasks/taskArchive.test.ts @@ -0,0 +1,44 @@ +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { isTaskRunning } from "./taskArchive"; + +function makeTask(status?: TaskRunStatus): Pick { + return { + latest_run: status + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status, + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + } + : undefined, + }; +} + +describe("isTaskRunning", () => { + it("returns false when a task has no run", () => { + expect(isTaskRunning(makeTask())).toBe(false); + }); + + it.each(["not_started", "queued", "in_progress"] as const)( + "returns true for %s", + (status) => { + expect(isTaskRunning(makeTask(status))).toBe(true); + }, + ); + + it.each(["completed", "failed", "cancelled"] as const)( + "returns false for %s", + (status) => { + expect(isTaskRunning(makeTask(status))).toBe(false); + }, + ); +}); diff --git a/packages/core/src/tasks/taskArchive.ts b/packages/core/src/tasks/taskArchive.ts new file mode 100644 index 0000000000..db52bb6310 --- /dev/null +++ b/packages/core/src/tasks/taskArchive.ts @@ -0,0 +1,6 @@ +import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; + +export function isTaskRunning(task: Pick): boolean { + const status = task.latest_run?.status; + return status !== undefined && !isTerminalStatus(status); +} diff --git a/packages/core/src/tasks/taskStatusPresentation.test.ts b/packages/core/src/tasks/taskStatusPresentation.test.ts new file mode 100644 index 0000000000..a7895cf59e --- /dev/null +++ b/packages/core/src/tasks/taskStatusPresentation.test.ts @@ -0,0 +1,69 @@ +import type { Task, TaskRun } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { getTaskStatusPresentationKind } from "./taskStatusPresentation"; + +function makeTask(latestRun?: Partial): Pick { + return { + latest_run: latestRun + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + status: "not_started", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + ...latestRun, + } + : undefined, + }; +} + +describe("getTaskStatusPresentationKind", () => { + it("prioritizes a pull request over cloud presentation", () => { + expect( + getTaskStatusPresentationKind( + makeTask({ + environment: "cloud", + status: "in_progress", + output: { pr_url: "https://github.com/PostHog/code/pull/123" }, + }), + ), + ).toBe("pr"); + }); + + it.each([ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", + ] as const)("uses chat presentation for cloud status %s", (status) => { + expect( + getTaskStatusPresentationKind(makeTask({ environment: "cloud", status })), + ).toBe("chat"); + }); + + it.each([ + ["completed", "completed"], + ["failed", "failed"], + ["in_progress", "running"], + ["queued", "started"], + ["not_started", "chat"], + ["cancelled", "chat"], + ] as const)("maps local status %s to %s", (status, expected) => { + expect( + getTaskStatusPresentationKind(makeTask({ environment: "local", status })), + ).toBe(expected); + }); + + it("falls back to chat when a task has no run", () => { + expect(getTaskStatusPresentationKind(makeTask())).toBe("chat"); + }); +}); diff --git a/packages/core/src/tasks/taskStatusPresentation.ts b/packages/core/src/tasks/taskStatusPresentation.ts new file mode 100644 index 0000000000..968a17bb76 --- /dev/null +++ b/packages/core/src/tasks/taskStatusPresentation.ts @@ -0,0 +1,37 @@ +import { readPrUrls } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; + +export type TaskStatusPresentationKind = + | "pr" + | "completed" + | "failed" + | "running" + | "started" + | "chat"; + +export function getTaskStatusPresentationKind( + task: Pick, +): TaskStatusPresentationKind { + const latestRun = task.latest_run; + + if (readPrUrls(latestRun?.output)[0]) { + return "pr"; + } + + if (latestRun?.environment === "cloud") { + return "chat"; + } + + switch (latestRun?.status) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "in_progress": + return "running"; + case "queued": + return "started"; + default: + return "chat"; + } +} From 53e538364092cc252cd383a9c8595a5c72268b8a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:19:34 +0300 Subject: [PATCH 9/9] refactor(core): rename cloud task service as engine Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/web/src/web-container.ts | 2 +- .../core/src/cloud-task/{cloud-task.ts => cloud-task-engine.ts} | 0 packages/core/src/cloud-task/cloud-task.module.ts | 2 +- packages/core/src/cloud-task/cloud-task.test.ts | 2 +- packages/core/src/handoff/handoff.ts | 2 +- packages/host-router/src/routers/cloud-task.router.ts | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename packages/core/src/cloud-task/{cloud-task.ts => cloud-task-engine.ts} (100%) diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index 3075d139ce..cf17cd493e 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -27,8 +27,8 @@ import { } from "@posthog/core/auth/identifiers"; import { canvasCoreModule } from "@posthog/core/canvas/canvas.module"; import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; -import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine"; import { CLOUD_TASK_AUTH, CLOUD_TASK_SERVICE, diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task-engine.ts similarity index 100% rename from packages/core/src/cloud-task/cloud-task.ts rename to packages/core/src/cloud-task/cloud-task-engine.ts diff --git a/packages/core/src/cloud-task/cloud-task.module.ts b/packages/core/src/cloud-task/cloud-task.module.ts index 02f0cc91db..464011d14f 100644 --- a/packages/core/src/cloud-task/cloud-task.module.ts +++ b/packages/core/src/cloud-task/cloud-task.module.ts @@ -1,5 +1,5 @@ import { ContainerModule } from "inversify"; -import { CloudTaskService } from "./cloud-task"; +import { CloudTaskService } from "./cloud-task-engine"; import { CLOUD_TASK_SERVICE } from "./identifiers"; export const cloudTaskModule = new ContainerModule(({ bind }) => { diff --git a/packages/core/src/cloud-task/cloud-task.test.ts b/packages/core/src/cloud-task/cloud-task.test.ts index a7fbf37c66..9002f948ff 100644 --- a/packages/core/src/cloud-task/cloud-task.test.ts +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -22,7 +22,7 @@ const fetchRouter = vi.hoisted(() => }), ); -import { CloudTaskService } from "./cloud-task"; +import { CloudTaskService } from "./cloud-task-engine"; const mockAuthService = { authenticatedFetch: vi.fn(), diff --git a/packages/core/src/handoff/handoff.ts b/packages/core/src/handoff/handoff.ts index c85c2fcfa8..2b8d477099 100644 --- a/packages/core/src/handoff/handoff.ts +++ b/packages/core/src/handoff/handoff.ts @@ -5,7 +5,7 @@ import { TypedEventEmitter, } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import type { CloudTaskService } from "../cloud-task/cloud-task"; +import type { CloudTaskService } from "../cloud-task/cloud-task-engine"; import { CLOUD_TASK_SERVICE } from "../cloud-task/identifiers"; import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; import { diff --git a/packages/host-router/src/routers/cloud-task.router.ts b/packages/host-router/src/routers/cloud-task.router.ts index 15d577ce59..4546995404 100644 --- a/packages/host-router/src/routers/cloud-task.router.ts +++ b/packages/host-router/src/routers/cloud-task.router.ts @@ -1,4 +1,4 @@ -import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task-engine"; import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers"; import { CloudTaskEvent,