From 484a6c7b88e6578dbe5dbdd74b6cc1753900a985 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:17:22 +0300 Subject: [PATCH 01/25] 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 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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 07/25] 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 08/25] 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 09/25] 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, From 2245a2e61032c11dc77773d4fcd64afc9a121e2a Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:20:17 +0300 Subject: [PATCH 10/25] refactor(core): extract portable cloud task 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-engine.ts | 96 ++++++++++++------- .../src/cloud-task/cloud-task-service.test.ts | 27 ++++++ .../core/src/cloud-task/cloud-task.module.ts | 2 +- .../core/src/cloud-task/cloud-task.test.ts | 47 +++++---- packages/core/src/cloud-task/cloud-task.ts | 35 +++++++ packages/core/src/cloud-task/schemas.ts | 22 ++--- packages/core/src/handoff/handoff.ts | 2 +- packages/core/vitest.config.ts | 23 +++++ .../src/routers/cloud-task.router.ts | 2 +- 10 files changed, 183 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/cloud-task/cloud-task-service.test.ts create mode 100644 packages/core/src/cloud-task/cloud-task.ts create mode 100644 packages/core/vitest.config.ts diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index cf17cd493e..3075d139ce 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-engine.ts b/packages/core/src/cloud-task/cloud-task-engine.ts index 22b5a1ddab..006b0ab6d2 100644 --- a/packages/core/src/cloud-task/cloud-task-engine.ts +++ b/packages/core/src/cloud-task/cloud-task-engine.ts @@ -1,37 +1,24 @@ +import type { RootLogger, ScopedLogger } from "@posthog/di/logger"; +import type { IAnalytics } from "@posthog/platform/analytics"; import { - ROOT_LOGGER, - type RootLogger, - type ScopedLogger, -} from "@posthog/di/logger"; -import { - ANALYTICS_SERVICE, - type IAnalytics, -} from "@posthog/platform/analytics"; -import type { StoredLogEntry } from "@posthog/shared"; -import { + type CloudTaskPermissionRequestUpdate, + isTerminalStatus, mcpToolKey, posthogToolMeta, + type StoredLogEntry, serializeError, + type TaskRunStatus, TypedEventEmitter, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { inject, injectable, optional, preDestroy } from "inversify"; -import type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types"; -import { - CLOUD_TASK_AUTH, - type ICloudTaskAuth, - MCP_RELAY_EXECUTOR, - type McpRelayExecutor, -} from "./identifiers"; +import type { ICloudTaskAuth, McpRelayExecutor } from "./identifiers"; import { CloudTaskEvent, type CloudTaskEvents, - isTerminalStatus, type SendCommandInput, type SendCommandOutput, type StopInput, type StopOutput, - type TaskRunStatus, type WatchInput, } from "./schemas"; import { type SseEvent, SseEventParser } from "./sse-parser"; @@ -435,23 +422,45 @@ function sandboxAlivePayload(watcher: { lastSandboxAlive: boolean | null }): { : { sandboxAlive: watcher.lastSandboxAlive }; } -@injectable() -export class CloudTaskService extends TypedEventEmitter { +export interface CloudTaskEngineDependencies { + auth: ICloudTaskAuth; + analytics: IAnalytics; + logger: RootLogger; + mcpRelayExecutor?: McpRelayExecutor | null; + streamFetch?: CloudTaskFetch; +} + +export type CloudTaskFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export function createCloudTaskEngine( + dependencies: CloudTaskEngineDependencies, +): CloudTaskEngine { + return new CloudTaskEngine(dependencies); +} + +export class CloudTaskEngine extends TypedEventEmitter { private watchers = new Map(); private readonly log: ScopedLogger; - - constructor( - @inject(CLOUD_TASK_AUTH) - private readonly auth: ICloudTaskAuth, - @inject(ANALYTICS_SERVICE) - private readonly analytics: IAnalytics, - @inject(ROOT_LOGGER) - logger: RootLogger, - @inject(MCP_RELAY_EXECUTOR) - @optional() - private readonly mcpRelayExecutor: McpRelayExecutor | null = null, - ) { + private readonly auth: ICloudTaskAuth; + private readonly analytics: IAnalytics; + private readonly mcpRelayExecutor: McpRelayExecutor | null; + private readonly streamFetch: CloudTaskFetch; + + constructor({ + auth, + analytics, + logger, + mcpRelayExecutor = null, + streamFetch = globalThis.fetch.bind(globalThis), + }: CloudTaskEngineDependencies) { super(); + this.auth = auth; + this.analytics = analytics; + this.mcpRelayExecutor = mcpRelayExecutor; + this.streamFetch = streamFetch; this.log = logger.scope("cloud-task"); } @@ -770,6 +779,22 @@ export class CloudTaskService extends TypedEventEmitter { void this.bootstrapWatcher(key); } + reconnectIfDisconnected(taskId: string, runId: string): void { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if ( + !watcher || + watcher.sseAbortController || + watcher.reconnectTimeoutId || + watcher.isBootstrapping || + isTerminalStatus(watcher.lastStatus) + ) { + return; + } + + void this.connectSse(key); + } + // Resets a watcher to its pre-bootstrap state so bootstrapWatcher can rebuild it from server truth. private resetWatcherForRebootstrap(watcher: WatcherState): void { watcher.reconnectAttempts = 0; @@ -959,7 +984,6 @@ export class CloudTaskService extends TypedEventEmitter { } } - @preDestroy() unwatchAll(): void { for (const key of [...this.watchers.keys()]) { this.stopWatcher(key); @@ -1306,7 +1330,7 @@ export class CloudTaskService extends TypedEventEmitter { try { // The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session. const response = usingProxy - ? await fetch(url.toString(), { + ? await this.streamFetch(url.toString(), { method: "GET", headers, signal: controller.signal, diff --git a/packages/core/src/cloud-task/cloud-task-service.test.ts b/packages/core/src/cloud-task/cloud-task-service.test.ts new file mode 100644 index 0000000000..644f9dd7cb --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task-service.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { CloudTaskService } from "./cloud-task"; +import { CloudTaskEngine } from "./cloud-task-engine"; + +describe("CloudTaskService", () => { + it("preserves the injectable service API as a thin engine wrapper", () => { + const scopedLog = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const service = new CloudTaskService( + { + authenticatedFetch: vi.fn(), + getCloudContext: vi.fn(), + }, + { track: vi.fn() } as never, + { ...scopedLog, scope: vi.fn(() => scopedLog) }, + ); + + expect(service).toBeInstanceOf(CloudTaskEngine); + expect(service.watch).toBeTypeOf("function"); + expect(service.retry).toBeTypeOf("function"); + expect(service.unwatchAll).toBeTypeOf("function"); + }); +}); diff --git a/packages/core/src/cloud-task/cloud-task.module.ts b/packages/core/src/cloud-task/cloud-task.module.ts index 464011d14f..02f0cc91db 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-engine"; +import { CloudTaskService } from "./cloud-task"; 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 9002f948ff..4178bd3d78 100644 --- a/packages/core/src/cloud-task/cloud-task.test.ts +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -5,14 +5,17 @@ const mockNetFetch = vi.hoisted(() => vi.fn()); const mockStreamFetch = vi.hoisted(() => vi.fn()); const mockStreamTokenFetch = vi.hoisted(() => vi.fn()); -// The service now uses global fetch for BOTH authenticated API calls (JSON) -// and SSE streaming. The two used to be distinct (net.fetch vs global fetch). // Route by URL: /stream_token/ → token mock (read-leg resolution), the stream leg // (Django /stream/ or proxy /v1/runs/:run/stream) → stream mock, everything else → API mock. // The token mock has a Django-path default so existing fixtures (which never set it) are untouched. const fetchRouter = vi.hoisted(() => - vi.fn((input: string | Request, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.url; + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; const impl = url.includes("/stream_token/") ? mockStreamTokenFetch : /\/stream(\/|\?|$)/.test(url) @@ -22,7 +25,10 @@ const fetchRouter = vi.hoisted(() => }), ); -import { CloudTaskService } from "./cloud-task-engine"; +import { + type CloudTaskEngine, + createCloudTaskEngine, +} from "./cloud-task-engine"; const mockAuthService = { authenticatedFetch: vi.fn(), @@ -86,8 +92,8 @@ async function waitFor( } } -describe("CloudTaskService", () => { - let service: CloudTaskService; +describe("CloudTaskEngine", () => { + let service: CloudTaskEngine; beforeEach(() => { const scopedLog = { @@ -98,11 +104,12 @@ describe("CloudTaskService", () => { }; const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) }; const analyticsMock = { track: vi.fn() }; - service = new CloudTaskService( - mockAuthService as never, - analyticsMock as never, - loggerMock, - ); + service = createCloudTaskEngine({ + auth: mockAuthService as never, + analytics: analyticsMock as never, + logger: loggerMock, + streamFetch: fetchRouter, + }); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); mockStreamTokenFetch.mockReset(); @@ -3077,8 +3084,8 @@ describe("CloudTaskService", () => { }); }); -describe("CloudTaskService MCP relay", () => { - let relayService: CloudTaskService; +describe("CloudTaskEngine MCP relay", () => { + let relayService: CloudTaskEngine; let mcpRelayExecutor: { execute: ReturnType; closeRun: ReturnType; @@ -3099,12 +3106,12 @@ describe("CloudTaskService MCP relay", () => { })), closeRun: vi.fn(async () => {}), }; - relayService = new CloudTaskService( - mockAuthService as never, - analyticsMock as never, - loggerMock, - mcpRelayExecutor as never, - ); + relayService = createCloudTaskEngine({ + auth: mockAuthService as never, + analytics: analyticsMock as never, + logger: loggerMock, + mcpRelayExecutor: mcpRelayExecutor as never, + }); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task.ts new file mode 100644 index 0000000000..1e2003d85a --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task.ts @@ -0,0 +1,35 @@ +import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { + ANALYTICS_SERVICE, + type IAnalytics, +} from "@posthog/platform/analytics"; +import { inject, injectable, optional, preDestroy } from "inversify"; +import { CloudTaskEngine } from "./cloud-task-engine"; +import { + CLOUD_TASK_AUTH, + type ICloudTaskAuth, + MCP_RELAY_EXECUTOR, + type McpRelayExecutor, +} from "./identifiers"; + +@injectable() +export class CloudTaskService extends CloudTaskEngine { + constructor( + @inject(CLOUD_TASK_AUTH) + auth: ICloudTaskAuth, + @inject(ANALYTICS_SERVICE) + analytics: IAnalytics, + @inject(ROOT_LOGGER) + logger: RootLogger, + @inject(MCP_RELAY_EXECUTOR) + @optional() + mcpRelayExecutor: McpRelayExecutor | null = null, + ) { + super({ auth, analytics, logger, mcpRelayExecutor }); + } + + @preDestroy() + override unwatchAll(): void { + super.unwatchAll(); + } +} diff --git a/packages/core/src/cloud-task/schemas.ts b/packages/core/src/cloud-task/schemas.ts index d694e52141..b8c03eb202 100644 --- a/packages/core/src/cloud-task/schemas.ts +++ b/packages/core/src/cloud-task/schemas.ts @@ -1,20 +1,12 @@ -import type { TaskRunStatus } from "@posthog/shared"; +import type { CloudTaskUpdatePayload } from "@posthog/shared"; import { z } from "zod"; -import type { CloudTaskUpdatePayload } from "./cloud-task-types"; -export type { CloudTaskUpdatePayload, TaskRunStatus }; - -export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; - -export function isTerminalStatus( - status: TaskRunStatus | string | null | undefined, -): boolean { - return ( - status !== null && - status !== undefined && - TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) - ); -} +export { + type CloudTaskUpdatePayload, + isTerminalStatus, + type TaskRunStatus, + TERMINAL_STATUSES, +} from "@posthog/shared"; // --- Events --- diff --git a/packages/core/src/handoff/handoff.ts b/packages/core/src/handoff/handoff.ts index 2b8d477099..c85c2fcfa8 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-engine"; +import type { CloudTaskService } from "../cloud-task/cloud-task"; import { CLOUD_TASK_SERVICE } from "../cloud-task/identifiers"; import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; import { diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000000..bed14b24e0 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import { trunkTestOptions } from "../../vitest.config.base"; + +export default defineConfig({ + oxc: false, + esbuild: { + tsconfigRaw: { + compilerOptions: { + experimentalDecorators: true, + target: "ES2022", + useDefineForClassFields: false, + verbatimModuleSyntax: true, + }, + }, + }, + test: { + globals: true, + ...trunkTestOptions, + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/host-router/src/routers/cloud-task.router.ts b/packages/host-router/src/routers/cloud-task.router.ts index 4546995404..15d577ce59 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-engine"; +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers"; import { CloudTaskEvent, From 36f9b398e6afb14a921e49148574f39f104a66c5 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:21:03 +0300 Subject: [PATCH 11/25] refactor(core): extract repository integration semantics Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../src/integrations/repositories.test.ts | 66 ++++++++++ .../core/src/integrations/repositories.ts | 124 ++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/packages/core/src/integrations/repositories.test.ts b/packages/core/src/integrations/repositories.test.ts index ce6af19540..80eec8ad36 100644 --- a/packages/core/src/integrations/repositories.test.ts +++ b/packages/core/src/integrations/repositories.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildTeamRepositoryOptions, + buildUserRepositoryOptions, combineGithubRepositories, combineRepositoryPicker, combineUserGithubRepositories, @@ -7,8 +9,11 @@ import { isEmptyRepositoryMap, isRepoInIntegration, normalizeRepoKey, + normalizeRepositoryNames, type RepositoryCacheAction, type RepositoryQueryResult, + repositoryLoadWarning, + repositoryOptionsEqual, resolveEffectiveUserRepositoryMap, resolveUserRepositoryCacheAction, sameUserRepositoryMap, @@ -18,6 +23,67 @@ import { type UserRepositoryIntegrationRef, } from "./repositories"; +describe("repository options", () => { + it("normalizes repository names", () => { + expect(normalizeRepositoryNames(["PostHog/Code", ""])).toEqual([ + "posthog/code", + ]); + }); + + it("builds sorted team options with integration labels", () => { + expect( + buildTeamRepositoryOptions( + [ + { id: 2, display_name: "Work" }, + { id: 1, config: { account: { login: "personal" } } }, + ], + { 1: ["z/repo"], 2: ["a/repo"] }, + ), + ).toEqual([ + { integrationId: 2, integrationLabel: "Work", repository: "a/repo" }, + { + integrationId: 1, + integrationLabel: "personal", + repository: "z/repo", + }, + ]); + }); + + it("builds user options with the same shape", () => { + expect( + buildUserRepositoryOptions( + [{ id: "user-1", installation_id: "42", account: { name: "Me" } }], + { 42: ["posthog/code"] }, + ), + ).toEqual([ + { integrationId: 42, integrationLabel: "Me", repository: "posthog/code" }, + ]); + }); + + it.each([ + [0, 2, null], + [1, 2, "Some GitHub repositories could not be loaded. Pull to retry."], + [2, 2, "Could not load GitHub repositories. Pull to retry."], + ])( + "describes %i of %i failed repository loads", + (failed, total, expected) => { + expect(repositoryLoadWarning(failed, total)).toBe(expected); + }, + ); + + it("compares option lists by content", () => { + const options = [ + { integrationId: 1, integrationLabel: "Me", repository: "a/repo" }, + ]; + expect( + repositoryOptionsEqual( + options, + options.map((option) => ({ ...option })), + ), + ).toBe(true); + }); +}); + function result( data: T | undefined, flags: Partial, "data">> = {}, diff --git a/packages/core/src/integrations/repositories.ts b/packages/core/src/integrations/repositories.ts index 43a7fbf74e..ea9bb6d202 100644 --- a/packages/core/src/integrations/repositories.ts +++ b/packages/core/src/integrations/repositories.ts @@ -5,6 +5,130 @@ export interface RepositoryQueryResult { isRefetching: boolean; } +export interface RepositoryOption { + integrationId: number; + integrationLabel: string; + repository: string; +} + +export interface RepositorySelection { + integrationId: number | null; + repository: string | null; +} + +export interface TeamRepositoryIntegration { + id: number; + display_name?: string; + config?: { account?: { login?: string } }; +} + +export interface UserRepositoryIntegration { + id: string; + installation_id: string; + account?: { name?: string | null } | null; +} + +export function normalizeRepositoryNames( + repositories: ReadonlyArray, +): string[] { + return repositories + .map((repository) => repository.toLowerCase()) + .filter((repository) => repository.length > 0); +} + +export function repositoryLoadWarning( + failedCount: number, + totalCount: number, +): string | null { + if (failedCount === 0) return null; + return failedCount === totalCount + ? "Could not load GitHub repositories. Pull to retry." + : "Some GitHub repositories could not be loaded. Pull to retry."; +} + +export function buildTeamRepositoryOptions( + integrations: ReadonlyArray, + repositoriesByIntegration: Readonly>, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByIntegration[integration.id] ?? []).map((repository) => ({ + integrationId: integration.id, + integrationLabel: + integration.display_name ?? + integration.config?.account?.login ?? + `GitHub ${integration.id}`, + repository, + })), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function buildUserRepositoryOptions( + integrations: ReadonlyArray, + repositoriesByInstallation: Readonly>, +): RepositoryOption[] { + return integrations + .flatMap((integration) => + (repositoriesByInstallation[integration.installation_id] ?? []).map( + (repository) => ({ + integrationId: Number(integration.installation_id), + integrationLabel: + integration.account?.name ?? + `GitHub ${integration.installation_id}`, + repository, + }), + ), + ) + .sort((left, right) => left.repository.localeCompare(right.repository)); +} + +export function repositoryOptionsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((option, index) => { + const other = right[index]; + return ( + other?.integrationId === option.integrationId && + other.integrationLabel === option.integrationLabel && + other.repository === option.repository + ); + }) + ); +} + +export function findRepositoryOption( + options: ReadonlyArray, + selection: RepositorySelection, +): RepositoryOption | null { + if (!selection.integrationId || !selection.repository) return null; + return ( + options.find( + (option) => + option.integrationId === selection.integrationId && + option.repository === selection.repository, + ) ?? null + ); +} + +export function toRepositorySelection( + option: RepositoryOption | null, +): RepositorySelection { + return { + integrationId: option?.integrationId ?? null, + repository: option?.repository ?? null, + }; +} + +export function isRepositorySelectionComplete( + selection: RepositorySelection, +): boolean { + return !!selection.integrationId && !!selection.repository; +} + export interface TeamRepositoriesResult { integrationId: number; repos?: string[] | null; From 3003336e7f5c74b0181f0f79a4ec92524c372071 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:21:33 +0300 Subject: [PATCH 12/25] refactor(core): extract pending prompt recovery Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../core/src/tasks/pendingPrompts.test.ts | 37 +++++++++++++++ packages/core/src/tasks/pendingPrompts.ts | 47 +++++++++++++++++++ .../task-detail/hooks/useTaskCreation.ts | 7 ++- .../ui/src/shell/pendingTaskPromptStore.ts | 45 +++++++----------- 4 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/tasks/pendingPrompts.test.ts create mode 100644 packages/core/src/tasks/pendingPrompts.ts diff --git a/packages/core/src/tasks/pendingPrompts.test.ts b/packages/core/src/tasks/pendingPrompts.test.ts new file mode 100644 index 0000000000..8a733ad113 --- /dev/null +++ b/packages/core/src/tasks/pendingPrompts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + buildPendingPromptKey, + capPendingPrompts, + listPendingPromptsNewestFirst, + selectNewestPendingPrompt, +} from "./pendingPrompts"; + +describe("pending prompts", () => { + it("keeps the newest prompts up to the limit", () => { + expect( + capPendingPrompts( + { + old: { createdAt: 1 }, + middle: { createdAt: 2 }, + newest: { createdAt: 3 }, + }, + 2, + ), + ).toEqual({ middle: { createdAt: 2 }, newest: { createdAt: 3 } }); + }); + + it("orders prompts newest first and selects the newest", () => { + const prompts = { old: { createdAt: 1 }, new: { createdAt: 2 } }; + expect( + listPendingPromptsNewestFirst(prompts).map(({ key }) => key), + ).toEqual(["new", "old"]); + expect(selectNewestPendingPrompt(prompts)?.key).toBe("new"); + }); + + it.each([ + ["uuid", 1, "abc", "uuid"], + [null, 123, "abc", "pending-123-abc"], + ])("builds a portable pending key", (uuid, timestamp, entropy, expected) => { + expect(buildPendingPromptKey(uuid, timestamp, entropy)).toBe(expected); + }); +}); diff --git a/packages/core/src/tasks/pendingPrompts.ts b/packages/core/src/tasks/pendingPrompts.ts new file mode 100644 index 0000000000..e4f77eafa2 --- /dev/null +++ b/packages/core/src/tasks/pendingPrompts.ts @@ -0,0 +1,47 @@ +export const MAX_RECOVERABLE_PROMPTS = 20; + +export interface TimestampedPendingPrompt { + createdAt: number; +} + +export interface RecoverablePendingPrompt< + TPrompt extends TimestampedPendingPrompt, +> { + key: string; + prompt: TPrompt; +} + +export function capPendingPrompts( + byKey: Record, + limit: number = MAX_RECOVERABLE_PROMPTS, +): Record { + const keys = Object.keys(byKey); + if (keys.length <= limit) return byKey; + + const kept = keys + .sort((left, right) => byKey[right].createdAt - byKey[left].createdAt) + .slice(0, limit); + return Object.fromEntries(kept.map((key) => [key, byKey[key]])); +} + +export function listPendingPromptsNewestFirst< + TPrompt extends TimestampedPendingPrompt, +>(byKey: Record): RecoverablePendingPrompt[] { + return Object.entries(byKey) + .map(([key, prompt]) => ({ key, prompt })) + .sort((left, right) => right.prompt.createdAt - left.prompt.createdAt); +} + +export function selectNewestPendingPrompt< + TPrompt extends TimestampedPendingPrompt, +>(byKey: Record): RecoverablePendingPrompt | null { + return listPendingPromptsNewestFirst(byKey)[0] ?? null; +} + +export function buildPendingPromptKey( + randomUuid: string | null, + timestamp: number, + entropy: string, +): string { + return randomUuid ?? `pending-${timestamp}-${entropy}`; +} diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index d13a1c445f..9b3abdaae0 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -31,7 +31,10 @@ import { useConnectivity } from "../../../hooks/useConnectivity"; import { toast } from "../../../primitives/toast"; import { track } from "../../../shell/analytics"; import { logger } from "../../../shell/logger"; -import { pendingTaskPromptStoreApi } from "../../../shell/pendingTaskPromptStore"; +import { + generatePendingTaskKey, + pendingTaskPromptStoreApi, +} from "../../../shell/pendingTaskPromptStore"; import { titleAttachmentStoreApi } from "../../../shell/titleAttachmentStore"; import { useAuthStateValue } from "../../auth/store"; import { assertCloudUsageAvailable } from "../../billing/preflightCloudUsage"; @@ -319,7 +322,7 @@ export function useTaskCreation({ const shouldShowPendingView = !onTaskCreated && !!plainPromptText; const pendingTaskKey = shouldShowPendingView - ? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`) + ? generatePendingTaskKey() : null; if (pendingTaskKey) { diff --git a/packages/ui/src/shell/pendingTaskPromptStore.ts b/packages/ui/src/shell/pendingTaskPromptStore.ts index b91fefd3f4..cd227ef24a 100644 --- a/packages/ui/src/shell/pendingTaskPromptStore.ts +++ b/packages/ui/src/shell/pendingTaskPromptStore.ts @@ -1,13 +1,8 @@ import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; -import { logger } from "@posthog/ui/shell/logger"; import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -const log = logger.scope("pending-task-prompts"); - -const MAX_PENDING_PROMPTS = 20; - export interface PendingTaskPrompt { promptText: string; attachments: UserMessageAttachment[]; @@ -16,26 +11,6 @@ export interface PendingTaskPrompt { export type PendingTaskPromptInput = Omit; -function capToNewest( - byKey: Record, -): Record { - const keys = Object.keys(byKey); - if (keys.length <= MAX_PENDING_PROMPTS) { - return byKey; - } - const keptKeys = keys - .sort((a, b) => byKey[b].createdAt - byKey[a].createdAt) - .slice(0, MAX_PENDING_PROMPTS); - log.warn("Dropping oldest unrecovered prompts beyond cap", { - dropped: keys.length - keptKeys.length, - }); - const kept: Record = {}; - for (const key of keptKeys) { - kept[key] = byKey[key]; - } - return kept; -} - interface PendingTaskPromptStore { byKey: Record; _hasHydrated: boolean; @@ -54,7 +29,7 @@ export const usePendingTaskPromptStore = create()( setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), set: (key, prompt) => set((state) => ({ - byKey: capToNewest({ + byKey: capPendingPrompts({ ...state.byKey, [key]: { ...prompt, createdAt: Date.now() }, }), @@ -110,9 +85,7 @@ export const pendingTaskPromptStoreApi = { usePendingTaskPromptStore.getState().move(fromKey, toKey), clear: (key: string) => usePendingTaskPromptStore.getState().clear(key), getAllNewestFirst: (): RecoverablePendingPrompt[] => - Object.entries(usePendingTaskPromptStore.getState().byKey) - .map(([key, prompt]) => ({ key, prompt })) - .sort((a, b) => b.prompt.createdAt - a.prompt.createdAt), + listPendingPromptsNewestFirst(usePendingTaskPromptStore.getState().byKey), whenHydrated: (): Promise => { if (usePendingTaskPromptStore.getState()._hasHydrated) { return Promise.resolve(); @@ -128,6 +101,14 @@ export const pendingTaskPromptStoreApi = { }, }; +export function generatePendingTaskKey(): string { + return buildPendingPromptKey( + globalThis.crypto?.randomUUID?.() ?? null, + Date.now(), + Math.random().toString(36).slice(2, 10), + ); +} + export function usePendingTaskPrompt( key: string | undefined, ): PendingTaskPrompt | undefined { @@ -135,3 +116,9 @@ export function usePendingTaskPrompt( key ? state.byKey[key] : undefined, ); } + +import { + buildPendingPromptKey, + capPendingPrompts, + listPendingPromptsNewestFirst, +} from "@posthog/core/tasks/pendingPrompts"; From b5204c8a1322c09f3b1f9a6432529a65479d787f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:22:05 +0300 Subject: [PATCH 13/25] refactor(core): extract plan approval presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../sessions/planApprovalPresentation.test.ts | 29 +++++++++++++++++++ .../src/sessions/planApprovalPresentation.ts | 23 +++++++++++++++ .../session-update/PlanApprovalView.tsx | 19 ++++-------- 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/sessions/planApprovalPresentation.test.ts create mode 100644 packages/core/src/sessions/planApprovalPresentation.ts diff --git a/packages/core/src/sessions/planApprovalPresentation.test.ts b/packages/core/src/sessions/planApprovalPresentation.test.ts new file mode 100644 index 0000000000..27f9636189 --- /dev/null +++ b/packages/core/src/sessions/planApprovalPresentation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { extractPlanText } from "./planApprovalPresentation"; + +describe("extractPlanText", () => { + it.each([ + [{ rawInput: { plan: "Raw plan" } }, "Raw plan"], + [{ content: [{ text: "Direct content" }] }, "Direct content"], + [ + { + content: [ + { type: "content", content: { type: "text", text: "Nested" } }, + ], + }, + "Nested", + ], + [{ rawInput: {}, content: [] }, null], + ])("extracts plan presentation from %o", (toolCall, expected) => { + expect(extractPlanText(toolCall)).toBe(expected); + }); + + it("prefers the canonical raw plan over rendered content", () => { + expect( + extractPlanText({ + rawInput: { plan: "Canonical" }, + content: [{ text: "Rendered" }], + }), + ).toBe("Canonical"); + }); +}); diff --git a/packages/core/src/sessions/planApprovalPresentation.ts b/packages/core/src/sessions/planApprovalPresentation.ts new file mode 100644 index 0000000000..2dd43553b6 --- /dev/null +++ b/packages/core/src/sessions/planApprovalPresentation.ts @@ -0,0 +1,23 @@ +function extractTextContent(item: unknown): string | null { + if (!item || typeof item !== "object") return null; + const record = item as Record; + if (typeof record.text === "string") return record.text; + + if (!record.content || typeof record.content !== "object") return null; + const content = record.content as Record; + return typeof content.text === "string" ? content.text : null; +} + +export function extractPlanText(toolCall: { + rawInput?: { plan?: unknown } | null; + content?: readonly unknown[] | null; +}): string | null { + const rawPlan = toolCall.rawInput?.plan; + if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; + + for (const item of toolCall.content ?? []) { + const text = extractTextContent(item); + if (text?.trim()) return text; + } + return null; +} diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx index b70c281953..bf609d89d2 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -1,4 +1,5 @@ import { CaretDown, CaretRight, CheckCircle } from "@phosphor-icons/react"; +import { extractPlanText } from "@posthog/core/sessions/planApprovalPresentation"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; import { PlanContent } from "../../../permissions/PlanContent"; @@ -32,20 +33,10 @@ export function PlanApprovalView({ | undefined; const isHistoricalPlan = rawInput?.historical === true; - const planText = useMemo(() => { - if (content?.length) { - const textContent = content.find((c) => c.type === "content"); - if (textContent && "content" in textContent) { - const inner = textContent.content as - | { type?: string; text?: string } - | undefined; - if (inner?.type === "text" && inner.text) { - return inner.text; - } - } - } - return rawInput?.plan ?? null; - }, [content, rawInput?.plan]); + const planText = useMemo( + () => extractPlanText({ rawInput, content }), + [content, rawInput], + ); const wasNotApproved = isFailed || wasCancelled; const showResult = isHistoricalPlan || isComplete || wasNotApproved; From 7ccd2592d6cc8f4117fe8287442ee751aeeade10 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:22:25 +0300 Subject: [PATCH 14/25] refactor(core): extract permission option presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../src/sessions/permissionResponse.test.ts | 52 ++++++++++++++ .../core/src/sessions/permissionResponse.ts | 67 +++++++++++++++++++ .../permissions/PlanApprovalSelector.tsx | 42 +++--------- packages/ui/src/features/permissions/types.ts | 9 ++- 4 files changed, 131 insertions(+), 39 deletions(-) diff --git a/packages/core/src/sessions/permissionResponse.test.ts b/packages/core/src/sessions/permissionResponse.test.ts index 228a28142d..d467ca6154 100644 --- a/packages/core/src/sessions/permissionResponse.test.ts +++ b/packages/core/src/sessions/permissionResponse.test.ts @@ -2,10 +2,62 @@ import type { PermissionRequest } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { formatPermissionAnswerPrompt, + getPermissionOptionMeta, isOtherPermissionOption, + isPermissionApproval, + isPermissionRejection, + permissionOptionUsesCustomInput, planPermissionResponse, + resolveInitialPlanApprovalOption, + selectPlanPermissionOptions, } from "./permissionResponse"; +describe("permission option presentation", () => { + const approveOnce = { + optionId: "default", + name: "Approve", + kind: "allow_once" as const, + }; + const approveAuto = { + optionId: "auto", + name: "Approve automatically", + kind: "allow_always" as const, + }; + const reject = { + optionId: "reject_with_feedback", + name: "Reject", + kind: "reject_once" as const, + _meta: { customInput: true, description: "Explain why" }, + }; + + it("classifies approval, rejection, and custom-input options", () => { + expect(isPermissionApproval(approveOnce)).toBe(true); + expect(isPermissionRejection(reject)).toBe(true); + expect(permissionOptionUsesCustomInput(reject)).toBe(true); + expect(getPermissionOptionMeta(reject)).toEqual({ + customInput: true, + description: "Explain why", + }); + }); + + it("selects plan options and prefers a feedback rejection", () => { + expect(selectPlanPermissionOptions([approveOnce, reject])).toEqual({ + approvals: [approveOnce], + rejection: reject, + }); + }); + + it.each([ + ["default", "default"], + [null, "auto"], + ["missing", "auto"], + ])("resolves preferred approval %s", (preferred, expected) => { + expect( + resolveInitialPlanApprovalOption([approveOnce, approveAuto], preferred), + ).toBe(expected); + }); +}); + function makePermission( options: Array<{ optionId: string; diff --git a/packages/core/src/sessions/permissionResponse.ts b/packages/core/src/sessions/permissionResponse.ts index eef81a7f10..5cc4a596b5 100644 --- a/packages/core/src/sessions/permissionResponse.ts +++ b/packages/core/src/sessions/permissionResponse.ts @@ -1,5 +1,72 @@ import type { PermissionRequest } from "@posthog/shared"; +export type PermissionOption = PermissionRequest["options"][number]; + +export function getPermissionOptionMeta(option: PermissionOption): { + customInput: boolean; + description?: string; +} { + const meta = option._meta as + | { customInput?: boolean; description?: string } + | null + | undefined; + return { + customInput: meta?.customInput === true, + ...(meta?.description ? { description: meta.description } : {}), + }; +} + +export function isPermissionApproval(option: PermissionOption): boolean { + return option.kind === "allow_once" || option.kind === "allow_always"; +} + +export function isPermissionRejection(option: PermissionOption): boolean { + return ( + option.kind === "reject_once" || + option.kind === "reject_always" || + option.optionId.includes("reject") + ); +} + +export function permissionOptionUsesCustomInput( + option: PermissionOption, +): boolean { + return ( + isOtherPermissionOption(option.optionId) || + getPermissionOptionMeta(option).customInput + ); +} + +export function selectPlanPermissionOptions(options: PermissionOption[]): { + approvals: PermissionOption[]; + rejection: PermissionOption | null; +} { + const approvals = options.filter(isPermissionApproval); + const rejections = options.filter(isPermissionRejection); + return { + approvals, + rejection: + rejections.find(permissionOptionUsesCustomInput) ?? rejections[0] ?? null, + }; +} + +export function resolveInitialPlanApprovalOption( + approvals: PermissionOption[], + preferredOptionId?: string | null, +): string | undefined { + const has = (optionId: string): boolean => + approvals.some((option) => option.optionId === optionId); + return ( + (preferredOptionId && has(preferredOptionId) + ? preferredOptionId + : undefined) ?? + (has("auto") ? "auto" : undefined) ?? + approvals.find((option) => option.optionId === "default")?.optionId ?? + approvals.find((option) => option.kind === "allow_once")?.optionId ?? + approvals[0]?.optionId + ); +} + const OTHER_OPTION_ID = "_other"; const OTHER_OPTION_ID_ALT = "other"; diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index b22a77951f..145add84aa 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -1,7 +1,8 @@ -import type { - PermissionOption, - SessionConfigOption, -} from "@agentclientprotocol/sdk"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { + resolveInitialPlanApprovalOption, + selectPlanPermissionOptions, +} from "@posthog/core/sessions/permissionResponse"; import type { ExecutionMode } from "@posthog/shared"; import { ModeSelector } from "@posthog/ui/features/message-editor/components/ModeSelector"; import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles"; @@ -17,21 +18,6 @@ import { type BasePermissionProps, toSelectorOptions } from "./types"; const TITLE = "Implementation Plan"; const QUESTION = "Approve this plan to proceed?"; -function isApprove(option: PermissionOption): boolean { - return option.kind === "allow_once" || option.kind === "allow_always"; -} - -function isReject(option: PermissionOption): boolean { - return option.kind === "reject_once" || option.kind === "reject_always"; -} - -function hasCustomInput(option: PermissionOption): boolean { - return ( - (option._meta as { customInput?: boolean } | null | undefined) - ?.customInput === true - ); -} - // Don't steal focus from an interactive element in a different grid cell // (multi-task view). Mirrors the guard in useActionSelectorState. function isInteractiveElementInDifferentCell( @@ -66,11 +52,8 @@ export function PlanApprovalSelector({ onSelect, onCancel, }: BasePermissionProps) { - const approveOptions = useMemo(() => options.filter(isApprove), [options]); - const rejectOption = useMemo( - () => - options.find((o) => isReject(o) && hasCustomInput(o)) ?? - options.find(isReject), + const { approvals: approveOptions, rejection: rejectOption } = useMemo( + () => selectPlanPermissionOptions(options), [options], ); @@ -87,16 +70,7 @@ export function PlanApprovalSelector({ // via `useMemo` (rather than seeding a `useState` once) means it stays // correct once the store finishes hydrating. const initialMode = useMemo(() => { - const has = (id: string) => approveOptions.some((o) => o.optionId === id); - return ( - (lastApprovalMode && has(lastApprovalMode) - ? lastApprovalMode - : undefined) ?? - (has("auto") ? "auto" : undefined) ?? - approveOptions.find((o) => o.optionId === "default")?.optionId ?? - approveOptions.find((o) => o.kind === "allow_once")?.optionId ?? - approveOptions[0]?.optionId - ); + return resolveInitialPlanApprovalOption(approveOptions, lastApprovalMode); }, [approveOptions, lastApprovalMode]); // Only the user's own pick lives in state; everything else derives from diff --git a/packages/ui/src/features/permissions/types.ts b/packages/ui/src/features/permissions/types.ts index 1505da5062..1794965cda 100644 --- a/packages/ui/src/features/permissions/types.ts +++ b/packages/ui/src/features/permissions/types.ts @@ -3,6 +3,7 @@ import type { RequestPermissionRequest, ToolCallContent, } from "@agentclientprotocol/sdk"; +import { getPermissionOptionMeta } from "@posthog/core/sessions/permissionResponse"; import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; import type { SelectorOption } from "@posthog/ui/primitives/ActionSelector"; @@ -26,14 +27,12 @@ export function toSelectorOptions( options: PermissionOption[], ): SelectorOption[] { return options.map((opt) => { - const meta = opt._meta as - | { description?: string; customInput?: boolean } - | undefined; + const meta = getPermissionOptionMeta(opt); return { id: opt.optionId, label: opt.name, - description: meta?.description, - customInput: meta?.customInput, + description: meta.description, + customInput: meta.customInput, }; }); } From 046422e63a97b1c2125b878cc17b7aa734cb3edd Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:22:47 +0300 Subject: [PATCH 15/25] refactor(core): extract composer controls Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../src/task-detail/composerControls.test.ts | 27 +++++ .../core/src/task-detail/composerControls.ts | 99 +++++++++++++++++++ .../components/UnifiedModelSelector.tsx | 4 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/task-detail/composerControls.test.ts create mode 100644 packages/core/src/task-detail/composerControls.ts diff --git a/packages/core/src/task-detail/composerControls.test.ts b/packages/core/src/task-detail/composerControls.test.ts new file mode 100644 index 0000000000..0ba02d02cc --- /dev/null +++ b/packages/core/src/task-detail/composerControls.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerPrimaryAction } from "./composerControls"; + +describe("resolveComposerPrimaryAction", () => { + it.each([ + [{ hasContent: true }, "send"], + [{ canStop: true }, "stop"], + [{ canStop: true, hasContent: true }, "send"], + [{ canStop: true, hasContent: true, allowSendWhileRunning: false }, "stop"], + [{ isRecording: true }, "mic-stop"], + [{}, "mic"], + [{ disabled: true, hasContent: true }, "disabled"], + [{ isTranscribing: true }, "disabled"], + ])("derives %s", (overrides, expected) => { + expect( + resolveComposerPrimaryAction({ + hasContent: false, + disabled: false, + isRecording: false, + isTranscribing: false, + canStop: false, + allowSendWhileRunning: true, + ...overrides, + }), + ).toBe(expected); + }); +}); diff --git a/packages/core/src/task-detail/composerControls.ts b/packages/core/src/task-detail/composerControls.ts new file mode 100644 index 0000000000..e9694cccef --- /dev/null +++ b/packages/core/src/task-detail/composerControls.ts @@ -0,0 +1,99 @@ +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_REASONING_EFFORT, + isRestrictedModelOption, + isSupportedReasoningEffort, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export interface ComposerModelOption { + value: string; + label: string; + description?: string; + disabled: boolean; +} + +export function getModelConfigOption( + configOptions: readonly CloudTaskConfigOption[], +): CloudTaskConfigOption { + const option = configOptions.find((item) => item.category === "model"); + if (!option) throw new Error("Cloud task model configuration is unavailable"); + return option; +} + +export function getComposerModelOptions( + modelOption: CloudTaskConfigOption, +): ComposerModelOption[] { + return modelOption.options.map((option) => ({ + value: option.value, + label: option.name, + description: option.description, + disabled: isRestrictedModelOption(option._meta), + })); +} + +export function getConfigOptionLabel( + options: ReadonlyArray<{ value: string; name: string }>, + value: string | undefined, +): string | undefined { + return options.find((option) => option.value === value)?.name ?? value; +} + +export function resolveAvailableModel( + modelOption: CloudTaskConfigOption, + value: string, +): string { + const selected = modelOption.options.find((option) => option.value === value); + return selected && !isRestrictedModelOption(selected._meta) + ? value + : modelOption.currentValue; +} + +export function resolveComposerModelChange({ + adapter, + modelOption, + requestedModel, + reasoning, +}: { + adapter: Adapter; + modelOption: CloudTaskConfigOption; + requestedModel: string; + reasoning: SupportedReasoningEffort; +}): { model: string; reasoning: SupportedReasoningEffort } { + const model = resolveAvailableModel(modelOption, requestedModel); + return { + model, + reasoning: isSupportedReasoningEffort(adapter, model, reasoning) + ? reasoning + : DEFAULT_REASONING_EFFORT, + }; +} + +export type ComposerPrimaryAction = + | "send" + | "stop" + | "mic" + | "mic-stop" + | "disabled"; + +export function resolveComposerPrimaryAction({ + hasContent, + disabled, + isRecording, + isTranscribing, + canStop, + allowSendWhileRunning, +}: { + hasContent: boolean; + disabled: boolean; + isRecording: boolean; + isTranscribing: boolean; + canStop: boolean; + allowSendWhileRunning: boolean; +}): ComposerPrimaryAction { + if (disabled || isTranscribing) return "disabled"; + if (canStop && (!allowSendWhileRunning || !hasContent)) return "stop"; + if (hasContent && !isRecording) return "send"; + return isRecording ? "mic-stop" : "mic"; +} diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index 06b3956f41..d34dbd9135 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -9,6 +9,7 @@ import { Robot, Spinner, } from "@phosphor-icons/react"; +import { getConfigOptionLabel } from "@posthog/core/task-detail/composerControls"; import { Button, DropdownMenu, @@ -78,8 +79,7 @@ export function UnifiedModelSelector({ }, [selectOption]); const currentValue = selectOption?.currentValue; - const currentLabel = - options.find((opt) => opt.value === currentValue)?.name ?? currentValue; + const currentLabel = getConfigOptionLabel(options, currentValue); const otherAdapter = getOtherAdapter(adapter); From 37f1c0ba9d4125f2ff41da71e43e60a2c8c71bf3 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:23:30 +0300 Subject: [PATCH 16/25] refactor(core): extract composer model policy Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../task-detail/composerModelPolicy.test.ts | 42 +++++++++++++++++++ .../src/task-detail/composerModelPolicy.ts | 35 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/core/src/task-detail/composerModelPolicy.test.ts create mode 100644 packages/core/src/task-detail/composerModelPolicy.ts diff --git a/packages/core/src/task-detail/composerModelPolicy.test.ts b/packages/core/src/task-detail/composerModelPolicy.test.ts new file mode 100644 index 0000000000..235c6199f6 --- /dev/null +++ b/packages/core/src/task-detail/composerModelPolicy.test.ts @@ -0,0 +1,42 @@ +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_GATEWAY_MODEL, + restrictedModelMeta, + type SupportedReasoningEffort, +} from "@posthog/shared"; +import { expect, it } from "vitest"; +import { resolveCloudComposerModelChange } from "./composerModelPolicy"; + +const modelOption: CloudTaskConfigOption = { + id: "model", + name: "Model", + type: "select", + currentValue: DEFAULT_GATEWAY_MODEL, + options: [ + { value: DEFAULT_GATEWAY_MODEL, name: "Claude" }, + { value: "restricted", name: "Restricted", _meta: restrictedModelMeta() }, + { value: "gpt-5.3-codex", name: "Codex" }, + ], + category: "model", + description: "Choose a model", +}; + +it.each([ + ["claude", DEFAULT_GATEWAY_MODEL, "high", DEFAULT_GATEWAY_MODEL, "high"], + ["claude", "restricted", "high", DEFAULT_GATEWAY_MODEL, "high"], + ["claude", "missing", "high", DEFAULT_GATEWAY_MODEL, "high"], + ["codex", "gpt-5.3-codex", "xhigh", "gpt-5.3-codex", "high"], +] as const)( + "resolves %s model %s with %s reasoning", + (adapter, requestedModel, reasoning, expectedModel, expectedReasoning) => { + expect( + resolveCloudComposerModelChange({ + adapter: adapter as Adapter, + modelOption, + requestedModel, + reasoning: reasoning as SupportedReasoningEffort, + }), + ).toEqual({ model: expectedModel, reasoning: expectedReasoning }); + }, +); diff --git a/packages/core/src/task-detail/composerModelPolicy.ts b/packages/core/src/task-detail/composerModelPolicy.ts new file mode 100644 index 0000000000..d9783335f2 --- /dev/null +++ b/packages/core/src/task-detail/composerModelPolicy.ts @@ -0,0 +1,35 @@ +import { + type Adapter, + type CloudTaskConfigOption, + DEFAULT_REASONING_EFFORT, + isRestrictedModelOption, + isSupportedReasoningEffort, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export function resolveCloudComposerModelChange({ + adapter, + modelOption, + requestedModel, + reasoning, +}: { + adapter: Adapter; + modelOption: CloudTaskConfigOption; + requestedModel: string; + reasoning: SupportedReasoningEffort; +}): { model: string; reasoning: SupportedReasoningEffort } { + const selected = modelOption.options.find( + (option) => option.value === requestedModel, + ); + const model = + selected && !isRestrictedModelOption(selected._meta) + ? requestedModel + : modelOption.currentValue; + + return { + model, + reasoning: isSupportedReasoningEffort(adapter, model, reasoning) + ? reasoning + : DEFAULT_REASONING_EFFORT, + }; +} From 0a1c884c84bc23155b1d1e0420da7c00828bad89 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 02:14:00 +0300 Subject: [PATCH 17/25] fix(core): prefer streamed plan content Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/core/src/sessions/planApprovalPresentation.test.ts | 4 ++-- packages/core/src/sessions/planApprovalPresentation.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/sessions/planApprovalPresentation.test.ts b/packages/core/src/sessions/planApprovalPresentation.test.ts index 27f9636189..8519c8a41f 100644 --- a/packages/core/src/sessions/planApprovalPresentation.test.ts +++ b/packages/core/src/sessions/planApprovalPresentation.test.ts @@ -18,12 +18,12 @@ describe("extractPlanText", () => { expect(extractPlanText(toolCall)).toBe(expected); }); - it("prefers the canonical raw plan over rendered content", () => { + it("prefers streamed content over stale raw input", () => { expect( extractPlanText({ rawInput: { plan: "Canonical" }, content: [{ text: "Rendered" }], }), - ).toBe("Canonical"); + ).toBe("Rendered"); }); }); diff --git a/packages/core/src/sessions/planApprovalPresentation.ts b/packages/core/src/sessions/planApprovalPresentation.ts index 2dd43553b6..0817576da7 100644 --- a/packages/core/src/sessions/planApprovalPresentation.ts +++ b/packages/core/src/sessions/planApprovalPresentation.ts @@ -12,12 +12,12 @@ export function extractPlanText(toolCall: { rawInput?: { plan?: unknown } | null; content?: readonly unknown[] | null; }): string | null { - const rawPlan = toolCall.rawInput?.plan; - if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; - for (const item of toolCall.content ?? []) { const text = extractTextContent(item); if (text?.trim()) return text; } + + const rawPlan = toolCall.rawInput?.plan; + if (typeof rawPlan === "string" && rawPlan.trim()) return rawPlan; return null; } From e27b337284941537e9cb5e6ff383aab58a7166b0 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:24:43 +0300 Subject: [PATCH 18/25] refactor(core): extract session presentation semantics Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../core/src/sessions/posthogExecDisplay.ts | 85 +++++++++++ .../src/sessions/thinkingActivities.test.ts | 17 +++ .../core/src/sessions/thinkingActivities.ts | 103 +++++++++++++ .../mcp-apps/components/McpToolView.tsx | 2 +- .../features/permissions/McpPermission.tsx | 4 +- .../utils/posthog-exec-display.test.ts | 4 +- .../posthog-mcp/utils/posthog-exec-display.ts | 136 ------------------ .../components/GeneratingIndicator.tsx | 102 +------------ 8 files changed, 216 insertions(+), 237 deletions(-) create mode 100644 packages/core/src/sessions/posthogExecDisplay.ts create mode 100644 packages/core/src/sessions/thinkingActivities.test.ts create mode 100644 packages/core/src/sessions/thinkingActivities.ts delete mode 100644 packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts diff --git a/packages/core/src/sessions/posthogExecDisplay.ts b/packages/core/src/sessions/posthogExecDisplay.ts new file mode 100644 index 0000000000..5b3029a535 --- /dev/null +++ b/packages/core/src/sessions/posthogExecDisplay.ts @@ -0,0 +1,85 @@ +import { parseMcpToolName } from "@posthog/shared"; + +const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; +const POSTHOG_VERB_RE = + /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; +const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; +const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; + +export interface PostHogExecDisplay { + label: string; + input?: string; +} + +export function isPostHogExecTool(toolName: string): boolean { + const mcp = parseMcpToolName(toolName); + return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); +} + +export function getPostHogExecDisplay( + toolInput: unknown, +): PostHogExecDisplay | null { + if (!toolInput || typeof toolInput !== "object") return null; + const input = toolInput as { command?: unknown; input?: unknown }; + if (typeof input.command !== "string") return null; + const match = input.command.match(POSTHOG_VERB_RE); + if (!match) return null; + const verb = match[1] as "tools" | "search" | "info" | "schema" | "call"; + const rest = (match[2] ?? "").trim(); + const explicitInput = readExplicitInput(input.input); + + switch (verb) { + case "tools": + return { label: "List tools", input: undefined }; + case "search": + return { + label: "Search tools", + input: explicitInput ?? (rest || undefined), + }; + case "info": + return { label: rest ? `Read ${rest}` : "Read tool", input: undefined }; + case "schema": { + const schema = rest.match(POSTHOG_TOOL_NAME_RE); + if (!schema) return { label: "Inspect schema", input: undefined }; + const path = explicitInput ?? ((schema[2] ?? "").trim() || undefined); + return { + label: path + ? `Inspect ${schema[1]}.${path}` + : `Inspect ${schema[1]} fields`, + input: undefined, + }; + } + case "call": { + const call = rest.match(POSTHOG_CALL_BODY_RE); + if (!call) return null; + return { + label: call[1], + input: explicitInput ?? ((call[2] ?? "").trim() || undefined), + }; + } + } +} + +function readExplicitInput(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return value.trim() || undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function formatPosthogExecBody( + input: string | undefined, +): string | undefined { + if (!input) return undefined; + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object") + return JSON.stringify(parsed, null, 2); + } catch { + return input; + } + return input; +} diff --git a/packages/core/src/sessions/thinkingActivities.test.ts b/packages/core/src/sessions/thinkingActivities.test.ts new file mode 100644 index 0000000000..1b1ca94aa6 --- /dev/null +++ b/packages/core/src/sessions/thinkingActivities.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + pickNextThinkingActivity, + pickThinkingActivity, + THINKING_ACTIVITIES, +} from "./thinkingActivities"; + +describe("thinking activities", () => { + it("selects from a bounded random value", () => { + expect(pickThinkingActivity(0)).toBe("Booping"); + expect(pickThinkingActivity(1)).toBe(THINKING_ACTIVITIES.at(-1)); + }); + + it("always advances when the random pick matches the current activity", () => { + expect(pickNextThinkingActivity("Booping", 0)).toBe("Crunching"); + }); +}); diff --git a/packages/core/src/sessions/thinkingActivities.ts b/packages/core/src/sessions/thinkingActivities.ts new file mode 100644 index 0000000000..0046775ffc --- /dev/null +++ b/packages/core/src/sessions/thinkingActivities.ts @@ -0,0 +1,103 @@ +export const THINKING_ACTIVITIES = [ + "Booping", + "Crunching", + "Digging", + "Fetching", + "Inferring", + "Indexing", + "Juggling", + "Noodling", + "Peeking", + "Percolating", + "Poking", + "Pondering", + "Scanning", + "Scrambling", + "Sifting", + "Sniffing", + "Spelunking", + "Tinkering", + "Unraveling", + "Decoding", + "Trekking", + "Sorting", + "Trimming", + "Mulling", + "Surfacing", + "Rummaging", + "Scouting", + "Scouring", + "Threading", + "Hunting", + "Swizzling", + "Grokking", + "Hedging", + "Scheming", + "Unfurling", + "Puzzling", + "Dissecting", + "Stacking", + "Snuffling", + "Hashing", + "Clustering", + "Teasing", + "Cranking", + "Merging", + "Snooping", + "Rewiring", + "Bundling", + "Linking", + "Mapping", + "Tickling", + "Flicking", + "Hopping", + "Rolling", + "Zipping", + "Twisting", + "Blooming", + "Sparking", + "Nesting", + "Looping", + "Wiring", + "Snipping", + "Zoning", + "Tracing", + "Warping", + "Twinkling", + "Flipping", + "Priming", + "Snagging", + "Scuttling", + "Framing", + "Sharpening", + "Flibbertigibbeting", + "Kerfuffling", + "Dithering", + "Discombobulating", + "Rambling", + "Befuddling", + "Waffling", + "Muckling", + "Hobnobbing", + "Galumphing", + "Puttering", + "Whiffling", + "Thinking", +] as const; + +export function pickThinkingActivity(randomValue: number): string { + const bounded = Math.max(0, Math.min(randomValue, 0.999999999999)); + return THINKING_ACTIVITIES[Math.floor(bounded * THINKING_ACTIVITIES.length)]; +} + +export function pickNextThinkingActivity( + current: string, + randomValue: number, +): string { + const picked = pickThinkingActivity(randomValue); + if (picked !== current || THINKING_ACTIVITIES.length <= 1) return picked; + const index = THINKING_ACTIVITIES.indexOf( + current as (typeof THINKING_ACTIVITIES)[number], + ); + return THINKING_ACTIVITIES[(index + 1) % THINKING_ACTIVITIES.length]; +} diff --git a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx index 2a40dc99bc..6e99d80395 100644 --- a/packages/ui/src/features/mcp-apps/components/McpToolView.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx @@ -2,7 +2,7 @@ import { Plugs } from "@phosphor-icons/react"; import { getPostHogExecDisplay, isPostHogExecTool, -} from "../../posthog-mcp/utils/posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; import { useChatThreadChrome } from "../../sessions/components/chat-thread/chatThreadChrome"; import { ToolRow } from "../../sessions/components/session-update/ToolRow"; import { diff --git a/packages/ui/src/features/permissions/McpPermission.tsx b/packages/ui/src/features/permissions/McpPermission.tsx index 6cf59f90cf..cd5ee5961d 100644 --- a/packages/ui/src/features/permissions/McpPermission.tsx +++ b/packages/ui/src/features/permissions/McpPermission.tsx @@ -1,9 +1,9 @@ -import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "@posthog/ui/features/posthog-mcp/utils/posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { mcpToolKey, readMcpToolDescriptor } from "@posthog/shared"; import { formatInput } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Box, Code } from "@radix-ui/themes"; diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts index 08aa1b6f74..5a8603b301 100644 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts +++ b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "./posthog-exec-display"; +} from "@posthog/core/sessions/posthogExecDisplay"; +import { describe, expect, it } from "vitest"; describe("isPostHogExecTool", () => { it("matches the bare posthog exec tool", () => { diff --git a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts deleted file mode 100644 index 50cda82a30..0000000000 --- a/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * The PostHog MCP exposes a single `exec` dispatcher that runs CLI-style - * subcommands. Generic MCP rendering would show this as - * `posthog - exec (MCP) {"command":"call execute-sql {…}"}` — pure plumbing - * with the dispatched action buried inside a JSON wrapper. - * - * These helpers pull the action out of the `command` string so the row can - * read `posthog - execute-sql {…}` (call), `posthog - Read execute-sql` - * (info), `posthog - Inspect query-trends.series` (schema), - * `posthog - Search tools query-` (search), or `posthog - List tools` - * (tools) instead. - * - * Supported verbs (per the `exec` tool description): - * tools — list every tool - * search — search by name/title/description - * info — show description + input schema - * schema [field_path] — drill into a specific field - * call [--json] — invoke a tool - */ - -import { parseMcpToolName } from "@posthog/shared"; - -// A PostHog MCP server name: optional `plugin_` prefix, `posthog`, then any -// number of `_` parts (e.g. `posthog`, `posthog_cloud`, -// `plugin_posthog_posthog`). The `exec` dispatcher lives on these servers. -const POSTHOG_SERVER_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; - -const POSTHOG_VERB_RE = - /^\s*(tools|search|info|schema|call)(?:\s+([\s\S]*))?\s*$/; -const POSTHOG_CALL_BODY_RE = /^(?:--json\s+)?([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; -const POSTHOG_TOOL_NAME_RE = /^([a-zA-Z0-9_-]+)\s*([\s\S]*)$/; - -export interface PostHogExecDisplay { - /** Replaces the tool name in the title — e.g. "execute-sql", "Read execute-sql". */ - label: string; - /** Args to show as the input preview, undefined when there is none to display. */ - input?: string; -} - -export function isPostHogExecTool(toolName: string): boolean { - const mcp = parseMcpToolName(toolName); - return !!mcp && mcp.tool === "exec" && POSTHOG_SERVER_RE.test(mcp.server); -} - -export function getPostHogExecDisplay( - toolInput: unknown, -): PostHogExecDisplay | null { - if (!toolInput || typeof toolInput !== "object") return null; - const obj = toolInput as { command?: unknown; input?: unknown }; - - if (typeof obj.command !== "string") return null; - const verbMatch = obj.command.match(POSTHOG_VERB_RE); - if (!verbMatch) return null; - - const verb = verbMatch[1] as "tools" | "search" | "info" | "schema" | "call"; - const rest = (verbMatch[2] ?? "").trim(); - const explicitInput = readExplicitInput(obj.input); - - switch (verb) { - case "tools": - // `tools` returns names only, not full schemas — "List", not "Read". - return { label: "List tools", input: undefined }; - - case "search": - return { - label: "Search tools", - input: explicitInput ?? (rest.length > 0 ? rest : undefined), - }; - - case "info": - // `info ` — fold the tool name into the label so the args slot stays clean. - return rest.length > 0 - ? { label: `Read ${rest}`, input: undefined } - : { label: "Read tool", input: undefined }; - - case "schema": { - // `schema [field_path]` is the drill-down verb. Fold the - // tool + path into a dotted locator so it reads as one path. - const m = rest.match(POSTHOG_TOOL_NAME_RE); - if (!m) return { label: "Inspect schema", input: undefined }; - const subTool = m[1]; - const fieldPath = (m[2] ?? "").trim(); - const path = - explicitInput ?? (fieldPath.length > 0 ? fieldPath : undefined); - return { - label: path - ? `Inspect ${subTool}.${path}` - : `Inspect ${subTool} fields`, - input: undefined, - }; - } - - case "call": { - // `call [--json] [json_input]` — collapse the verb, surface the - // sub-tool as the label and the JSON body as args. - const m = rest.match(POSTHOG_CALL_BODY_RE); - if (!m) return null; - const subTool = m[1]; - const args = (m[2] ?? "").trim(); - return { - label: subTool, - input: explicitInput ?? (args.length > 0 ? args : undefined), - }; - } - } -} - -function readExplicitInput(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === "string") return value.trim() || undefined; - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} - -/** - * Pretty-prints the unwrapped exec args for display in the permission dialog - * body — JSON payloads (the `call` case) render multi-line; non-JSON args - * (e.g. a `search` regex) pass through unchanged. - */ -export function formatPosthogExecBody( - input: string | undefined, -): string | undefined { - if (!input) return undefined; - try { - const parsed = JSON.parse(input); - if (parsed && typeof parsed === "object") { - return JSON.stringify(parsed, null, 2); - } - } catch { - // not JSON — fall through and show raw - } - return input; -} diff --git a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx index ba2a202e21..47343950d1 100644 --- a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx +++ b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx @@ -1,109 +1,19 @@ import { Brain, Circle } from "@phosphor-icons/react"; +import { + pickNextThinkingActivity, + pickThinkingActivity, +} from "@posthog/core/sessions/thinkingActivities"; import { Flex, Text } from "@radix-ui/themes"; import { useEffect, useRef, useState } from "react"; -const THINKING_MESSAGES = [ - "Booping", - "Crunching", - "Digging", - "Fetching", - "Inferring", - "Indexing", - "Juggling", - "Noodling", - "Peeking", - "Percolating", - "Poking", - "Pondering", - "Scanning", - "Scrambling", - "Sifting", - "Sniffing", - "Spelunking", - "Tinkering", - "Unraveling", - "Decoding", - "Trekking", - "Sorting", - "Trimming", - "Mulling", - "Surfacing", - "Rummaging", - "Scouting", - "Scouring", - "Threading", - "Hunting", - "Swizzling", - "Grokking", - "Hedging", - "Scheming", - "Unfurling", - "Puzzling", - "Dissecting", - "Stacking", - "Snuffling", - "Hashing", - "Clustering", - "Teasing", - "Cranking", - "Merging", - "Snooping", - "Rewiring", - "Bundling", - "Linking", - "Mapping", - "Tickling", - "Flicking", - "Hopping", - "Rolling", - "Zipping", - "Twisting", - "Blooming", - "Sparking", - "Nesting", - "Wiring", - "Snipping", - "Zoning", - "Tracing", - "Warping", - "Twinkling", - "Flipping", - "Priming", - "Snagging", - "Scuttling", - "Framing", - "Sharpening", - "Flibbertigibbeting", - "Kerfuffling", - "Dithering", - "Discombobulating", - "Rambling", - "Befuddling", - "Waffling", - "Muckling", - "Hobnobbing", - "Galumphing", - "Puttering", - "Whiffling", - "Thinking", -]; - function getRandomThinkingMessage(): string { - return THINKING_MESSAGES[ - Math.floor(Math.random() * THINKING_MESSAGES.length) - ]; + return pickThinkingActivity(Math.random()); } /** Pick a new word that differs from the current one, so consecutive changes * always read as a change. */ function getNextThinkingMessage(current: string): string { - if (THINKING_MESSAGES.length <= 1) return THINKING_MESSAGES[0]; - let next = current; - while (next === current) { - next = - THINKING_MESSAGES[Math.floor(Math.random() * THINKING_MESSAGES.length)]; - } - return next; + return pickNextThinkingActivity(current, Math.random()); } export function formatDuration(ms: number, fractionDigits = 2): string { From 04a7f525faf4ce436eb6d14905758c091f64511c Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:25:57 +0300 Subject: [PATCH 19/25] refactor(api-client): expose shared automation contracts Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../api-client/src/posthog-client.test.ts | 27 ++++++ packages/api-client/src/posthog-client.ts | 93 +++++++++++++------ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index da93bc7960..cc25844ea1 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -475,6 +475,33 @@ describe("PostHogAPIClient", () => { ); }); + it.each([true, false])("forwards auto publish %s", async (autoPublish) => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", null, { autoPublish }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.objectContaining({ auto_publish: autoPublish }), + }), + ); + }); + it("rejects unsupported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index b2ef9fa8a8..75bf5183ea 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4,30 +4,22 @@ 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, @@ -210,11 +202,22 @@ export class TaskAutomationValidationError extends Error { 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); + const body = error.body; + if ( + typeof body === "object" && + body !== null && + "detail" in body && + typeof body.detail === "string" + ) { + throw new TaskAutomationValidationError({ + detail: body.detail, + code: + "code" in body && typeof body.code === "string" + ? body.code + : "invalid_input", + attr: + "attr" in body && typeof body.attr === "string" ? body.attr : null, + }); } } @@ -250,6 +253,41 @@ export type { export type Evaluation = Schemas.Evaluation; +export type TaskAutomation = Omit< + Schemas.TaskAutomation, + "github_integration" | "timezone" | "template_id" | "enabled" +> & { + github_integration: number | null; + timezone: string | null; + template_id: string | null; + enabled: boolean; +}; + +export type CreateTaskAutomationOptions = Pick< + Schemas.TaskAutomation, + "name" | "prompt" | "repository" | "cron_expression" +> & + Partial< + Pick< + Schemas.TaskAutomation, + "github_integration" | "template_id" | "enabled" + > + > & { timezone: string }; + +export type UpdateTaskAutomationOptions = Partial; + +function normalizeTaskAutomation( + automation: Schemas.TaskAutomation, +): TaskAutomation { + return { + ...automation, + github_integration: automation.github_integration ?? null, + timezone: automation.timezone ?? null, + template_id: automation.template_id ?? null, + enabled: automation.enabled ?? true, + }; +} + export interface UserGitHubIntegration { id: string; kind: "github"; @@ -787,7 +825,7 @@ function buildCloudRunRequestBody( if (options?.prAuthorshipMode) { body.pr_authorship_mode = options.prAuthorshipMode; } - if (options?.autoPublish) { + if (options?.autoPublish !== undefined) { body.auto_publish = options.autoPublish; } if (options?.rtkEnabled === false) { @@ -2486,7 +2524,7 @@ export class PostHogAPIClient { }, ); - return taskAutomationListSchema.parse(data).results; + return data.results.map(normalizeTaskAutomation); } async getTaskAutomation(automationId: string): Promise { @@ -2498,24 +2536,22 @@ export class PostHogAPIClient { }, ); - return taskAutomationSchema.parse(data); + return normalizeTaskAutomation(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, + body: options as Schemas.TaskAutomation, }, ); - return taskAutomationSchema.parse(data); + return normalizeTaskAutomation(data); } catch (error) { rethrowTaskAutomationError(error); } @@ -2526,17 +2562,15 @@ export class PostHogAPIClient { 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, + body: updates, }, ); - return taskAutomationSchema.parse(data); + return normalizeTaskAutomation(data); } catch (error) { rethrowTaskAutomationError(error); } @@ -2559,7 +2593,9 @@ export class PostHogAPIClient { path, url: new URL(`${this.api.baseUrl}${path}`), }); - return taskAutomationSchema.parse(await response.json()); + return normalizeTaskAutomation( + (await response.json()) as Schemas.TaskAutomation, + ); } catch (error) { rethrowTaskAutomationError(error); } @@ -2604,16 +2640,13 @@ export class PostHogAPIClient { return normalizeTaskResponse(data, { teamId }); } - async updateTask( - taskId: string, - updates: Partial, - ): Promise { + async updateTask(taskId: string, updates: Partial): Promise { const teamId = await this.getTeamId(); const data = await this.api.patch( `/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, - body: updates, + body: updates as unknown as Partial, }, ); From 0b25fcd86abfe0ce4a8b40f4c7fce246786d6292 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:26:21 +0300 Subject: [PATCH 20/25] refactor(core): extract inbox presentation semantics Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- .../automationTemplatePresentation.ts | 37 +++++++++++++++++++ packages/core/src/inbox/engagement.ts | 27 ++++++++++---- packages/core/src/inbox/reportMembership.ts | 17 +++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/automations/automationTemplatePresentation.ts diff --git a/packages/core/src/automations/automationTemplatePresentation.ts b/packages/core/src/automations/automationTemplatePresentation.ts new file mode 100644 index 0000000000..7d73244aa7 --- /dev/null +++ b/packages/core/src/automations/automationTemplatePresentation.ts @@ -0,0 +1,37 @@ +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; + +export const SKILL_TEMPLATE_ID_PREFIX = "llm-skill:"; + +export function formatSkillTemplateId(skillName: string): string { + return `${SKILL_TEMPLATE_ID_PREFIX}${skillName.trim()}`; +} + +export function parseSkillTemplateId( + templateId: string | null | undefined, +): string | null { + if (!templateId?.startsWith(SKILL_TEMPLATE_ID_PREFIX)) return null; + const skillName = templateId.slice(SKILL_TEMPLATE_ID_PREFIX.length).trim(); + return skillName || null; +} + +export interface AutomationTemplatePresentation { + templateName: string | null; + repositoryLabel: string | null; + contextLabel: string | null; + secondaryLabel: string; +} + +export function getAutomationTemplatePresentation( + automation: Pick, +): AutomationTemplatePresentation { + const repositoryLabel = automation.repository.trim() || null; + const skillName = parseSkillTemplateId(automation.template_id); + const contextLabel = skillName ? "Skill store" : null; + return { + templateName: + skillName ?? (automation.template_id ? "Template automation" : null), + repositoryLabel, + contextLabel, + secondaryLabel: repositoryLabel ?? contextLabel ?? "No repository context", + }; +} diff --git a/packages/core/src/inbox/engagement.ts b/packages/core/src/inbox/engagement.ts index dc4428ddbb..012ca18dc4 100644 --- a/packages/core/src/inbox/engagement.ts +++ b/packages/core/src/inbox/engagement.ts @@ -174,13 +174,16 @@ export function buildBulkActionEvents( export interface InboxViewedFilterState { sourceProductFilter: string[]; priorityFilter: string[]; - searchQuery: string; + searchQuery?: string; + statusFilter?: readonly string[]; + defaultStatusFilter?: readonly string[]; + suggestedReviewerFilter?: string[]; /** * True when the reviewer scope is the default ("For you"). False when the * user has narrowed to a teammate or the whole project — treated as an * active filter for `has_active_filters`. */ - isDefaultScope: boolean; + isDefaultScope?: boolean; } export interface BuildInboxViewedInput { @@ -192,7 +195,7 @@ export interface BuildInboxViewedInput { /** Server-reported total of reports matching the active query — the headline inbox number. */ totalCount: number; /** Tab badge counts shown in the v2 header (the numbers the user actually sees). */ - tabCounts: { pulls: number; reports: number }; + tabCounts?: { pulls: number; reports: number }; filters: InboxViewedFilterState; } @@ -207,7 +210,8 @@ export interface BuildInboxViewedInput { export function buildInboxViewedProperties( input: BuildInboxViewedInput, ): InboxViewedProperties { - const { visibleReports, totalCount, tabCounts, filters } = input; + const { visibleReports, totalCount, filters } = input; + const tabCounts = input.tabCounts ?? { pulls: 0, reports: totalCount }; const priorityCounts = { P0: 0, P1: 0, P2: 0, P3: 0, P4: 0, unknown: 0 }; const actionabilityCounts = { @@ -237,11 +241,20 @@ export function buildInboxViewedProperties( } } + const statusFiltered = + filters.statusFilter !== undefined && + filters.defaultStatusFilter !== undefined && + (filters.statusFilter.length !== filters.defaultStatusFilter.length || + filters.statusFilter.some( + (status) => !filters.defaultStatusFilter?.includes(status), + )); const hasActiveFilters = filters.sourceProductFilter.length > 0 || filters.priorityFilter.length > 0 || - filters.searchQuery.trim().length > 0 || - !filters.isDefaultScope; + (filters.searchQuery?.trim().length ?? 0) > 0 || + statusFiltered || + (filters.suggestedReviewerFilter?.length ?? 0) > 0 || + filters.isDefaultScope === false; return { report_count: visibleReports.length, @@ -249,7 +262,7 @@ export function buildInboxViewedProperties( ready_count: readyCount, has_active_filters: hasActiveFilters, source_product_filter: filters.sourceProductFilter, - status_filter_count: 0, + status_filter_count: filters.statusFilter?.length ?? 0, is_empty: totalCount === 0, priority_p0_count: priorityCounts.P0, priority_p1_count: priorityCounts.P1, diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index edfca5f9e8..e31621fba3 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -35,6 +35,23 @@ export function isDismissedReport(report: SignalReport): boolean { return report.status === "suppressed" || report.status === "resolved"; } +export function isRestorableReport( + report: Pick, +): boolean { + return report.status === "suppressed"; +} + +export function getImmediatelyActionableReports( + reports: SignalReport[], +): SignalReport[] { + return reports.filter( + (report) => + report.status === "ready" && + report.actionability === "immediately_actionable" && + !report.already_addressed, + ); +} + export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`; export const INBOX_SCOPE_FOR_YOU: InboxScope = "for-you"; From afa5cb66f1dc49d187bb03c698076d03d639ed48 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:26:59 +0300 Subject: [PATCH 21/25] refactor(core): extract inbox activity presentation Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/core/src/inbox/activityLog.ts | 74 +++++++++++++++++++ .../components/detail/ArtefactLogList.tsx | 11 +-- .../components/detail/ArtefactTaskRun.tsx | 25 ++----- 3 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/inbox/activityLog.ts diff --git a/packages/core/src/inbox/activityLog.ts b/packages/core/src/inbox/activityLog.ts new file mode 100644 index 0000000000..11fb75cab2 --- /dev/null +++ b/packages/core/src/inbox/activityLog.ts @@ -0,0 +1,74 @@ +import type { AnySignalReportArtefact } from "@posthog/shared/domain-types"; + +export type ActivityArtefact = Extract< + AnySignalReportArtefact, + { type: "commit" | "task_run" } +>; + +export function selectActivityArtefacts( + artefacts: AnySignalReportArtefact[], +): ActivityArtefact[] { + return artefacts + .filter( + (artefact): artefact is ActivityArtefact => + artefact.type === "commit" || artefact.type === "task_run", + ) + .sort((left, right) => left.created_at.localeCompare(right.created_at)); +} + +export function shortSha(sha: string): string { + return sha.slice(0, 12); +} + +const SIGNALS_TYPE_LABELS: Record = { + research: "Research", + implementation: "Implementation", + repo_selection: "Repo selection", +}; + +export function humanizeIdentifier(value: string): string { + const spaced = value.replace(/[_-]+/g, " ").trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +export function taskRunLabel(content: { + product: string; + type: string; +}): string { + return content.product === "signals" + ? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)) + : humanizeIdentifier(content.type); +} + +export function attributionLabel(artefact: { + created_by?: { first_name?: string; email: string } | null; + task_id?: string | null; +}): string | null { + if (artefact.created_by) { + return artefact.created_by.first_name?.trim() || artefact.created_by.email; + } + return artefact.task_id ? "agent" : null; +} + +export type DiffLineKind = "add" | "del" | "hunk" | "context"; + +export interface DiffLine { + text: string; + kind: DiffLineKind; +} + +export function parseDiffLines(diff: string): DiffLine[] { + return diff + .replace(/\n$/, "") + .split("\n") + .map((text) => { + if (text.startsWith("+") && !text.startsWith("+++")) { + return { text, kind: "add" as const }; + } + if (text.startsWith("-") && !text.startsWith("---")) { + return { text, kind: "del" as const }; + } + if (text.startsWith("@@")) return { text, kind: "hunk" as const }; + return { text, kind: "context" as const }; + }); +} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx index 9170e124b4..58fed94c23 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -3,6 +3,7 @@ import { CaretDownIcon, CaretRightIcon, } from "@phosphor-icons/react"; +import { attributionLabel } from "@posthog/core/inbox/activityLog"; import type { ActionabilityJudgmentContent, AnySignalReportArtefact, @@ -70,16 +71,6 @@ function languageFromPath(filePath: string): string { * Who produced the artefact: a user's name, "agent" for task-attributed writes, * or null for system (pipeline) writes and pre-attribution rows. */ -function attributionLabel(artefact: AnySignalReportArtefact): string | null { - if (artefact.created_by) { - return artefact.created_by.first_name?.trim() || artefact.created_by.email; - } - if (artefact.task_id) { - return "agent"; - } - return null; -} - // The generic `SignalReportArtefact` fallback carries `type: string`, so it stays // in every narrowed branch and breaks discriminated-union narrowing — the runtime // `type` dispatch is authoritative (content is set alongside type in the diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx index ed07a0d98a..d3fe02b8ed 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx @@ -1,4 +1,8 @@ import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import { + humanizeIdentifier, + taskRunLabel, +} from "@posthog/core/inbox/activityLog"; import type { Task, TaskRunArtefactContent } from "@posthog/shared/types"; import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; @@ -6,21 +10,6 @@ import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { Badge, Box, Text } from "@radix-ui/themes"; import { useState } from "react"; -const SIGNALS_PRODUCT = "signals"; - -// Friendlier labels for the built-in signals-pipeline task types; custom-agent types fall back -// to a humanized form of their identifier. -const SIGNALS_TYPE_LABELS: Record = { - research: "Research", - implementation: "Implementation", - repo_selection: "Repo selection", -}; - -function humanizeIdentifier(value: string): string { - const spaced = value.replace(/[_-]+/g, " ").trim(); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); -} - /** * Renders a `task_run` artefact: loads the referenced task and lets the user * expand it to read the full conversation log (read-only) via `TaskLogsPanel`. @@ -39,10 +28,8 @@ export function ArtefactTaskRun({ ); const task = taskQuery.data; - const isSignals = content.product === SIGNALS_PRODUCT; - const label = isSignals - ? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)) - : humanizeIdentifier(content.type); + const isSignals = content.product === "signals"; + const label = taskRunLabel(content); const status = task?.latest_run?.status; return ( From a7e4e28fda33b6af9fb35043d512a9b9ed399b47 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:27:37 +0300 Subject: [PATCH 22/25] refactor(core): extract portability contracts Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/api-client/src/types.ts | 30 +++++++++++++++++++ packages/core/src/mcp-servers/presentation.ts | 13 ++++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/core/src/mcp-servers/presentation.ts diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index a17f035ad1..0c6146081c 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -8,3 +8,33 @@ export type McpAuthType = Schemas.MCPAuthTypeEnum; export type McpRecommendedServer = Schemas.MCPServerTemplate; export type McpServerInstallation = Schemas.MCPServerInstallation; export type McpInstallationTool = Schemas.MCPServerInstallationTool; +export type McpOAuthRedirectResponse = Schemas.OAuthRedirectResponse; +export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile"; +export type McpInstallResponse = + | McpServerInstallation + | McpOAuthRedirectResponse; + +export interface InstallCustomMcpServerOptions { + name: string; + url: string; + auth_type: McpAuthType; + api_key?: string; + description?: string; + client_id?: string; + client_secret?: string; + install_source?: McpInstallSource; + posthog_code_callback_url?: string; +} + +export interface InstallMcpTemplateOptions { + template_id: string; + api_key?: string; + install_source?: McpInstallSource; + posthog_code_callback_url?: string; +} + +export interface UpdateMcpServerInstallationOptions { + display_name?: string; + description?: string; + is_enabled?: boolean; +} diff --git a/packages/core/src/mcp-servers/presentation.ts b/packages/core/src/mcp-servers/presentation.ts new file mode 100644 index 0000000000..66b3bfd895 --- /dev/null +++ b/packages/core/src/mcp-servers/presentation.ts @@ -0,0 +1,13 @@ +export function isMcpOAuthRedirect( + response: object, +): response is { redirect_url: string } { + return ( + "redirect_url" in response && typeof response.redirect_url === "string" + ); +} + +export function isStdioMcpServer(server: { + transport_type?: string | null; +}): boolean { + return server.transport_type === "stdio"; +} From 1f3ad6a77ca2a4a921c45ed5bbae43d4460dbeac Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:28:04 +0300 Subject: [PATCH 23/25] refactor(ui): reuse inbox identifier formatting Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- packages/ui/src/features/inbox/hooks/useReportTasks.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ui/src/features/inbox/hooks/useReportTasks.ts b/packages/ui/src/features/inbox/hooks/useReportTasks.ts index 3f2093f148..76ba270a13 100644 --- a/packages/ui/src/features/inbox/hooks/useReportTasks.ts +++ b/packages/ui/src/features/inbox/hooks/useReportTasks.ts @@ -1,3 +1,4 @@ +import { humanizeIdentifier } from "@posthog/core/inbox/activityLog"; import type { SignalReportStatus, Task, @@ -19,11 +20,6 @@ export interface ReportTaskData { startedAt: string; } -function humanizeIdentifier(value: string): string { - const words = value.replace(/[_-]+/g, " ").trim(); - return words.charAt(0).toUpperCase() + words.slice(1); -} - function derivePurpose(taskRun: { product: string; type: string; From 0c3e6fb4d458974e060c02c5fe9640a4901a4bc9 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:29:57 +0300 Subject: [PATCH 24/25] refactor(mobile): adopt shared task runtime Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/package.json | 2 + apps/mobile/src/app/automation/[id].tsx | 2 +- apps/mobile/src/app/automation/create.tsx | 2 +- apps/mobile/src/app/task/[id].tsx | 25 +- apps/mobile/src/app/task/index.tsx | 5 +- .../features/chat/components/ToolMessage.tsx | 7 +- apps/mobile/src/features/inbox/api.ts | 3 +- .../inbox/components/EditReviewersSheet.tsx | 5 +- .../inbox/components/ReviewerFilterSheet.tsx | 2 +- .../inbox/components/ReviewerOptionRow.tsx | 5 +- .../inbox/components/SuggestedReviewers.tsx | 10 +- .../features/inbox/components/TinderView.tsx | 5 +- apps/mobile/src/features/inbox/constants.ts | 14 - .../features/inbox/hooks/useInboxReports.ts | 22 +- .../features/inbox/stores/inboxFilterStore.ts | 8 +- apps/mobile/src/features/inbox/utils.test.ts | 242 +---- apps/mobile/src/features/inbox/utils.ts | 160 --- .../components/SwipeableArchivedDrawerRow.tsx | 2 +- .../features/tasks/api.automations.test.ts | 280 ----- apps/mobile/src/features/tasks/api.test.ts | 78 +- apps/mobile/src/features/tasks/api.ts | 718 +------------ .../src/features/tasks/api.warm.test.ts | 187 ---- .../tasks/components/AutomationDetail.tsx | 5 +- .../tasks/components/AutomationForm.tsx | 20 +- .../tasks/components/AutomationItem.tsx | 5 +- .../tasks/components/AutomationList.tsx | 2 +- .../components/AutomationStatusBadge.tsx | 2 +- .../CreateAutomationScreen.test.tsx | 2 +- .../components/CustomImageBadge.test.tsx | 10 +- .../tasks/components/CustomImageBadge.tsx | 2 +- .../components/GitHubConnectionPrompt.tsx | 5 +- .../tasks/components/ScheduleEditor.tsx | 4 +- .../tasks/components/SwipeableTaskItem.tsx | 8 +- .../tasks/components/TaskItem.test.tsx | 2 +- .../features/tasks/components/TaskItem.tsx | 2 +- .../features/tasks/components/TaskList.tsx | 5 +- .../tasks/components/TaskStatusIcon.test.ts | 12 +- .../tasks/components/TaskStatusIcon.tsx | 2 +- .../tasks/components/taskStatusIconKind.ts | 5 +- .../features/tasks/composer/options.test.ts | 27 + .../src/features/tasks/composer/options.ts | 79 +- .../tasks/hooks/useAutomations.test.ts | 24 +- .../features/tasks/hooks/useAutomations.ts | 31 +- .../tasks/hooks/useCustomImageName.ts | 6 +- .../tasks/hooks/useIntegrations.test.ts | 8 +- .../features/tasks/hooks/useIntegrations.ts | 31 +- .../src/features/tasks/hooks/useTasks.test.ts | 15 +- .../src/features/tasks/hooks/useTasks.ts | 33 +- .../tasks/hooks/useUserIntegrations.ts | 13 +- .../features/tasks/hooks/useWarmTask.test.tsx | 4 +- .../src/features/tasks/hooks/useWarmTask.ts | 34 +- apps/mobile/src/features/tasks/index.ts | 3 - .../tasks/lib/cloudTaskStream.test.ts | 54 + .../src/features/tasks/lib/cloudTaskStream.ts | 962 ++---------------- .../src/features/tasks/lib/sseParser.ts | 89 -- .../tasks/stores/taskSessionStore.test.ts | 22 +- .../features/tasks/stores/taskSessionStore.ts | 26 +- .../features/tasks/stores/taskStore.test.ts | 54 - .../src/features/tasks/stores/taskStore.ts | 48 +- apps/mobile/src/features/tasks/types.ts | 206 +--- .../features/tasks/utils/archiveGuard.test.ts | 54 +- .../src/features/tasks/utils/archiveGuard.ts | 6 - .../tasks/utils/automationSchedule.test.ts | 101 -- .../tasks/utils/automationSchedule.ts | 213 ---- .../tasks/utils/automationStatus.test.ts | 16 +- .../features/tasks/utils/automationStatus.ts | 3 +- .../utils/automationTemplatePresentation.ts | 2 +- .../features/tasks/utils/parseSessionLogs.ts | 93 +- .../tasks/utils/sessionActivity.test.ts | 161 --- .../features/tasks/utils/sessionActivity.ts | 133 --- apps/mobile/src/lib/analytics.ts | 10 +- apps/mobile/src/lib/api.ts | 15 +- apps/mobile/src/lib/posthogApiClient.test.ts | 190 ++++ apps/mobile/src/lib/posthogApiClient.ts | 84 ++ pnpm-lock.yaml | 6 + 75 files changed, 854 insertions(+), 3879 deletions(-) delete mode 100644 apps/mobile/src/features/tasks/api.automations.test.ts delete mode 100644 apps/mobile/src/features/tasks/api.warm.test.ts create mode 100644 apps/mobile/src/features/tasks/composer/options.test.ts create mode 100644 apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts delete mode 100644 apps/mobile/src/features/tasks/lib/sseParser.ts delete mode 100644 apps/mobile/src/features/tasks/stores/taskStore.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/automationSchedule.ts delete mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.test.ts delete mode 100644 apps/mobile/src/features/tasks/utils/sessionActivity.ts create mode 100644 apps/mobile/src/lib/posthogApiClient.test.ts create mode 100644 apps/mobile/src/lib/posthogApiClient.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f1057c0146..09ebacf3d0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,8 @@ "@expo/ui": "0.2.0-beta.9", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@posthog/api-client": "workspace:*", + "@posthog/core": "workspace:*", "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", diff --git a/apps/mobile/src/app/automation/[id].tsx b/apps/mobile/src/app/automation/[id].tsx index 5983ecdf28..55f9bd5c6d 100644 --- a/apps/mobile/src/app/automation/[id].tsx +++ b/apps/mobile/src/app/automation/[id].tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useState } from "react"; import { @@ -10,7 +11,6 @@ import { ScrollView, View, } from "react-native"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationDetail } from "@/features/tasks/components/AutomationDetail"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { diff --git a/apps/mobile/src/app/automation/create.tsx b/apps/mobile/src/app/automation/create.tsx index 2233cd688a..1f4d662bff 100644 --- a/apps/mobile/src/app/automation/create.tsx +++ b/apps/mobile/src/app/automation/create.tsx @@ -1,3 +1,4 @@ +import { TaskAutomationValidationError } from "@posthog/api-client/posthog-client"; import { getCalendars } from "expo-localization"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useMemo, useRef, useState } from "react"; @@ -10,7 +11,6 @@ import { View, } from "react-native"; import { Text } from "@/components/text"; -import { TaskAutomationValidationError } from "@/features/tasks/api"; import { AutomationForm } from "@/features/tasks/components/AutomationForm"; import { useCreateTaskAutomation } from "@/features/tasks/hooks/useAutomations"; import { useSkillStoreSkill } from "@/features/tasks/skills/hooks"; diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 889c7811ea..fe73a847f3 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,4 +1,10 @@ import { Text } from "@components/text"; +import { + countUserMessages, + getSessionActivityPhase, +} from "@posthog/core/sessions/sessionActivity"; +import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; +import type { Task } from "@posthog/shared"; import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -14,7 +20,7 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import Animated, { useAnimatedStyle } from "react-native-reanimated"; import { FloatingBackButton } from "@/components/FloatingBackButton"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { getTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge"; import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader"; import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge"; @@ -51,15 +57,7 @@ import { } from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; -import type { Task } from "@/features/tasks/types"; -import { - confirmStopRun, - isTaskRunning, -} from "@/features/tasks/utils/archiveGuard"; -import { - countUserMessages, - getSessionActivityPhase, -} from "@/features/tasks/utils/sessionActivity"; +import { confirmStopRun } from "@/features/tasks/utils/archiveGuard"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { ANALYTICS_EVENTS, @@ -67,6 +65,7 @@ import { useAnalytics, } from "@/lib/analytics"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; const log = logger.scope("task-detail"); @@ -221,7 +220,8 @@ export default function TaskDetailScreen() { setLoading(true); setError(null); - getTask(taskId) + getPostHogApiClient() + .getTask(taskId) .then((fetchedTask) => { if (cancelled) return; setTask(fetchedTask); @@ -252,7 +252,8 @@ export default function TaskDetailScreen() { if (retrying) return; let cancelled = false; - getTask(taskId) + getPostHogApiClient() + .getTask(taskId) .then((freshTask) => { if (cancelled) return; setTask(freshTask); diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 75fe35e96a..012b206dcc 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -30,7 +30,7 @@ import { import Animated, { runOnJS, useAnimatedStyle } from "react-native-reanimated"; import { useVoiceRecording } from "@/features/chat"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { GitHubConnectionPrompt } from "@/features/tasks/components/GitHubConnectionPrompt"; import { GitHubLoadNotice } from "@/features/tasks/components/GitHubLoadNotice"; import { AttachmentSheet } from "@/features/tasks/composer/attachments/AttachmentSheet"; @@ -80,6 +80,7 @@ import { } from "@/features/tasks/utils/repositorySelection"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { toRgba, useThemeColors } from "@/lib/theme"; const log = logger.scope("task-create"); @@ -308,7 +309,7 @@ export default function NewTaskScreen() { ? `Attached: ${attachments[0].fileName}` : `Attached ${attachments.length} files`); - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ description: descriptionText, title: descriptionText.slice(0, 100), repository: selection.repository ?? undefined, diff --git a/apps/mobile/src/features/chat/components/ToolMessage.tsx b/apps/mobile/src/features/chat/components/ToolMessage.tsx index 479849a51b..3859504777 100644 --- a/apps/mobile/src/features/chat/components/ToolMessage.tsx +++ b/apps/mobile/src/features/chat/components/ToolMessage.tsx @@ -659,9 +659,12 @@ function CreateTaskPreview({ try { // Dynamic import to avoid circular dependency - const { createTask, runTaskInCloud } = await import("../../tasks/api"); + const [{ runTaskInCloud }, { getPostHogApiClient }] = await Promise.all([ + import("../../tasks/api"), + import("@/lib/posthogApiClient"), + ]); - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ title: args.title, description: args.description, repository: args.repository, diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index ce7bbeec0e..9b37b82725 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -1,5 +1,4 @@ -import { HttpError } from "@/features/tasks/api"; -import { authedFetch, getBaseUrl, getProjectId } from "@/lib/api"; +import { authedFetch, getBaseUrl, getProjectId, HttpError } from "@/lib/api"; import { logger } from "@/lib/logger"; import type { DismissalReasonOptionValue } from "./constants"; diff --git a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx index 2aec45975d..2e30695576 100644 --- a/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx +++ b/apps/mobile/src/features/inbox/components/EditReviewersSheet.tsx @@ -1,4 +1,8 @@ import { Text } from "@components/text"; +import { + buildReviewerOptions, + reviewerMatchesAvailable, +} from "@posthog/core/inbox/artefacts"; import { MagnifyingGlass } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -14,7 +18,6 @@ import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import type { AvailableSuggestedReviewer, SuggestedReviewer } from "../types"; -import { buildReviewerOptions, reviewerMatchesAvailable } from "../utils"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface EditReviewersSheetProps { diff --git a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx index d07faaffa1..44ac730daf 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerFilterSheet.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import { buildReviewerOptions } from "@posthog/core/inbox/artefacts"; import { useMemo } from "react"; import { ActivityIndicator, @@ -12,7 +13,6 @@ import { useScreenInsets } from "@/hooks/useScreenInsets"; import { useThemeColors } from "@/lib/theme"; import { useAvailableSuggestedReviewers } from "../hooks/useInboxReports"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; -import { buildReviewerOptions } from "../utils"; import { ReviewerOptionRow } from "./ReviewerOptionRow"; interface ReviewerFilterSheetProps { diff --git a/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx b/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx index a6f9c5c265..0c21699177 100644 --- a/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx +++ b/apps/mobile/src/features/inbox/components/ReviewerOptionRow.tsx @@ -1,8 +1,11 @@ import { Text } from "@components/text"; +import { + type ReviewerOption, + reviewerOptionLabel, +} from "@posthog/core/inbox/artefacts"; import { Check } from "phosphor-react-native"; import { Image, Pressable, View } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import { type ReviewerOption, reviewerOptionLabel } from "../utils"; interface ReviewerOptionRowProps { reviewer: ReviewerOption; diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx index 0536f1b364..d0eab2e25f 100644 --- a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -1,4 +1,9 @@ import { Text } from "@components/text"; +import { + orderSuggestedReviewers, + reviewerMatchesAvailable, + toSuggestedReviewerWriteContent, +} from "@posthog/core/inbox/artefacts"; import { Eye, Plus, X } from "phosphor-react-native"; import { useMemo, useState } from "react"; import { @@ -20,11 +25,6 @@ import type { SuggestedReviewer, SuggestedReviewersArtefact, } from "../types"; -import { - orderSuggestedReviewers, - reviewerMatchesAvailable, - toSuggestedReviewerWriteContent, -} from "../utils"; import { EditReviewersSheet } from "./EditReviewersSheet"; export type ReviewerActionExtra = Pick< diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index cc034d6ceb..a8c99da1e4 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -17,7 +17,7 @@ import { } from "react-native-safe-area-context"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { createTask, runTaskInCloud } from "@/features/tasks/api"; +import { runTaskInCloud } from "@/features/tasks/api"; import { DEFAULT_MODEL } from "@/features/tasks/composer/options"; import type { CreateTaskOptions, @@ -29,6 +29,7 @@ import { useAnalytics, } from "@/lib/analytics"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; import { getReportRepository } from "../api"; import { useDismissedReportsStore } from "../stores/dismissedReportsStore"; @@ -239,7 +240,7 @@ export function TinderView({ // 3. Create the task const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; - const task = await createTask({ + const task = await getPostHogApiClient().createTask({ description: prompt, title: prompt.slice(0, 255), repository: match?.repository ?? repo ?? undefined, diff --git a/apps/mobile/src/features/inbox/constants.ts b/apps/mobile/src/features/inbox/constants.ts index 201d15982f..f15aca7c5b 100644 --- a/apps/mobile/src/features/inbox/constants.ts +++ b/apps/mobile/src/features/inbox/constants.ts @@ -1,17 +1,3 @@ -/** Comma-separated statuses for the inbox pipeline (excludes terminal/deleted). */ -export const INBOX_PIPELINE_STATUS_FILTER = - "potential,candidate,in_progress,ready,pending_input"; - -/** - * Status filter for the Archive view — the two terminal, not-in-inbox states: - * `suppressed` (user archived it; restorable) and `resolved` (its - * implementation PR merged; terminal, reference only). - */ -export const INBOX_DISMISSED_STATUS_FILTER = "suppressed,resolved"; - -/** Polling interval for inbox queries (ms). */ -export const INBOX_REFETCH_INTERVAL_MS = 5_000; - /** * Reasons offered when the user dismisses a signal report. * Mirrors apps/code/src/shared/dismissalReasons.ts. diff --git a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts index 25f6e0eb40..ac37c738d1 100644 --- a/apps/mobile/src/features/inbox/hooks/useInboxReports.ts +++ b/apps/mobile/src/features/inbox/hooks/useInboxReports.ts @@ -1,3 +1,12 @@ +import { + buildArchiveListOrdering, + buildPriorityFilterParam, + buildSignalReportListOrdering, + buildStatusFilterParam, + buildSuggestedReviewerFilterParam, + INBOX_DISMISSED_STATUS_FILTER, + INBOX_REFETCH_INTERVAL_MS, +} from "@posthog/core/inbox/reportFiltering"; import { useInfiniteQuery, useMutation, @@ -19,10 +28,6 @@ import { restoreSignalReport, updateSignalReportArtefact, } from "../api"; -import { - INBOX_DISMISSED_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, -} from "../constants"; import { useInboxFilterStore } from "../stores/inboxFilterStore"; import type { AvailableSuggestedReviewersResponse, @@ -36,14 +41,7 @@ import type { SuggestedReviewer, SuggestedReviewerWriteEntry, } from "../types"; -import { - buildArchiveListOrdering, - buildPriorityFilterParam, - buildSignalReportListOrdering, - buildStatusFilterParam, - buildSuggestedReviewerFilterParam, - isRestorableReport, -} from "../utils"; +import { isRestorableReport } from "../utils"; export const inboxKeys = { all: ["inbox", "signal-reports"] as const, diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index 97c16ccc3f..a0536417da 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -1,3 +1,4 @@ +import { INBOX_PIPELINE_STATUSES } from "@posthog/core/inbox/reportFiltering"; import type { SourceProduct } from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; @@ -18,12 +19,7 @@ type SortDirection = "asc" | "desc"; export type { SourceProduct }; export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", + ...INBOX_PIPELINE_STATUSES, ]; interface InboxFilterState { diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index 8c1d70a32e..ad56b19305 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,25 +1,11 @@ import { describe, expect, it } from "vitest"; -import type { - AvailableSuggestedReviewer, - Signal, - SignalReport, - SignalReportOrderingField, - SignalReportStatus, - SuggestedReviewer, -} from "./types"; +import type { Signal, SignalReport, SignalReportStatus } from "./types"; import { - buildArchiveListOrdering, buildInboxViewedProperties, - buildPriorityFilterParam, - buildReviewerOptions, - buildSignalReportListOrdering, dismissalReasonLabel, formatSignalReportSummaryMarkdown, isRestorableReport, - orderSuggestedReviewers, - reviewerMatchesAvailable, sourceLine, - toSuggestedReviewerWriteContent, } from "./utils"; function signal(source_product: string, source_type: string): Signal { @@ -35,84 +21,6 @@ function signal(source_product: string, source_type: string): Signal { }; } -function reviewer(login: string, uuid?: string): SuggestedReviewer { - return { - github_login: login, - github_name: login, - relevant_commits: [], - user: uuid - ? { - id: 1, - uuid, - email: `${login}@posthog.com`, - first_name: login, - last_name: "", - } - : null, - }; -} - -describe("orderSuggestedReviewers", () => { - it("moves the current user to the front", () => { - const reviewers = [ - reviewer("a", "uuid-a"), - reviewer("me", "uuid-me"), - reviewer("c", "uuid-c"), - ]; - const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); - expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); - }); - - it.each([ - { - label: "already first", - reviewers: [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "absent", - reviewers: [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "null meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: null as string | null | undefined, - }, - { - label: "undefined meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: undefined as string | null | undefined, - }, - ])("is a no-op when $label", ({ reviewers, meUuid }) => { - expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); - }); -}); - -function makeReviewer( - partial: Partial = {}, -): SuggestedReviewer { - return { - github_login: "octocat", - github_name: "The Octocat", - relevant_commits: [], - user: null, - ...partial, - }; -} - -function makeAvailable( - partial: Partial = {}, -): AvailableSuggestedReviewer { - return { - uuid: "uuid-1", - name: "Ada Lovelace", - email: "ada@example.com", - github_login: "ada", - ...partial, - }; -} - const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", "pending_input", @@ -299,139 +207,6 @@ describe("buildInboxViewedProperties", () => { }); }); -describe("toSuggestedReviewerWriteContent", () => { - it.each([ - { - name: "prefers github_login so the server preserves commits/name", - reviewer: makeReviewer({ - github_login: "ada", - user: { id: 1, uuid: "u1", email: "", first_name: "", last_name: "" }, - }), - expected: [{ github_login: "ada" }], - }, - { - name: "falls back to user_uuid when there is no github_login", - reviewer: makeReviewer({ - github_login: "", - user: { id: 1, uuid: "u1", email: "", first_name: "", last_name: "" }, - }), - expected: [{ user_uuid: "u1" }], - }, - { - name: "drops entries with neither a login nor a resolved user", - reviewer: makeReviewer({ github_login: "", user: null }), - expected: [], - }, - ])("$name", ({ reviewer, expected }) => { - expect(toSuggestedReviewerWriteContent([reviewer])).toEqual(expected); - }); -}); - -describe("reviewerMatchesAvailable", () => { - it.each([ - { - name: "matches on user uuid", - reviewer: makeReviewer({ - github_login: "", - user: { - id: 1, - uuid: "uuid-1", - email: "", - first_name: "", - last_name: "", - }, - }), - expected: true, - }, - { - name: "matches on case-insensitive github login", - reviewer: makeReviewer({ github_login: "ADA", user: null }), - expected: true, - }, - { - name: "does not match different people", - reviewer: makeReviewer({ github_login: "octocat", user: null }), - expected: false, - }, - ])("$name", ({ reviewer, expected }) => { - expect(reviewerMatchesAvailable(reviewer, makeAvailable())).toBe(expected); - }); -}); - -describe("buildSignalReportListOrdering", () => { - it.each([ - { - field: "priority" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-priority,-created_at", - }, - { - field: "priority" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,priority,-created_at", - }, - { - field: "signal_count" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-signal_count", - }, - { - field: "total_weight" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,total_weight", - }, - { - field: "created_at" as SignalReportOrderingField, - direction: "desc" as const, - expected: "status,-is_suggested_reviewer,-created_at", - }, - { - field: "updated_at" as SignalReportOrderingField, - direction: "asc" as const, - expected: "status,-is_suggested_reviewer,updated_at", - }, - ])( - "orders $field $direction as $expected", - ({ field, direction, expected }) => { - expect(buildSignalReportListOrdering(field, direction)).toBe(expected); - }, - ); -}); - -describe("buildPriorityFilterParam", () => { - it.each([ - { - name: "returns undefined for an empty selection", - input: [], - expected: undefined, - }, - { - name: "joins selected priorities with commas", - input: ["P0", "P2"] as const, - expected: "P0,P2", - }, - { - name: "dedupes repeated priorities", - input: ["P1", "P1", "P3"] as const, - expected: "P1,P3", - }, - ])("$name", ({ input, expected }) => { - expect(buildPriorityFilterParam([...input])).toBe(expected); - }); -}); - -describe("buildArchiveListOrdering", () => { - it.each([ - { direction: "desc" as const, expected: "-updated_at" }, - { direction: "asc" as const, expected: "updated_at" }, - ])( - "sorts by field without a status prefix ($direction)", - ({ direction, expected }) => { - expect(buildArchiveListOrdering("updated_at", direction)).toBe(expected); - }, - ); -}); - describe("isRestorableReport", () => { it.each([ { status: "suppressed" as SignalReportStatus, expected: true }, @@ -470,18 +245,3 @@ describe("sourceLine", () => { expect(sourceLine(signal(product, type))).toBe(expected); }); }); - -describe("buildReviewerOptions", () => { - it("dedupes by uuid and pins the current user first", () => { - const options = buildReviewerOptions( - [ - makeAvailable({ uuid: "b", name: "Bob" }), - makeAvailable({ uuid: "a", name: "Ada" }), - makeAvailable({ uuid: "a", name: "Ada (dupe)" }), - ], - "b", - ); - expect(options.map((o) => o.uuid)).toEqual(["b", "a"]); - expect(options[0].isMe).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 52ffb73972..b0040ddcef 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -6,14 +6,10 @@ import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import type { InboxViewedProperties } from "@/lib/analytics"; import { DISMISSAL_REASON_OPTIONS } from "./constants"; import type { - AvailableSuggestedReviewer, Signal, SignalReport, - SignalReportOrderingField, SignalReportPriority, SignalReportStatus, - SuggestedReviewer, - SuggestedReviewerWriteEntry, } from "./types"; const ERROR_TRACKING_TYPE_LABELS: Record = { @@ -129,77 +125,6 @@ export function inboxStatusLabel(status: SignalReportStatus): string { } } -/** - * Build comma-separated `ordering` param for the API: - * 1. Status rank (ready first) - * 2. Suggested reviewer (current user first) - * 3. User-selected field - * - * Priority is a coarse 5-bucket rank, so ties are broken by newest first. - */ -export function buildSignalReportListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - const fieldKey = direction === "desc" ? `-${field}` : field; - const tiebreak = field === "priority" ? ",-created_at" : ""; - return `status,-is_suggested_reviewer,${fieldKey}${tiebreak}`; -} - -/** - * Ordering for the Archive view, which lists two terminal statuses - * (`suppressed` + `resolved`). Unlike the pipeline ordering, it must not prefix - * with `status`: that would group one terminal state ahead of the other before - * the time sort, burying recent items behind older ones from the sibling status. - */ -export function buildArchiveListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - return direction === "desc" ? `-${field}` : field; -} - -/** - * Build a comma-separated status filter string for the API. - */ -export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { - return statuses.join(","); -} - -/** - * Build a comma-separated suggested reviewer filter for the API. - */ -export function buildSuggestedReviewerFilterParam( - reviewerIds: string[], -): string | undefined { - const normalized = reviewerIds.map((id) => id.trim()).filter(Boolean); - if (normalized.length === 0) return undefined; - return Array.from(new Set(normalized)).join(","); -} - -export function buildPriorityFilterParam( - priorities: SignalReportPriority[], -): string | undefined { - if (priorities.length === 0) return undefined; - return Array.from(new Set(priorities)).join(","); -} - -export function filterReportsBySearch( - reports: SignalReport[], - query: string, -): SignalReport[] { - const trimmed = query.trim(); - if (!trimmed) return reports; - - const lower = trimmed.toLowerCase(); - return reports.filter( - (report) => - report.title?.toLowerCase().includes(lower) || - report.summary?.toLowerCase().includes(lower) || - report.id.toLowerCase().includes(lower), - ); -} - /** * Returns only reports that are actionable for the tinder-like card deck: * ready, immediately actionable, not already addressed. @@ -213,91 +138,6 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { ); } -export function orderSuggestedReviewers( - reviewers: SuggestedReviewer[], - meUuid: string | null | undefined, -): SuggestedReviewer[] { - if (!meUuid) return reviewers; - const meIndex = reviewers.findIndex((r) => r.user?.uuid === meUuid); - if (meIndex <= 0) return reviewers; - return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; -} - -export interface ReviewerOption { - uuid: string; - name: string; - email: string; - github_login: string; - isMe: boolean; -} - -/** Deduplicate the available-reviewers list by uuid and sort "Me" first, then by name. */ -export function buildReviewerOptions( - reviewers: AvailableSuggestedReviewer[], - currentUserUuid: string | undefined, -): ReviewerOption[] { - const seen = new Set(); - const options: ReviewerOption[] = []; - - for (const r of reviewers) { - if (!r.uuid || seen.has(r.uuid)) continue; - seen.add(r.uuid); - options.push({ - uuid: r.uuid, - name: r.name?.trim() || "", - email: r.email?.trim() || "", - github_login: r.github_login?.trim() || "", - isMe: r.uuid === currentUserUuid, - }); - } - - options.sort((a, b) => { - if (a.isMe && !b.isMe) return -1; - if (!a.isMe && b.isMe) return 1; - return (a.name || a.email).localeCompare(b.name || b.email); - }); - - return options; -} - -export function reviewerOptionLabel(r: ReviewerOption): string { - const base = r.name || r.email || "Unknown user"; - return r.isMe ? `${base} (Me)` : base; -} - -/** A reviewer in the artefact matches an org member by user uuid or (case-insensitive) login. */ -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() - ); -} - -/** - * Build the full-replacement write payload from a read-shape list. Kept reviewers - * are sent by `github_login` so the server preserves their commits/name; an entry - * with only a resolved user falls back to `user_uuid`. Entries with neither are - * dropped. - */ -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); -} - interface InboxViewedFilterState { sourceProductFilter: string[]; statusFilter: SignalReportStatus[]; diff --git a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx index 4bb2583879..6e942a2688 100644 --- a/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx +++ b/apps/mobile/src/features/navigation/components/SwipeableArchivedDrawerRow.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; @@ -11,7 +12,6 @@ import { View, } from "react-native"; import { TaskStatusIcon } from "@/features/tasks/components/TaskStatusIcon"; -import type { Task } from "@/features/tasks/types"; import { useThemeColors } from "@/lib/theme"; const SWIPE_THRESHOLD = 60; diff --git a/apps/mobile/src/features/tasks/api.automations.test.ts b/apps/mobile/src/features/tasks/api.automations.test.ts deleted file mode 100644 index c4390a5d14..0000000000 --- a/apps/mobile/src/features/tasks/api.automations.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { - createTaskAutomation, - deleteTaskAutomation, - getTaskAutomation, - getTaskAutomations, - runTaskAutomation, - TaskAutomationValidationError, - updateTaskAutomation, -} from "./api"; - -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:shared-daily-brief", - enabled: true, - last_run_at: null, - last_run_status: null, - last_task_id: "task-1", - last_task_run_id: null, - last_error: null, - created_at: "2026-05-13T00:00:00Z", - updated_at: "2026-05-13T00:00:00Z", -}; - -describe("task automation api", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("lists task automations from the existing backend endpoint", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - results: [automationPayload], - }), - { status: 200 }, - ), - ); - - const automations = await getTaskAutomations(); - - expect(automations).toHaveLength(1); - expect(automations[0]?.id).toBe("automation-1"); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/?limit=500", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - }), - }), - ); - }); - - it("serializes automation creation payloads with the existing backend contract", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ); - - await createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 9 * * *", - timezone: "Europe/London", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "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", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - }), - ); - }); - - it("serializes skill-backed automation payloads with a prefixed template id", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - ...automationPayload, - id: "automation-2", - name: "Shared daily brief", - template_id: "llm-skill:shared-daily-brief", - }), - { status: 200 }, - ), - ); - - await createTaskAutomation({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/task_automations/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "posthog/posthog", - github_integration: 7, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - }), - ); - }); - - it("retains backend field attribution for validation errors", async () => { - mockFetch.mockImplementation(() => - Promise.resolve( - new Response( - JSON.stringify({ - type: "validation_error", - code: "invalid_input", - detail: - "Only standard 5-field cron expressions are supported (minute hour day month weekday). Example: '0 9 * * 1-5'.", - attr: "cron_expression", - }), - { status: 400, statusText: "Bad Request" }, - ), - ), - ); - - await expect( - createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - cron_expression: "not a cron", - timezone: "Europe/London", - }), - ).rejects.toBeInstanceOf(TaskAutomationValidationError); - - await expect( - createTaskAutomation({ - name: "Daily PRs", - prompt: "Check PRs", - repository: "posthog/posthog", - cron_expression: "not a cron", - timezone: "Europe/London", - }), - ).rejects.toMatchObject({ - attr: "cron_expression", - code: "invalid_input", - }); - }); - - it("surfaces skill-backed validation failures without losing backend attr info", async () => { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - type: "validation_error", - code: "invalid_input", - detail: "Repository is still required for this template.", - attr: "repository", - }), - { status: 400, statusText: "Bad Request" }, - ), - ); - - await expect( - createTaskAutomation({ - name: "Shared daily brief", - prompt: "Summarize feature usage for my product areas.", - repository: "", - github_integration: null, - cron_expression: "0 8 * * 1-5", - timezone: "America/New_York", - enabled: true, - template_id: "llm-skill:shared-daily-brief", - }), - ).rejects.toMatchObject({ - attr: "repository", - code: "invalid_input", - message: "Repository is still required for this template.", - }); - }); - - it("supports retrieve, update, delete, and run-now automation flows", async () => { - mockFetch - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ) - .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce( - new Response(JSON.stringify(automationPayload), { status: 200 }), - ); - - const retrieved = await getTaskAutomation("automation-1"); - const updated = await updateTaskAutomation("automation-1", { - enabled: false, - cron_expression: "30 14 * * *", - }); - await deleteTaskAutomation("automation-1"); - const ran = await runTaskAutomation("automation-1"); - - expect(retrieved.id).toBe("automation-1"); - expect(updated.id).toBe("automation-1"); - expect(ran.id).toBe("automation-1"); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/", - expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ - enabled: false, - cron_expression: "30 14 * * *", - }), - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 3, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/", - expect.objectContaining({ - method: "DELETE", - }), - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 4, - "https://app.posthog.test/api/projects/42/task_automations/automation-1/run/", - expect.objectContaining({ - method: "POST", - }), - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/api.test.ts b/apps/mobile/src/features/tasks/api.test.ts index ab54e96253..bcb3f5c1f0 100644 --- a/apps/mobile/src/features/tasks/api.test.ts +++ b/apps/mobile/src/features/tasks/api.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockFetch } = vi.hoisted(() => ({ +const { mockFetch, mockRunTaskInCloud } = vi.hoisted(() => ({ mockFetch: vi.fn(), + mockRunTaskInCloud: vi.fn(), })); vi.mock("expo/fetch", () => ({ @@ -9,6 +10,16 @@ vi.mock("expo/fetch", () => ({ })); vi.mock("@/lib/api", () => ({ + HttpError: class HttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } + }, getBaseUrl: () => "https://app.posthog.test", getProjectId: () => 42, getAccessToken: () => "token", @@ -24,6 +35,10 @@ vi.mock("@/lib/api", () => ({ }), })); +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ runTaskInCloud: mockRunTaskInCloud }), +})); + import { cancelRun, HttpError, @@ -38,10 +53,8 @@ function bodyOf(call: unknown): Record { describe("runTaskInCloud", () => { beforeEach(() => { - mockFetch.mockReset(); - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ id: "task-1" }), { status: 200 }), - ); + mockRunTaskInCloud.mockReset(); + mockRunTaskInCloud.mockResolvedValue({ id: "task-1" }); }); it.each([true, false])( @@ -49,23 +62,28 @@ describe("runTaskInCloud", () => { async (flag) => { await runTaskInCloud("task-1", { autoPublish: flag }); - expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ - auto_publish: flag, - }); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ autoPublish: flag }), + ); }, ); it("omits auto_publish when not provided", async () => { await runTaskInCloud("task-1", { model: "claude-opus-4-8" }); - expect(bodyOf(mockFetch.mock.calls[0])).not.toHaveProperty("auto_publish"); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ autoPublish: undefined }), + ); }); it("sends no body for the plain initial run", async () => { await runTaskInCloud("task-1"); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect(init.body).toBeUndefined(); + expect(mockRunTaskInCloud).toHaveBeenCalledWith("task-1"); }); it("forwards the selected sandbox environment and custom image", async () => { @@ -74,10 +92,14 @@ describe("runTaskInCloud", () => { customImageId: "image-123", }); - expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ - sandbox_environment_id: "environment-123", - custom_image_id: "image-123", - }); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ + sandboxEnvironmentId: "environment-123", + customImageId: "image-123", + }), + ); }); it("omits the sandbox environment and custom image when unset", async () => { @@ -87,24 +109,34 @@ describe("runTaskInCloud", () => { customImageId: null, }); - const body = bodyOf(mockFetch.mock.calls[0]); - expect(body).not.toHaveProperty("sandbox_environment_id"); - expect(body).not.toHaveProperty("custom_image_id"); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ + sandboxEnvironmentId: undefined, + customImageId: undefined, + }), + ); }); it("sends rtk_enabled=false when the run opts out", async () => { await runTaskInCloud("task-1", { rtkEnabled: false }); - expect(bodyOf(mockFetch.mock.calls[0])).toMatchObject({ - rtk_enabled: false, - }); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ rtkEnabled: false }), + ); }); it("omits rtk_enabled when the run keeps compression on", async () => { await runTaskInCloud("task-1", { rtkEnabled: true }); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect(init.body).toBeUndefined(); + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "task-1", + undefined, + expect.objectContaining({ rtkEnabled: true }), + ); }); }); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index f7d03d1056..f50a155246 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -1,8 +1,10 @@ -import type { Adapter } from "@posthog/shared"; import type { - SandboxCustomImage, - SandboxEnvironment, -} from "@posthog/shared/domain-types"; + Adapter, + ExecutionMode, + StoredLogEntry, + Task, + TaskRun, +} from "@posthog/shared"; import { fetch } from "expo/fetch"; import { authedFetch, @@ -10,75 +12,11 @@ import { getAccessToken, getBaseUrl, getProjectId, + HttpError, } from "@/lib/api"; -import { logger } from "@/lib/logger"; -import type { - CreateTaskAutomationOptions, - CreateTaskOptions, - Integration, - StoredLogEntry, - Task, - TaskAutomation, - TaskRun, - UpdateTaskAutomationOptions, - UserGithubIntegration, -} from "./types"; - -const log = logger.scope("tasks-api"); - -export class HttpError extends Error { - readonly status: number; - - constructor(status: number, statusText: string, prefix: string) { - super(`${prefix}: ${status} ${statusText}`); - this.name = "HttpError"; - this.status = status; - } -} - -export class TaskAutomationValidationError extends Error { - readonly code: string; - readonly attr: string | null; - - constructor(message: string, code: string, attr: string | null) { - super(message); - this.name = "TaskAutomationValidationError"; - this.code = code; - this.attr = attr; - } -} - -async function parseJsonResponse(response: Response): Promise { - return (await response.json()) as T; -} +import { getPostHogApiClient } from "@/lib/posthogApiClient"; -async function parseTaskAutomationError(response: Response): Promise { - let payload: { - code?: string; - detail?: string; - attr?: string; - } | null = null; - - try { - payload = await response.json(); - } catch { - payload = null; - } - - if (response.status === 400 && payload?.detail) { - throw new TaskAutomationValidationError( - payload.detail, - payload.code ?? "invalid_input", - payload.attr ?? null, - ); - } - - throw new HttpError( - response.status, - response.statusText, - "Task automation request failed", - ); -} +export { HttpError } from "@/lib/api"; async function withRetry( fn: () => Promise, @@ -125,309 +63,6 @@ function isRetryableError(error: unknown): boolean { return false; } -export async function getTasks(filters?: { - repository?: string; - createdBy?: number; - originProduct?: string; -}): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const params = new URLSearchParams({ limit: "500" }); - if (filters?.repository) { - params.set("repository", filters.repository); - } - if (filters?.createdBy) { - params.set("created_by", String(filters.createdBy)); - } - if (filters?.originProduct) { - params.set("origin_product", filters.originProduct); - } - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch tasks", - ); - } - - const data = await parseJsonResponse<{ results?: Task[] }>(response); - return data.results ?? []; -} - -export async function getTask(taskId: string): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task", - ); - } - - return await parseJsonResponse(response); -} - -export async function getTaskAutomations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/?limit=500`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task automations", - ); - } - - const data = await parseJsonResponse<{ results?: TaskAutomation[] }>( - response, - ); - return data.results ?? []; -} - -export async function getTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch task automation", - ); - } - - return await parseJsonResponse(response); -} - -export async function createTaskAutomation( - options: CreateTaskAutomationOptions, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/`, - { - method: "POST", - body: JSON.stringify(options), - }, - ); - - if (!response.ok) { - await parseTaskAutomationError(response); - } - - return await parseJsonResponse(response); -} - -export async function updateTaskAutomation( - automationId: string, - updates: UpdateTaskAutomationOptions, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { - method: "PATCH", - body: JSON.stringify(updates), - }, - ); - - if (!response.ok) { - await parseTaskAutomationError(response); - } - - return await parseJsonResponse(response); -} - -export async function deleteTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/`, - { method: "DELETE" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to delete task automation", - ); - } -} - -export async function runTaskAutomation( - automationId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/task_automations/${automationId}/run/`, - { method: "POST" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to run task automation", - ); - } - - return await parseJsonResponse(response); -} - -export async function warmTask(options: { - repository: string; - github_integration: number; - branch?: string | null; - runtime_adapter?: string | null; - model?: string | null; - reasoning_effort?: string | null; - sandbox_environment_id?: string | null; - custom_image_id?: string | null; -}): Promise<{ task_id: string; run_id: string } | null> { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/warm/`, - { - method: "POST", - body: JSON.stringify({ - repository: options.repository, - github_integration: options.github_integration, - branch: options.branch ?? null, - runtime_adapter: options.runtime_adapter ?? null, - model: options.model ?? null, - reasoning_effort: options.reasoning_effort ?? null, - ...(options.sandbox_environment_id - ? { sandbox_environment_id: options.sandbox_environment_id } - : {}), - ...(options.custom_image_id - ? { custom_image_id: options.custom_image_id } - : {}), - }), - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to warm task", - ); - } - - const text = await response.text(); - if (!text) { - return null; - } - return JSON.parse(text) as { task_id: string; run_id: string }; -} - -export async function createTask(options: CreateTaskOptions): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/`, - { - method: "POST", - body: JSON.stringify({ - origin_product: "user_created", - ...options, - }), - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - log.error("Create task error", errorText); - throw new HttpError( - response.status, - `${response.statusText} - ${errorText}`, - "Failed to create task", - ); - } - - return await parseJsonResponse(response); -} - -export async function updateTask( - taskId: string, - updates: Partial, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { - method: "PATCH", - body: JSON.stringify(updates), - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to update task", - ); - } - - return await parseJsonResponse(response); -} - -export async function deleteTask(taskId: string): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/`, - { method: "DELETE" }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to delete task", - ); - } -} - export interface RunTaskInCloudOptions { branch?: string | null; resumeFromRunId?: string; @@ -439,10 +74,6 @@ export interface RunTaskInCloudOptions { model?: string; /** Reasoning effort: "low" | "medium" | "high" (model-dependent). */ reasoningEffort?: string; - /** Sandbox environment / custom base image to run on. Sent so a reused warm - * sandbox matches the selection instead of a mismatched default. */ - sandboxEnvironmentId?: string | null; - customImageId?: string | null; /** Permission mode: "default" | "acceptEdits" | "plan" | "auto". */ initialPermissionMode?: string; /** Source that triggered this run. */ @@ -454,92 +85,34 @@ export interface RunTaskInCloudOptions { autoPublish?: boolean; /** Only false is sent: opts the run out of rtk command-output compression. */ rtkEnabled?: boolean; + sandboxEnvironmentId?: string | null; + customImageId?: string | null; } export async function runTaskInCloud( taskId: string, options?: RunTaskInCloudOptions, ): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - // Only serialize a body when we have options to send. Sending an empty - // or minimal body on the initial run historically changed backend - // behavior, so we preserve the "no body" path for the common case. - const hasOptions = - !!options && - (options.branch !== undefined || - options.resumeFromRunId !== undefined || - options.pendingUserMessage !== undefined || - options.mode !== undefined || - options.runtimeAdapter !== undefined || - options.model !== undefined || - options.reasoningEffort !== undefined || - options.sandboxEnvironmentId !== undefined || - options.customImageId !== undefined || - options.initialPermissionMode !== undefined || - options.runSource !== undefined || - options.signalReportId !== undefined || - options.autoPublish !== undefined || - options.rtkEnabled === false); - - let body: string | undefined; - if (hasOptions) { - const payload: Record = { - mode: options?.mode ?? "interactive", - }; - if (options?.branch) payload.branch = options.branch; - if (options?.resumeFromRunId) { - payload.resume_from_run_id = options.resumeFromRunId; - } - if (options?.pendingUserMessage) { - payload.pending_user_message = options.pendingUserMessage; - } - if (options?.runtimeAdapter) { - payload.runtime_adapter = options.runtimeAdapter; - if (options?.model) payload.model = options.model; - if (options?.reasoningEffort) { - payload.reasoning_effort = options.reasoningEffort; - } - } - if (options?.sandboxEnvironmentId) { - payload.sandbox_environment_id = options.sandboxEnvironmentId; - } - if (options?.customImageId) { - payload.custom_image_id = options.customImageId; - } - if (options?.initialPermissionMode) { - payload.initial_permission_mode = options.initialPermissionMode; - } - if (options?.runSource) payload.run_source = options.runSource; - if (options?.signalReportId) - payload.signal_report_id = options.signalReportId; - if (options?.autoPublish !== undefined) { - payload.auto_publish = options.autoPublish; - } - if (options?.rtkEnabled === false) { - payload.rtk_enabled = false; - } - body = JSON.stringify(payload); - } - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/run/`, - { - method: "POST", - body, - }, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to run task", - ); - } - - return await response.json(); + if (!options) { + return getPostHogApiClient().runTaskInCloud(taskId); + } + + return getPostHogApiClient().runTaskInCloud(taskId, options.branch, { + adapter: options.runtimeAdapter, + model: options.model, + reasoningLevel: options.reasoningEffort, + initialPermissionMode: options.initialPermissionMode as + | ExecutionMode + | undefined, + runSource: options.runSource, + signalReportId: options.signalReportId, + autoPublish: options.autoPublish, + rtkEnabled: options.rtkEnabled, + sandboxEnvironmentId: options.sandboxEnvironmentId ?? undefined, + customImageId: options.customImageId ?? undefined, + resumeFromRunId: options.resumeFromRunId, + pendingUserMessage: options.pendingUserMessage, + }); } export async function getTaskRun( @@ -834,232 +407,3 @@ export async function streamCloudTask( signal: options.signal, }); } - -export async function getSandboxCustomImages(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch sandbox custom images", - ); - } - - const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>( - response, - ); - return data.results ?? []; -} - -export async function getSandboxEnvironments(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/sandbox_environments/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch sandbox environments", - ); - } - - const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>( - response, - ); - return data.results ?? []; -} - -export async function getIntegrations(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/environments/${projectId}/integrations/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch integrations", - ); - } - - const data = await parseJsonResponse< - | { - results?: Integration[]; - } - | Integration[] - >(response); - return Array.isArray(data) ? data : (data.results ?? []); -} - -const GITHUB_REPOS_PAGE_SIZE = 500; - -export async function getGithubRepositories( - integrationId: number, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const allRepos: string[] = []; - let offset = 0; - - while (true) { - const params = new URLSearchParams({ - limit: String(GITHUB_REPOS_PAGE_SIZE), - offset: String(offset), - }); - const response = await authedFetch( - `${baseUrl}/api/environments/${projectId}/integrations/${integrationId}/github_repos/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch repositories", - ); - } - - const data = await response.json(); - const repos: Array = - data.repositories ?? data.results ?? data ?? []; - - const normalized = repos - .map((repo) => { - if (typeof repo === "string") return repo.toLowerCase(); - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }) - .filter((name) => name.length > 0); - - allRepos.push(...normalized); - - if (!data.has_more || repos.length === 0) { - return allRepos; - } - - offset += repos.length; - } -} - -export interface GithubUserConnectResult { - install_url: string; - connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; -} - -/** - * Starts the user-scoped GitHub connection flow (mirrors desktop). The backend - * picks the lightweight OAuth flow when the team already has the GitHub App - * installed, otherwise a discover/install flow, and returns the URL to open. - * - * `connect_from: "posthog_mobile"` tells the backend to redirect the OAuth - * callback to `posthog://github/callback` so the in-app browser auto-closes. - */ -export async function startGithubUserIntegrationConnect(): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/github/start/`, - { - method: "POST", - body: JSON.stringify({ - team_id: projectId, - connect_from: "posthog_mobile", - }), - }, - ); - - if (!response.ok) { - const payload = (await response.json().catch(() => ({}))) as { - detail?: unknown; - }; - const detail = - typeof payload.detail === "string" - ? payload.detail - : "Failed to start GitHub connection"; - throw new HttpError(response.status, response.statusText, detail); - } - - return parseJsonResponse(response); -} - -export async function getUserGithubIntegrations(): Promise< - UserGithubIntegration[] -> { - const baseUrl = getBaseUrl(); - - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/?kind=github`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch personal GitHub integrations", - ); - } - - const data = await parseJsonResponse<{ results?: UserGithubIntegration[] }>( - response, - ); - return data.results ?? []; -} - -export async function getUserGithubRepositories( - installationId: string, -): Promise { - const baseUrl = getBaseUrl(); - - const allRepos: string[] = []; - let offset = 0; - - while (true) { - const params = new URLSearchParams({ - limit: String(GITHUB_REPOS_PAGE_SIZE), - offset: String(offset), - }); - const response = await authedFetch( - `${baseUrl}/api/users/@me/integrations/github/${installationId}/repos/?${params}`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch repositories", - ); - } - - const data = await response.json(); - const repos: Array = - data.repositories ?? data.results ?? data ?? []; - - const normalized = repos - .map((repo) => { - if (typeof repo === "string") return repo.toLowerCase(); - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }) - .filter((name) => name.length > 0); - - allRepos.push(...normalized); - - if (!data.has_more || repos.length === 0) { - return allRepos; - } - - offset += repos.length; - } -} diff --git a/apps/mobile/src/features/tasks/api.warm.test.ts b/apps/mobile/src/features/tasks/api.warm.test.ts deleted file mode 100644 index 06c9d1aba0..0000000000 --- a/apps/mobile/src/features/tasks/api.warm.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockFetch } = vi.hoisted(() => ({ - mockFetch: vi.fn(), -})); - -vi.mock("expo/fetch", () => ({ - fetch: mockFetch, -})); - -vi.mock("@/lib/api", () => ({ - getBaseUrl: () => "https://app.posthog.test", - getProjectId: () => 42, - authedFetch: (url: string, init?: RequestInit) => - mockFetch(url, { - ...init, - headers: { - Authorization: "Bearer token", - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - }, - }), -})); - -import { HttpError, warmTask } from "./api"; - -describe("warmTask", () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - it("posts the warm request with the backend contract", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - const result = await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - }); - - expect(result).toEqual({ task_id: "task-1", run_id: "run-1" }); - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: null, - model: null, - reasoning_effort: null, - }), - }), - ); - }); - - it("forwards the selected runtime, model, and reasoning effort", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: "claude", - model: "claude-opus-4-8", - reasoning_effort: "high", - }), - }), - ); - }); - - it("forwards the selected sandbox environment and custom image", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - sandbox_environment_id: "environment-123", - custom_image_id: "image-123", - }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: "main", - runtime_adapter: null, - model: null, - reasoning_effort: null, - sandbox_environment_id: "environment-123", - custom_image_id: "image-123", - }), - }), - ); - }); - - it("omits the sandbox environment and custom image when unset", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - sandbox_environment_id: null, - custom_image_id: null, - }); - - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - const body = JSON.parse(init.body as string); - expect(body).not.toHaveProperty("sandbox_environment_id"); - expect(body).not.toHaveProperty("custom_image_id"); - }); - - it("serializes a missing branch as null", async () => { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify({ task_id: "task-1", run_id: "run-1" }), { - status: 200, - }), - ); - - await warmTask({ repository: "posthog/posthog", github_integration: 7 }); - - expect(mockFetch).toHaveBeenCalledWith( - "https://app.posthog.test/api/projects/42/tasks/warm/", - expect.objectContaining({ - body: JSON.stringify({ - repository: "posthog/posthog", - github_integration: 7, - branch: null, - runtime_adapter: null, - model: null, - reasoning_effort: null, - }), - }), - ); - }); - - it("returns null when the response body is empty", async () => { - mockFetch.mockResolvedValueOnce(new Response("", { status: 200 })); - - const result = await warmTask({ - repository: "posthog/posthog", - github_integration: 7, - }); - - expect(result).toBeNull(); - }); - - it("throws an HttpError on a failed response", async () => { - mockFetch.mockResolvedValueOnce(new Response("nope", { status: 500 })); - - await expect( - warmTask({ repository: "posthog/posthog", github_integration: 7 }), - ).rejects.toBeInstanceOf(HttpError); - }); -}); diff --git a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx index 6838b40761..b560293b6b 100644 --- a/apps/mobile/src/features/tasks/components/AutomationDetail.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationDetail.tsx @@ -1,7 +1,8 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; +import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule"; +import type { TaskRun } from "@posthog/shared"; import { ActivityIndicator, Pressable, View } from "react-native"; -import type { TaskAutomation, TaskRun } from "../types"; -import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationForm.tsx b/apps/mobile/src/features/tasks/components/AutomationForm.tsx index d6f076ef82..56387f474d 100644 --- a/apps/mobile/src/features/tasks/components/AutomationForm.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationForm.tsx @@ -1,4 +1,12 @@ import { Text } from "@components/text"; +import type { CreateTaskAutomationOptions } from "@posthog/api-client/posthog-client"; +import { + type AutomationScheduleDraft, + buildCronExpression, + createDefaultScheduleDraft, + deriveAutomationName, + parseCronExpression, +} from "@posthog/core/automations/automationSchedule"; import { CaretDown, GithubLogo } from "phosphor-react-native"; import { type MutableRefObject, useEffect, useMemo, useState } from "react"; import { @@ -12,17 +20,7 @@ import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { useThemeColors } from "@/lib/theme"; import { RepositoryPickerInline } from "../composer/RepositoryPickerInline"; import { useIntegrations } from "../hooks/useIntegrations"; -import type { - CreateTaskAutomationOptions, - RepositorySelection, -} from "../types"; -import { - type AutomationScheduleDraft, - buildCronExpression, - createDefaultScheduleDraft, - deriveAutomationName, - parseCronExpression, -} from "../utils/automationSchedule"; +import type { RepositorySelection } from "../types"; import { findRepositoryOption, isRepositorySelectionComplete, diff --git a/apps/mobile/src/features/tasks/components/AutomationItem.tsx b/apps/mobile/src/features/tasks/components/AutomationItem.tsx index 5ce8a7fe80..89d4f4d5de 100644 --- a/apps/mobile/src/features/tasks/components/AutomationItem.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationItem.tsx @@ -1,9 +1,10 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; +import { formatAutomationScheduleSummary } from "@posthog/core/automations/automationSchedule"; +import type { TaskRun } from "@posthog/shared"; import { format, formatDistanceToNow } from "date-fns"; import { memo } from "react"; import { Pressable, View } from "react-native"; -import type { TaskAutomation, TaskRun } from "../types"; -import { formatAutomationScheduleSummary } from "../utils/automationSchedule"; import { getAutomationTemplatePresentation } from "../utils/automationTemplatePresentation"; import { AutomationStatusBadge } from "./AutomationStatusBadge"; diff --git a/apps/mobile/src/features/tasks/components/AutomationList.tsx b/apps/mobile/src/features/tasks/components/AutomationList.tsx index 31a5821962..1027a5aa46 100644 --- a/apps/mobile/src/features/tasks/components/AutomationList.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationList.tsx @@ -1,4 +1,5 @@ import { Text } from "@components/text"; +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; import { Plus } from "phosphor-react-native"; import { ActivityIndicator, @@ -10,7 +11,6 @@ import { import { useThemeColors } from "@/lib/theme"; import { useAutomations } from "../hooks/useAutomations"; import { useTasks } from "../hooks/useTasks"; -import type { TaskAutomation } from "../types"; import { AutomationItem } from "./AutomationItem"; interface AutomationListProps { diff --git a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx index 970de00d64..600b36302e 100644 --- a/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx +++ b/apps/mobile/src/features/tasks/components/AutomationStatusBadge.tsx @@ -1,6 +1,6 @@ import { Text } from "@components/text"; +import type { TaskRun } from "@posthog/shared"; import { View } from "react-native"; -import type { TaskRun } from "../types"; import { getAutomationStatusPresentation } from "../utils/automationStatus"; interface AutomationStatusBadgeProps { diff --git a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx index 29934cada1..87fb997014 100644 --- a/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx +++ b/apps/mobile/src/features/tasks/components/CreateAutomationScreen.test.tsx @@ -60,7 +60,7 @@ vi.mock("@/features/tasks/components/AutomationForm", () => ({ createElement("AutomationForm", props), })); -vi.mock("@/features/tasks/api", () => ({ +vi.mock("@posthog/api-client/posthog-client", () => ({ TaskAutomationValidationError: class TaskAutomationValidationError extends Error { code: string; attr: string | null; diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx index 09b0531b6c..cc6d38b3bd 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx @@ -1,8 +1,8 @@ +import type { Task, TaskRun } from "@posthog/shared"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Task, TaskRun } from "../types"; const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( () => ({ @@ -14,9 +14,11 @@ const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted( vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore })); -vi.mock("../api", () => ({ - getSandboxCustomImages: mockGetImages, - getSandboxEnvironments: mockGetEnvironments, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + listSandboxCustomImages: mockGetImages, + listSandboxEnvironments: mockGetEnvironments, + }), })); vi.mock("phosphor-react-native", () => ({ diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx index 2e382c228b..13bf4e2d4a 100644 --- a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx +++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx @@ -1,9 +1,9 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { Cube } from "phosphor-react-native"; import { View } from "react-native"; import { toRgba } from "@/lib/theme"; import { useCustomImageName } from "../hooks/useCustomImageName"; -import type { Task } from "../types"; // Theme tokens have no violet; a fixed Radix violet-9 mirrors the desktop // custom-image badge and reads well in both light and dark. diff --git a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx index 296c8ead4e..0da19fca21 100644 --- a/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx +++ b/apps/mobile/src/features/tasks/components/GitHubConnectionPrompt.tsx @@ -3,8 +3,8 @@ import * as WebBrowser from "expo-web-browser"; import { Pressable, View } from "react-native"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useThemeColors } from "@/lib/theme"; -import { startGithubUserIntegrationConnect } from "../api"; const log = logger.scope("github-connection-prompt"); @@ -44,7 +44,8 @@ export function GitHubConnectionPrompt({ // and, because we pass `connect_from: "posthog_mobile"`, redirects the // callback to `posthog://github/callback` so this in-app browser closes. try { - const { install_url } = await startGithubUserIntegrationConnect(); + const { install_url } = + await getPostHogApiClient().startGithubUserIntegrationConnect(); authorizeUrl = install_url; } catch (error) { log.error("Failed to start GitHub connection", { error }); diff --git a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx index 102e607e87..726973a639 100644 --- a/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx +++ b/apps/mobile/src/features/tasks/components/ScheduleEditor.tsx @@ -1,5 +1,4 @@ import { Text } from "@components/text"; -import { Pressable, TextInput, View } from "react-native"; import { type AutomationScheduleDraft, type AutomationScheduleMode, @@ -7,7 +6,8 @@ import { sanitizeHour, sanitizeMinute, WEEKDAY_OPTIONS, -} from "../utils/automationSchedule"; +} from "@posthog/core/automations/automationSchedule"; +import { Pressable, TextInput, View } from "react-native"; interface ScheduleEditorProps { value: AutomationScheduleDraft; diff --git a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx index a65b4c2576..64bd68eb9a 100644 --- a/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/SwipeableTaskItem.tsx @@ -1,3 +1,5 @@ +import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, ArrowCounterClockwise } from "phosphor-react-native"; import { useEffect, useRef } from "react"; @@ -10,11 +12,7 @@ import { View, } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; -import { - confirmArchiveRunningTask, - isTaskRunning, -} from "../utils/archiveGuard"; +import { confirmArchiveRunningTask } from "../utils/archiveGuard"; import { TaskItem } from "./TaskItem"; const SWIPE_THRESHOLD = 60; diff --git a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx index d62cfd7665..38cac02b57 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx @@ -1,7 +1,7 @@ +import type { Task } from "@posthog/shared"; import { createElement } from "react"; import { act, create } from "react-test-renderer"; import { describe, expect, it, vi } from "vitest"; -import type { Task } from "../types"; import { TaskItem } from "./TaskItem"; vi.mock("phosphor-react-native", () => ({ diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index e99bdfb768..c235722b37 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,11 +1,11 @@ import { Text } from "@components/text"; +import type { Task } from "@posthog/shared"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; import { Check, GitPullRequest } from "phosphor-react-native"; import { memo } from "react"; import { Linking, Pressable, View } from "react-native"; import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { TaskStatusIcon } from "./TaskStatusIcon"; function PrBadge({ prUrl, number }: { prUrl: string; number: number }) { diff --git a/apps/mobile/src/features/tasks/components/TaskList.tsx b/apps/mobile/src/features/tasks/components/TaskList.tsx index 403d9c6836..e2a5a7e11a 100644 --- a/apps/mobile/src/features/tasks/components/TaskList.tsx +++ b/apps/mobile/src/features/tasks/components/TaskList.tsx @@ -1,4 +1,6 @@ import { Text } from "@components/text"; +import { taskActivityTimestamp } from "@posthog/core/tasks/taskActivity"; +import type { Task } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { Archive, GitBranch, Plus, Sparkle, X } from "phosphor-react-native"; import { useCallback, useMemo, useState } from "react"; @@ -13,8 +15,7 @@ import { useThemeColors } from "@/lib/theme"; import { useTasks } from "../hooks/useTasks"; import { useUserIntegrations } from "../hooks/useUserIntegrations"; import { useArchivedTasksStore } from "../stores/archivedTasksStore"; -import { taskActivityTimestamp, useTaskStore } from "../stores/taskStore"; -import type { Task } from "../types"; +import { useTaskStore } from "../stores/taskStore"; import { GitHubConnectionPrompt } from "./GitHubConnectionPrompt"; import { GitHubLoadNotice } from "./GitHubLoadNotice"; import { SwipeableTaskItem } from "./SwipeableTaskItem"; diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts index 080e0f1ac9..92e99ef8af 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.test.ts @@ -1,5 +1,5 @@ +import type { Task } from "@posthog/shared"; import { describe, expect, it } from "vitest"; -import type { Task } from "../types"; import { getTaskStatusIconKind } from "./taskStatusIconKind"; function makeTask(latestRun?: Partial>): Task { @@ -51,25 +51,16 @@ describe("getTaskStatusIconKind", () => { makeTask({ environment: "cloud", status: "queued" }), ), ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "in_progress" }), ), ).toBe("chat"); - - expect( - getTaskStatusIconKind( - makeTask({ environment: "cloud", status: "started" }), - ), - ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "completed" }), ), ).toBe("chat"); - expect( getTaskStatusIconKind( makeTask({ environment: "cloud", status: "cancelled" }), @@ -83,7 +74,6 @@ describe("getTaskStatusIconKind", () => { makeTask({ environment: "local", status: "in_progress" }), ), ).toBe("running"); - expect( getTaskStatusIconKind( makeTask({ environment: "local", status: "failed" }), diff --git a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx index 06c992f047..8736b203b0 100644 --- a/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx +++ b/apps/mobile/src/features/tasks/components/TaskStatusIcon.tsx @@ -1,3 +1,4 @@ +import type { Task } from "@posthog/shared"; import { ChatCircle, CheckCircle, @@ -9,7 +10,6 @@ import { import { memo, useEffect, useRef } from "react"; import { Animated, Easing } from "react-native"; import { useThemeColors } from "@/lib/theme"; -import type { Task } from "../types"; import { getTaskStatusIconKind } from "./taskStatusIconKind"; interface TaskStatusIconProps { diff --git a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts index fa7fbcd357..0fb172132b 100644 --- a/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts +++ b/apps/mobile/src/features/tasks/components/taskStatusIconKind.ts @@ -1,4 +1,4 @@ -import type { Task } from "../types"; +import type { Task } from "@posthog/shared"; export type TaskStatusIconKind = | "pr" @@ -13,7 +13,6 @@ export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { const status = task.latest_run?.status; const environment = task.latest_run?.environment; - // Match desktop semantics, but let PR win when a cloud task also has one. if (prUrl) { return "pr"; } @@ -34,7 +33,7 @@ export function getTaskStatusIconKind(task: Task): TaskStatusIconKind { return "running"; } - if (status === "queued" || status === "started") { + if (status === "queued") { return "started"; } diff --git a/apps/mobile/src/features/tasks/composer/options.test.ts b/apps/mobile/src/features/tasks/composer/options.test.ts new file mode 100644 index 0000000000..75328ebf04 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/options.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL, + DEFAULT_REASONING, + modelSupportsReasoning, + REASONING_LEVELS, +} from "./options"; + +describe("task composer options", () => { + it("uses an eligible non-premium default model", () => { + expect(DEFAULT_MODEL).toBe("claude-opus-4-8"); + expect(DEFAULT_MODEL).not.toContain("fable"); + }); + + it("derives reasoning defaults and options from shared policy", () => { + expect(DEFAULT_REASONING).toBe("high"); + expect(REASONING_LEVELS.map((option) => option.value)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(modelSupportsReasoning("claude-opus-4-8")).toBe(true); + expect(modelSupportsReasoning("claude-haiku-4-5")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/options.ts b/apps/mobile/src/features/tasks/composer/options.ts index be0b48cdd3..572fff4a35 100644 --- a/apps/mobile/src/features/tasks/composer/options.ts +++ b/apps/mobile/src/features/tasks/composer/options.ts @@ -1,32 +1,39 @@ -export type ExecutionMode = "default" | "acceptEdits" | "plan" | "auto"; -export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; +import { + DEFAULT_CLAUDE_EXECUTION_MODE, + getAvailableModes, +} from "@posthog/core/sessions/executionModes"; +import { + DEFAULT_GATEWAY_MODEL, + DEFAULT_REASONING_EFFORT, + defaultEligibleModel, + getReasoningEffortOptions, + type ExecutionMode as SharedExecutionMode, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export type ExecutionMode = Extract< + SharedExecutionMode, + "default" | "acceptEdits" | "plan" | "auto" +>; +export type ReasoningEffort = SupportedReasoningEffort; export const EXECUTION_MODES: { value: ExecutionMode; label: string; description: string; -}[] = [ - { - value: "plan", - label: "Plan Mode", - description: "Plan first, no tool execution", - }, - { - value: "default", - label: "Default", - description: "Standard behaviour, prompts for dangerous operations", - }, - { - value: "acceptEdits", - label: "Accept Edits", - description: "Auto-accept file edit operations", - }, - { - value: "auto", - label: "Auto", - description: "Model decides which prompts to approve or deny", - }, -]; +}[] = getAvailableModes() + .filter( + (mode): mode is typeof mode & { id: ExecutionMode } => + mode.id === "default" || + mode.id === "acceptEdits" || + mode.id === "plan" || + mode.id === "auto", + ) + .map((mode) => ({ + value: mode.id, + label: mode.name, + description: mode.description, + })); export interface ModelOption { value: string; @@ -68,20 +75,20 @@ export const MODELS: ModelOption[] = [ }, ]; +export const DEFAULT_EXECUTION_MODE: ExecutionMode = + DEFAULT_CLAUDE_EXECUTION_MODE; +export const DEFAULT_MODEL = + defaultEligibleModel(DEFAULT_GATEWAY_MODEL) ?? + MODELS.find((model) => defaultEligibleModel(model.value))?.value ?? + DEFAULT_GATEWAY_MODEL; +export const DEFAULT_REASONING: ReasoningEffort = DEFAULT_REASONING_EFFORT; + export const REASONING_LEVELS: { value: ReasoningEffort; label: string; -}[] = [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, -]; - -export const DEFAULT_EXECUTION_MODE: ExecutionMode = "plan"; -export const DEFAULT_MODEL = "claude-opus-4-8"; -export const DEFAULT_REASONING: ReasoningEffort = "high"; +}[] = (getReasoningEffortOptions("claude", DEFAULT_MODEL) ?? []).map( + (option) => ({ value: option.value, label: option.name }), +); export function modelLabel(value: string): string { return MODELS.find((m) => m.value === value)?.label ?? value; @@ -96,5 +103,5 @@ export function reasoningLabel(value: ReasoningEffort): string { } export function modelSupportsReasoning(value: string): boolean { - return MODELS.find((m) => m.value === value)?.supportsReasoning ?? false; + return getReasoningEffortOptions("claude", value) !== null; } diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts index 93c4cf1dc0..772d9a4f69 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.test.ts @@ -8,26 +8,36 @@ const { mockGetTaskAutomations, mockCreateTaskAutomation, mockUpdateTaskAutomation, + mockApiClient, } = vi.hoisted(() => ({ mockUseAuthStore: vi.fn(), mockGetTaskAutomations: vi.fn(), mockCreateTaskAutomation: vi.fn(), mockUpdateTaskAutomation: vi.fn(), + mockApiClient: { + listTaskAutomations: vi.fn(), + getTaskAutomation: vi.fn(), + createTaskAutomation: vi.fn(), + updateTaskAutomation: vi.fn(), + deleteTaskAutomation: vi.fn(), + runTaskAutomation: vi.fn(), + }, })); vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ - getTaskAutomations: mockGetTaskAutomations, - getTaskAutomation: vi.fn(), - createTaskAutomation: mockCreateTaskAutomation, - updateTaskAutomation: mockUpdateTaskAutomation, - deleteTaskAutomation: vi.fn(), - runTaskAutomation: vi.fn(), +vi.mock("../api", () => ({ runTaskInCloud: vi.fn() })); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => mockApiClient, })); +mockApiClient.listTaskAutomations = mockGetTaskAutomations; +mockApiClient.createTaskAutomation = mockCreateTaskAutomation; +mockApiClient.updateTaskAutomation = mockUpdateTaskAutomation; + import { automationKeys, getAutomationPollingInterval, diff --git a/apps/mobile/src/features/tasks/hooks/useAutomations.ts b/apps/mobile/src/features/tasks/hooks/useAutomations.ts index e22d3e6d16..e6db7c11bd 100644 --- a/apps/mobile/src/features/tasks/hooks/useAutomations.ts +++ b/apps/mobile/src/features/tasks/hooks/useAutomations.ts @@ -1,19 +1,12 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth"; -import { logger } from "@/lib/logger"; -import { - createTaskAutomation, - deleteTaskAutomation, - getTaskAutomation, - getTaskAutomations, - runTaskAutomation, - updateTaskAutomation, -} from "../api"; import type { CreateTaskAutomationOptions, TaskAutomation, UpdateTaskAutomationOptions, -} from "../types"; +} from "@posthog/api-client/posthog-client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/features/auth"; +import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { taskKeys } from "./useTasks"; const log = logger.scope("automations-mutations"); @@ -59,7 +52,7 @@ export function useAutomations() { const query = useQuery({ queryKey: automationKeys.list(), - queryFn: getTaskAutomations, + queryFn: () => getPostHogApiClient().listTaskAutomations(), enabled: !!projectId && !!oauthAccessToken, refetchInterval: (query) => getAutomationPollingInterval( @@ -80,7 +73,7 @@ export function useAutomation(automationId: string) { return useQuery({ queryKey: automationKeys.detail(automationId), - queryFn: () => getTaskAutomation(automationId), + queryFn: () => getPostHogApiClient().getTaskAutomation(automationId), enabled: !!projectId && !!oauthAccessToken && !!automationId, refetchInterval: (query) => getAutomationPollingInterval( @@ -94,7 +87,7 @@ export function useCreateTaskAutomation() { return useMutation({ mutationFn: (options: CreateTaskAutomationOptions) => - createTaskAutomation(options), + getPostHogApiClient().createTaskAutomation(options), onSuccess: (automation) => { queryClient.setQueryData( automationKeys.detail(automation.id), @@ -118,7 +111,7 @@ export function useUpdateTaskAutomation() { }: { automationId: string; updates: UpdateTaskAutomationOptions; - }) => updateTaskAutomation(automationId, updates), + }) => getPostHogApiClient().updateTaskAutomation(automationId, updates), onSuccess: (automation, { automationId }) => { queryClient.setQueryData( automationKeys.detail(automationId), @@ -136,7 +129,8 @@ export function useDeleteTaskAutomation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (automationId: string) => deleteTaskAutomation(automationId), + mutationFn: (automationId: string) => + getPostHogApiClient().deleteTaskAutomation(automationId), onSuccess: (_, automationId) => { queryClient.removeQueries({ queryKey: automationKeys.detail(automationId), @@ -153,7 +147,8 @@ export function useRunTaskAutomation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (automationId: string) => runTaskAutomation(automationId), + mutationFn: (automationId: string) => + getPostHogApiClient().runTaskAutomation(automationId), onSuccess: (automation, automationId) => { queryClient.setQueryData( automationKeys.detail(automationId), diff --git a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts index a634ecd5e6..a8bf916ce8 100644 --- a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts +++ b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; -import { getSandboxCustomImages, getSandboxEnvironments } from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; export const sandboxKeys = { customImages: () => ["sandbox-custom-images"] as const, @@ -26,7 +26,7 @@ export function useCustomImageName({ const imagesQuery = useQuery({ queryKey: sandboxKeys.customImages(), - queryFn: getSandboxCustomImages, + queryFn: () => getPostHogApiClient().listSandboxCustomImages(), enabled: canQuery && hasImageRef, staleTime: 60_000, retry: 0, @@ -34,7 +34,7 @@ export function useCustomImageName({ const environmentsQuery = useQuery({ queryKey: sandboxKeys.environments(), - queryFn: getSandboxEnvironments, + queryFn: () => getPostHogApiClient().listSandboxEnvironments(), enabled: canQuery && !!sandboxEnvironmentId, staleTime: 60_000, retry: 0, diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts index 070fb37c47..45040a2df6 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.test.ts @@ -14,9 +14,11 @@ vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore, })); -vi.mock("../api", () => ({ - getGithubRepositories: mockGetGithubRepositories, - getIntegrations: mockGetIntegrations, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + getGithubRepositories: mockGetGithubRepositories, + getIntegrations: mockGetIntegrations, + }), })); import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; diff --git a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts index 49c1638780..bf2aab3c77 100644 --- a/apps/mobile/src/features/tasks/hooks/useIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useIntegrations.ts @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { getGithubRepositories, getIntegrations } from "../api"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { useRepositoryCacheStore } from "../stores/repositoryCacheStore"; -import type { RepositoryOption } from "../types"; +import type { Integration, RepositoryOption } from "../types"; import { buildRepositoryOptions } from "../utils/repositorySelection"; /** Cheap content-equality check for repository option lists. Lets the cache @@ -59,8 +59,27 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: integrationKeys.github(), queryFn: async () => { - const data = await getIntegrations(); - return data.filter((i) => i.kind === "github"); + const data = await getPostHogApiClient().getIntegrations(); + return data.flatMap((integration): Integration[] => { + if ( + integration.kind !== "github" || + typeof integration.id !== "number" + ) { + return []; + } + + return [ + { + id: integration.id, + kind: integration.kind, + display_name: + typeof integration.display_name === "string" + ? integration.display_name + : undefined, + config: integration.config as Integration["config"], + }, + ]; + }); }, enabled: enabled && !!projectId && !!oauthAccessToken, }); @@ -78,7 +97,9 @@ export function useIntegrations(options: UseIntegrationsOptions = {}) { const results = await Promise.allSettled( githubIntegrations.map(async (integration) => ({ integrationId: integration.id, - repositories: await getGithubRepositories(integration.id), + repositories: await getPostHogApiClient().getGithubRepositories( + integration.id, + ), })), ); diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts index 6cd5c33ada..579ec4bce2 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.test.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.test.ts @@ -28,12 +28,17 @@ vi.mock("@/lib/logger", () => { }); vi.mock("../api", () => ({ - createTask: vi.fn(), - deleteTask: vi.fn(), - getTask: vi.fn(), - getTasks: vi.fn(), runTaskInCloud: vi.fn(), - updateTask: vi.fn(), +})); + +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ + createTask: vi.fn(), + deleteTask: vi.fn(), + getTask: vi.fn(), + getTasks: vi.fn(), + updateTask: vi.fn(), + }), })); vi.mock("../stores/taskStore", () => ({ diff --git a/apps/mobile/src/features/tasks/hooks/useTasks.ts b/apps/mobile/src/features/tasks/hooks/useTasks.ts index 1af7d5aa84..95b5d4725f 100644 --- a/apps/mobile/src/features/tasks/hooks/useTasks.ts +++ b/apps/mobile/src/features/tasks/hooks/useTasks.ts @@ -1,16 +1,12 @@ +import { filterAndSortTasks } from "@posthog/core/tasks/taskActivity"; +import type { Task } from "@posthog/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuthStore, useUserQuery } from "@/features/auth"; import { logger } from "@/lib/logger"; -import { - createTask, - deleteTask, - getTask, - getTasks, - runTaskInCloud, - updateTask, -} from "../api"; -import { filterAndSortTasks, useTaskStore } from "../stores/taskStore"; -import type { CreateTaskOptions, Task } from "../types"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import { runTaskInCloud } from "../api"; +import { useTaskStore } from "../stores/taskStore"; +import type { CreateTaskOptions } from "../types"; const log = logger.scope("tasks-mutations"); const ACTIVE_TASK_POLLING_INTERVAL_MS = 5_000; @@ -69,7 +65,7 @@ export function useTasks(filters?: { const query = useQuery({ queryKey: taskKeys.list(queryFilters), - queryFn: () => getTasks(queryFilters), + queryFn: () => getPostHogApiClient().getTasks(queryFilters), enabled: !!projectId && !!oauthAccessToken && !!currentUser?.id, refetchInterval: (query) => getTaskPollingInterval(query.state.data as Task[] | undefined), @@ -102,7 +98,7 @@ export function useTask(taskId: string) { return useQuery({ queryKey: taskKeys.detail(taskId), - queryFn: () => getTask(taskId), + queryFn: () => getPostHogApiClient().getTask(taskId), enabled: !!projectId && !!oauthAccessToken && !!taskId, refetchInterval: (query) => getTaskPollingInterval(query.state.data as Task | undefined), @@ -117,7 +113,8 @@ export function useCreateTask() { }; const mutation = useMutation({ - mutationFn: (options: CreateTaskOptions) => createTask(options), + mutationFn: (options: CreateTaskOptions) => + getPostHogApiClient().createTask(options), onSuccess: () => { invalidateTasks(); }, @@ -139,7 +136,13 @@ export function useUpdateTask() { }: { taskId: string; updates: Partial; - }) => updateTask(taskId, updates), + }) => + getPostHogApiClient().updateTask( + taskId, + updates as Parameters< + ReturnType["updateTask"] + >[1], + ), onSuccess: (updatedTask, { taskId }) => { // Update the detail cache immediately queryClient.setQueryData(taskKeys.detail(taskId), updatedTask); @@ -155,7 +158,7 @@ export function useDeleteTask() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (taskId: string) => deleteTask(taskId), + mutationFn: (taskId: string) => getPostHogApiClient().deleteTask(taskId), onSuccess: (_, taskId) => { // Remove from detail cache queryClient.removeQueries({ queryKey: taskKeys.detail(taskId) }); diff --git a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts index 68ae4f9808..1ba6655cf2 100644 --- a/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts +++ b/apps/mobile/src/features/tasks/hooks/useUserIntegrations.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { useAuthStore } from "@/features/auth"; -import { getUserGithubIntegrations, getUserGithubRepositories } from "../api"; -import type { RepositoryOption, UserGithubIntegration } from "../types"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; +import type { RepositoryOption } from "../types"; /** * User-scoped sibling of {@link useIntegrations}. Reads the authenticated @@ -29,7 +29,10 @@ interface UseUserIntegrationsOptions { enabled?: boolean; } -function integrationLabel(integration: UserGithubIntegration): string { +function integrationLabel(integration: { + installation_id: string; + account?: { name?: string | null } | null; +}): string { return integration.account?.name ?? `GitHub ${integration.installation_id}`; } @@ -39,7 +42,7 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const integrationsQuery = useQuery({ queryKey: userIntegrationKeys.github(), - queryFn: getUserGithubIntegrations, + queryFn: () => getPostHogApiClient().getGithubUserIntegrations(), enabled: enabled && !!oauthAccessToken, }); @@ -54,7 +57,7 @@ export function useUserIntegrations(options: UseUserIntegrationsOptions = {}) { const results = await Promise.allSettled( integrations.map(async (integration) => ({ installationId: integration.installation_id, - repositories: await getUserGithubRepositories( + repositories: await getPostHogApiClient().getGithubUserRepositories( integration.installation_id, ), })), diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx index 5b757d40fc..175a77c276 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.test.tsx @@ -8,8 +8,8 @@ const flagState = vi.hoisted(() => ({ enabled: true as boolean })); vi.mock("posthog-react-native", () => ({ useFeatureFlag: () => flagState.enabled, })); -vi.mock("@/features/tasks/api", () => ({ - warmTask: mockWarmTask, +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ warmTask: mockWarmTask }), })); vi.mock("@/lib/logger", () => { const mockLogger = { diff --git a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts index 935619f75f..d3034f8638 100644 --- a/apps/mobile/src/features/tasks/hooks/useWarmTask.ts +++ b/apps/mobile/src/features/tasks/hooks/useWarmTask.ts @@ -1,8 +1,8 @@ import { TASKS_PREWARM_SANDBOX_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "posthog-react-native"; import { useEffect, useRef } from "react"; -import { warmTask } from "@/features/tasks/api"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; const log = logger.scope("warm-task"); @@ -79,21 +79,23 @@ export function useWarmTask({ debounceRef.current = setTimeout(() => { debounceRef.current = null; lastWarmedKeyRef.current = key; - void warmTask({ - repository: repo, - github_integration: githubIntegration, - branch: warmBranch, - runtime_adapter: warmRuntimeAdapter, - model: warmModel, - reasoning_effort: warmReasoningEffort, - ...(warmSandboxEnvironmentId - ? { sandbox_environment_id: warmSandboxEnvironmentId } - : {}), - ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}), - }).catch((error) => { - lastWarmedKeyRef.current = null; - log.warn("Failed to warm task", error); - }); + void getPostHogApiClient() + .warmTask({ + repository: repo, + github_integration: githubIntegration, + branch: warmBranch, + runtime_adapter: warmRuntimeAdapter, + model: warmModel, + reasoning_effort: warmReasoningEffort, + ...(warmSandboxEnvironmentId + ? { sandbox_environment_id: warmSandboxEnvironmentId } + : {}), + ...(warmCustomImageId ? { custom_image_id: warmCustomImageId } : {}), + }) + .catch((error) => { + lastWarmedKeyRef.current = null; + log.warn("Failed to warm task", error); + }); }, WARM_DEBOUNCE_MS); return clearDebounce; diff --git a/apps/mobile/src/features/tasks/index.ts b/apps/mobile/src/features/tasks/index.ts index 07c05346e7..7da4db747e 100644 --- a/apps/mobile/src/features/tasks/index.ts +++ b/apps/mobile/src/features/tasks/index.ts @@ -1,7 +1,5 @@ // Tasks feature -// API -export * from "./api"; // Components export { TaskItem } from "./components/TaskItem"; export { TaskList } from "./components/TaskList"; @@ -29,7 +27,6 @@ export * from "./types"; // Utils export { - convertRawEntriesToEvents, convertStoredEntriesToEvents, parseSessionLogs, } from "./utils/parseSessionLogs"; diff --git a/apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts b/apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts new file mode 100644 index 0000000000..a3fc53a610 --- /dev/null +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + engine: { + off: vi.fn(), + on: vi.fn(), + reconnectIfDisconnected: vi.fn(), + unwatch: vi.fn(), + watch: vi.fn(), + }, +})); + +vi.mock("@posthog/core/cloud-task/cloud-task-engine", () => ({ + createCloudTaskEngine: () => mocks.engine, +})); + +vi.mock("@posthog/core/cloud-task/schemas", () => ({ + CloudTaskEvent: { Update: "cloud-task-update" }, +})); + +vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); + +vi.mock("@/lib/api", () => ({ + authedFetch: vi.fn(), + getBaseUrl: () => "https://app.posthog.test", + getProjectId: () => 42, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { scope: vi.fn() }, +})); + +import { watchCloudTask } from "./cloudTaskStream"; + +describe("watchCloudTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("asks the shared engine to reconnect only when disconnected", () => { + const handle = watchCloudTask({ + taskId: "task-1", + runId: "run-1", + onUpdate: vi.fn(), + }); + + handle.reconnectIfDisconnected(); + + expect(mocks.engine.reconnectIfDisconnected).toHaveBeenCalledWith( + "task-1", + "run-1", + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts index 2541eed7e7..ac761b6bc9 100644 --- a/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts +++ b/apps/mobile/src/features/tasks/lib/cloudTaskStream.ts @@ -1,120 +1,18 @@ -import { fetch } from "expo/fetch"; -import { createTimeoutSignal } from "@/lib/api"; -import { logger } from "@/lib/logger"; import { - fetchSessionLogs, - getTaskRun, - HttpError, - streamCloudTask, -} from "../api"; + type CloudTaskEngine, + type CloudTaskFetch, + createCloudTaskEngine, +} from "@posthog/core/cloud-task/cloud-task-engine"; +import { CloudTaskEvent } from "@posthog/core/cloud-task/schemas"; +import type { CloudTaskUpdatePayload } from "@posthog/shared"; +import { fetch } from "expo/fetch"; import { - type CloudTaskUpdatePayload, - isKeepaliveEvent, - isPermissionRequestEvent, - isSseErrorEvent, - isTaskRunStateEvent, - isTerminalStatus, - type StoredLogEntry, - type TaskRun, - type TaskRunStateEvent, - type TaskRunStatus, -} from "../types"; -import { parseSessionLogs } from "../utils/parseSessionLogs"; -import { type SseEvent, SseEventParser } from "./sseParser"; - -const log = logger.scope("cloud-task-stream"); - -const MAX_SSE_RECONNECT_ATTEMPTS = 5; -const SSE_RECONNECT_BASE_DELAY_MS = 2_000; -const SSE_RECONNECT_MAX_DELAY_MS = 30_000; -const EVENT_BATCH_FLUSH_MS = 16; -const EVENT_BATCH_MAX_SIZE = 50; -const SESSION_LOG_PAGE_LIMIT = 5_000; - -interface CloudTaskConnectionError { - title: string; - message: string; - retryable: boolean; - autoRetry?: boolean; -} - -class CloudTaskStreamError extends Error { - constructor( - message: string, - public readonly details: CloudTaskConnectionError, - public readonly status?: number, - ) { - super(message); - this.name = "CloudTaskStreamError"; - } -} - -function createStreamStatusError(status: number): CloudTaskStreamError { - switch (status) { - case 401: - return new CloudTaskStreamError( - "Cloud authentication expired", - { - title: "Cloud authentication expired", - message: "Please reauthenticate and retry the cloud run stream.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 403: - return new CloudTaskStreamError( - "Cloud access denied", - { - title: "Cloud access denied", - message: - "You no longer have access to this cloud run. Reauthenticate and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 404: - return new CloudTaskStreamError( - "Cloud run not found", - { - title: "Cloud run not found", - message: - "This cloud run could not be found. It may have been deleted or moved.", - retryable: false, - autoRetry: false, - }, - status, - ); - case 406: - return new CloudTaskStreamError( - "Cloud stream unavailable", - { - title: "Cloud stream unavailable", - message: - "The backend rejected the live stream request. Restart the backend and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - default: - return new CloudTaskStreamError( - `Stream request failed with status ${status}`, - { - title: "Cloud stream failed", - message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, - retryable: true, - autoRetry: true, - }, - status, - ); - } -} - -function shouldFailWatcherForFetchStatus(status: number): boolean { - return status === 401 || status === 403 || status === 404; -} + authedFetch, + type FetchInit, + getBaseUrl, + getProjectId, +} from "@/lib/api"; +import { logger } from "@/lib/logger"; export interface WatchCloudTaskOptions { taskId: string; @@ -127,786 +25,78 @@ export interface WatchCloudTaskHandle { reconnectIfDisconnected: () => void; } -interface WatcherState { - taskId: string; - runId: string; - onUpdate: (update: CloudTaskUpdatePayload) => void; - stopped: boolean; - sseAbortController: AbortController | null; - reconnectTimeoutId: ReturnType | null; - batchFlushTimeoutId: ReturnType | null; - pendingLogEntries: StoredLogEntry[]; - totalEntryCount: number; - reconnectAttempts: number; - lastEventId: string | null; - lastStatus: TaskRunStatus | null; - lastStage: string | null; - lastOutput: Record | null; - lastErrorMessage: string | null; - lastBranch: string | null; - lastStatusUpdatedAt: string | null; - isBootstrapping: boolean; - hasEmittedSnapshot: boolean; - bufferedLogBatches: StoredLogEntry[][]; - failed: boolean; - needsPostBootstrapReconnect: boolean; - needsStopAfterBootstrap: boolean; -} - -export function watchCloudTask( - options: WatchCloudTaskOptions, -): WatchCloudTaskHandle { - const watcher: WatcherState = { - taskId: options.taskId, - runId: options.runId, - onUpdate: options.onUpdate, - stopped: false, - sseAbortController: null, - reconnectTimeoutId: null, - batchFlushTimeoutId: null, - pendingLogEntries: [], - totalEntryCount: 0, - reconnectAttempts: 0, - lastEventId: null, - lastStatus: null, - lastStage: null, - lastOutput: null, - lastErrorMessage: null, - lastBranch: null, - lastStatusUpdatedAt: null, - isBootstrapping: false, - hasEmittedSnapshot: false, - bufferedLogBatches: [], - failed: false, - needsPostBootstrapReconnect: false, - needsStopAfterBootstrap: false, - }; - - void bootstrapWatcher(watcher); - - return { - stop: () => stopWatcher(watcher), - reconnectIfDisconnected: () => { - if ( - watcher.stopped || - watcher.failed || - isTerminalStatus(watcher.lastStatus) - ) { - return; - } - if (watcher.sseAbortController || watcher.reconnectTimeoutId) { - return; - } - log.debug("Force reconnect after suspension", { runId: watcher.runId }); - watcher.reconnectAttempts = 0; - void connectSse(watcher, { - startLatest: !watcher.lastEventId, - }); +const mobileCloudTaskAnalytics = { + initialize: () => {}, + track: () => {}, + identify: () => {}, + setCurrentUserId: () => {}, + getCurrentUserId: () => null, + getOrCreateSessionId: () => "mobile-cloud-task", + resetUser: () => {}, + captureException: () => {}, + flush: async () => {}, + shutdown: async () => {}, +}; + +let cloudTaskEngine: CloudTaskEngine | null = null; + +function getCloudTaskEngine(): CloudTaskEngine { + if (cloudTaskEngine) { + return cloudTaskEngine; + } + + cloudTaskEngine = createCloudTaskEngine({ + auth: { + authenticatedFetch: (url, init) => + authedFetch(url, init as FetchInit | undefined), + getCloudContext: async () => ({ + apiHost: getBaseUrl(), + teamId: getProjectId(), + }), }, - }; -} - -function stopWatcher(watcher: WatcherState): void { - if (watcher.stopped) return; - watcher.stopped = true; - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - // Drop any unflushed batches; the consumer is gone. - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; -} - -async function bootstrapWatcher(watcher: WatcherState): Promise { - if (watcher.stopped) return; - - watcher.failed = false; - watcher.needsPostBootstrapReconnect = false; - watcher.needsStopAfterBootstrap = false; - - const run = await fetchTaskRunState(watcher); - if (watcher.stopped || watcher.failed) return; - - if (!run) { - failWatcher(watcher, { - title: "Failed to load cloud run", - message: "Could not fetch the cloud run state. Retry to reconnect.", - retryable: true, - }); - return; - } - - applyTaskRunState(watcher, run); - - if (isTerminalStatus(run.status)) { - const historicalEntries = await fetchHistoricalEntries(watcher, run); - if (watcher.stopped || watcher.failed) return; - if (!historicalEntries) { - failWatcher(watcher, { - title: "Failed to load task history", - message: - "Could not load the persisted cloud task logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - emitSnapshot(watcher, historicalEntries); - stopWatcher(watcher); - return; - } - - watcher.isBootstrapping = true; - watcher.bufferedLogBatches = []; - void connectSse(watcher, { startLatest: true }); - - const historicalEntries = await fetchHistoricalEntries(watcher, run); - if (watcher.stopped || watcher.failed) return; - if (!historicalEntries) { - failWatcher(watcher, { - title: "Failed to load cloud run history", - message: - "Could not load the existing cloud run logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - // Flush any pending live entries into the bootstrap buffer before snapshot. - flushLogBatch(watcher); - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - emitSnapshot(watcher, historicalEntries); - - watcher.isBootstrapping = false; - drainBufferedLogBatches(watcher, historicalEntries); - - if (watcher.failed) return; - - if (watcher.needsStopAfterBootstrap || isTerminalStatus(watcher.lastStatus)) { - watcher.needsStopAfterBootstrap = false; - stopWatcher(watcher); - return; - } - - if (watcher.needsPostBootstrapReconnect) { - watcher.needsPostBootstrapReconnect = false; - scheduleReconnect(watcher, undefined, { countAttempt: false }); - } - - void verifyPostBootstrapStatus(watcher); -} - -async function verifyPostBootstrapStatus(watcher: WatcherState): Promise { - if (watcher.stopped) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - const run = await fetchTaskRunState(watcher); - if (watcher.stopped || !run) return; - - if (!applyTaskRunState(watcher, run)) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - emitStatus(watcher); -} - -async function connectSse( - watcher: WatcherState, - options?: { startLatest?: boolean }, -): Promise { - if (watcher.stopped) return; - - const controller = new AbortController(); - watcher.sseAbortController = controller; - - const parser = new SseEventParser(); - const decoder = new TextDecoder(); - - try { - const response = await streamCloudTask(watcher.taskId, watcher.runId, { - lastEventId: watcher.lastEventId, - startLatest: options?.startLatest, - signal: controller.signal, - }); - - if (!response.ok) { - throw createStreamStatusError(response.status); - } - - if (!response.body) { - throw new Error("Stream response did not include a body"); - } - - const reader = response.body.getReader(); - - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - if (!value) { - continue; - } - - const chunk = decoder.decode(value, { stream: true }); - const events = parser.parse(chunk); - for (const event of events) { - handleSseEvent(watcher, event); - if (watcher.failed) return; - } - } - - const trailingEvents = parser.parse(decoder.decode()); - for (const event of trailingEvents) { - handleSseEvent(watcher, event); - if (watcher.failed) return; - } - - flushLogBatch(watcher); - - if (controller.signal.aborted) { - return; - } - - await handleStreamCompletion(watcher, { reconnectIfNonTerminal: true }); - } catch (error) { - flushLogBatch(watcher); - - if (controller.signal.aborted) { - return; - } - - if ( - error instanceof CloudTaskStreamError && - error.details.autoRetry === false - ) { - failWatcher(watcher, error.details); - return; - } - - const errorMessage = - error instanceof Error ? error.message : "Unknown stream error"; - log.warn("Cloud task stream error", { - runId: watcher.runId, - error: errorMessage, - }); - await handleStreamCompletion(watcher, { - reconnectIfNonTerminal: true, - reconnectError: error, - countReconnectAttempt: true, - }); - } finally { - if (watcher.sseAbortController === controller) { - watcher.sseAbortController = null; - } - } -} - -function handleSseEvent(watcher: WatcherState, event: SseEvent): void { - if (watcher.failed || watcher.stopped) return; - - if (event.id) { - watcher.lastEventId = event.id; - } - - if (event.event === "error") { - const message = isSseErrorEvent(event.data) - ? event.data.error - : "Unknown stream error"; - throw new Error(message); - } - - if (event.event === "keepalive" || isKeepaliveEvent(event.data)) { - return; - } - - watcher.reconnectAttempts = 0; - - if (isTaskRunStateEvent(event.data)) { - if (applyTaskRunState(watcher, event.data)) { - if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { - emitStatus(watcher); - } - } - return; - } - - if (isPermissionRequestEvent(event.data)) { - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "permission_request", - requestId: event.data.requestId, - toolCall: event.data.toolCall, - options: event.data.options, - }); - return; - } - - // StoredLogEntry always has a string `type`. Anything else is a server - // event the mobile client doesn't understand yet — drop it instead of - // forwarding a malformed entry to convertStoredEntriesToEvents. - if ( - typeof event.data !== "object" || - event.data === null || - typeof (event.data as { type?: unknown }).type !== "string" - ) { - log.warn("Skipping unrecognized SSE event", { - runId: watcher.runId, - eventName: event.event, - }); - return; - } - - watcher.pendingLogEntries.push(event.data as StoredLogEntry); - if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { - flushLogBatch(watcher); - return; - } - - if (!watcher.batchFlushTimeoutId) { - watcher.batchFlushTimeoutId = setTimeout(() => { - watcher.batchFlushTimeoutId = null; - flushLogBatch(watcher); - }, EVENT_BATCH_FLUSH_MS); - } -} - -function flushLogBatch(watcher: WatcherState): void { - if (watcher.pendingLogEntries.length === 0) return; - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - const entries = watcher.pendingLogEntries; - watcher.pendingLogEntries = []; - - if (watcher.isBootstrapping) { - watcher.bufferedLogBatches.push(entries); - return; - } - - watcher.totalEntryCount += entries.length; - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: entries, - totalEntryCount: watcher.totalEntryCount, + analytics: mobileCloudTaskAnalytics, + logger, + streamFetch: fetch as CloudTaskFetch, }); -} - -function drainBufferedLogBatches( - watcher: WatcherState, - historicalEntries: StoredLogEntry[], -): void { - if (watcher.bufferedLogBatches.length === 0) return; - - // Content-based dedup because SSE IDs (Redis stream IDs) don't exist in - // the S3-backed historical entries — the JSON payload is the only shared key. - const historicalCounts = new Map(); - for (const entry of historicalEntries) { - const serialized = JSON.stringify(entry); - historicalCounts.set( - serialized, - (historicalCounts.get(serialized) ?? 0) + 1, - ); - } - - for (const entries of watcher.bufferedLogBatches) { - const dedupedEntries = entries.filter((entry) => { - const serialized = JSON.stringify(entry); - const remaining = historicalCounts.get(serialized) ?? 0; - if (remaining <= 0) return true; - historicalCounts.set(serialized, remaining - 1); - return false; - }); - if (dedupedEntries.length === 0) continue; - - watcher.totalEntryCount += dedupedEntries.length; - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: dedupedEntries, - totalEntryCount: watcher.totalEntryCount, - }); - } - - watcher.bufferedLogBatches = []; -} - -function emitSnapshot(watcher: WatcherState, entries: StoredLogEntry[]): void { - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: entries, - totalEntryCount: watcher.totalEntryCount, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); -} - -function emitStatus(watcher: WatcherState): void { - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); -} - -function failWatcher( - watcher: WatcherState, - error: CloudTaskConnectionError, -): void { - if (watcher.stopped) return; - - watcher.failed = true; - watcher.isBootstrapping = false; - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - watcher.onUpdate({ - taskId: watcher.taskId, - runId: watcher.runId, - kind: "error", - errorTitle: error.title, - errorMessage: error.message, - retryable: error.retryable, - }); + return cloudTaskEngine; } -function scheduleReconnect( - watcher: WatcherState, - error?: unknown, - options: { countAttempt?: boolean } = {}, -): void { - if ( - watcher.stopped || - watcher.failed || - isTerminalStatus(watcher.lastStatus) - ) { - return; - } - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - } - - const countAttempt = options.countAttempt ?? true; - if (countAttempt) { - watcher.reconnectAttempts += 1; - } else { - watcher.reconnectAttempts = 0; - } - - if (watcher.reconnectAttempts > MAX_SSE_RECONNECT_ATTEMPTS) { - const details = - error instanceof CloudTaskStreamError - ? error.details - : { - title: "Cloud stream disconnected", - message: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }; - failWatcher(watcher, details); - return; - } - - const delay = Math.min( - SSE_RECONNECT_BASE_DELAY_MS * - 2 ** Math.max(watcher.reconnectAttempts - 1, 0), - SSE_RECONNECT_MAX_DELAY_MS, - ); - - watcher.reconnectTimeoutId = setTimeout(() => { - if (watcher.stopped) return; - watcher.reconnectTimeoutId = null; - void connectSse(watcher, { - startLatest: watcher.isBootstrapping || watcher.hasEmittedSnapshot, - }); - }, delay); -} - -async function handleStreamCompletion( - watcher: WatcherState, - options: { - reconnectIfNonTerminal: boolean; - reconnectError?: unknown; - countReconnectAttempt?: boolean; - }, -): Promise { - if (watcher.stopped) return; - - const { reconnectIfNonTerminal } = options; - const run = await fetchTaskRunState(watcher); - if (watcher.stopped || watcher.failed) return; - - if (watcher.isBootstrapping) { - if (!run) { - watcher.needsPostBootstrapReconnect = true; - return; - } - - applyTaskRunState(watcher, run); - if (isTerminalStatus(watcher.lastStatus) || !reconnectIfNonTerminal) { - watcher.needsStopAfterBootstrap = true; - } else { - watcher.needsPostBootstrapReconnect = true; - } - return; - } - - if (!run) { - scheduleReconnect( - watcher, - new CloudTaskStreamError("Failed to fetch terminal cloud run state", { - title: "Cloud run state unavailable", - message: - "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", - retryable: true, - }), - ); - return; - } - - const stateChanged = applyTaskRunState(watcher, run); - - if (!isTerminalStatus(watcher.lastStatus) && reconnectIfNonTerminal) { - if (stateChanged) { - emitStatus(watcher); - } - log.warn("Cloud task stream ended before terminal status", { - runId: watcher.runId, - status: watcher.lastStatus, - }); - scheduleReconnect(watcher, options.reconnectError, { - countAttempt: options.countReconnectAttempt ?? false, - }); - return; - } - - emitStatus(watcher); - stopWatcher(watcher); -} - -function applyTaskRunState( - watcher: WatcherState, - run: - | Pick< - TaskRun, - | "status" - | "stage" - | "output" - | "error_message" - | "branch" - | "updated_at" - > - | TaskRunStateEvent, -): boolean { - const updatedAt = run.updated_at ?? null; - if ( - updatedAt && - watcher.lastStatusUpdatedAt && - Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) - ) { - return false; - } - - const nextStatus = run.status ?? watcher.lastStatus; - const nextStage = run.stage ?? null; - const nextOutput = run.output ?? null; - const nextErrorMessage = run.error_message ?? null; - const nextBranch = run.branch ?? null; - - const changed = - nextStatus !== watcher.lastStatus || - nextStage !== watcher.lastStage || - JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || - nextErrorMessage !== watcher.lastErrorMessage || - nextBranch !== watcher.lastBranch; - - watcher.lastStatus = nextStatus ?? null; - watcher.lastStage = nextStage; - watcher.lastOutput = nextOutput; - watcher.lastErrorMessage = nextErrorMessage; - watcher.lastBranch = nextBranch; - if (updatedAt) { - watcher.lastStatusUpdatedAt = updatedAt; - } - - return changed; -} - -async function fetchTaskRunState( - watcher: WatcherState, -): Promise { - try { - return await getTaskRun(watcher.taskId, watcher.runId); - } catch (error) { - if (error instanceof HttpError) { - log.warn("Cloud task status fetch failed", { - runId: watcher.runId, - status: error.status, - }); - if (shouldFailWatcherForFetchStatus(error.status)) { - failWatcher(watcher, createStreamStatusError(error.status).details); - } - return null; - } - log.warn("Cloud task status fetch error", { - runId: watcher.runId, - error, - }); - return null; - } -} - -/** - * Loads the historical log entries for the run, mirroring the desktop's - * dual-source strategy: - * 1. Try the paginated `session_logs/` API — the live source while a run - * is active. For older / archived runs this can come back empty even - * though the canonical log exists on S3. - * 2. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the - * canonical archive for completed runs. - * - * Returns `null` only when both sources fail outright (so the bootstrap can - * surface a retryable error). An empty paginated result is treated as "no - * data yet" and falls through to S3 — if S3 also has nothing we return the - * empty array so the snapshot can still flip the session to `"connected"`. - */ -async function fetchHistoricalEntries( - watcher: WatcherState, - run: TaskRun, -): Promise { - const paginated = await fetchAllSessionLogs(watcher); - if (watcher.stopped || watcher.failed) return null; - if (paginated && paginated.length > 0) return paginated; - - if (run.log_url) { - const s3Entries = await fetchS3LogEntries(watcher, run.log_url); - if (watcher.stopped || watcher.failed) return null; - if (s3Entries && s3Entries.length > 0) return s3Entries; - } - - // Both sources returned no rows. Prefer the paginated result (which is - // `[]` rather than `null`) so the caller can still emit an empty snapshot - // and the session flips to `"connected"` instead of hanging on loading. - return paginated ?? null; -} - -async function fetchS3LogEntries( - watcher: WatcherState, - logUrl: string, -): Promise { - try { - const response = await fetch(logUrl, { - signal: createTimeoutSignal(15_000), - }); - if (response.status === 404) { - // No archived log yet for this run — not an error, just no data. - return []; - } - if (!response.ok) { - log.warn("S3 session log fetch returned non-OK", { - runId: watcher.runId, - status: response.status, - }); - return null; +export function watchCloudTask({ + taskId, + runId, + onUpdate, +}: WatchCloudTaskOptions): WatchCloudTaskHandle { + const engine = getCloudTaskEngine(); + const listener = (update: CloudTaskUpdatePayload): void => { + if (update.taskId === taskId && update.runId === runId) { + onUpdate(update); } - const content = await response.text(); - if (!content.trim()) return []; - return parseSessionLogs(content).rawEntries; - } catch (error) { - log.warn("S3 session log fetch failed", { - runId: watcher.runId, - error, - }); - return null; - } -} + }; -async function fetchAllSessionLogs( - watcher: WatcherState, -): Promise { - const entries: StoredLogEntry[] = []; - let offset = 0; + engine.on(CloudTaskEvent.Update, listener); + engine.watch({ + taskId, + runId, + apiHost: getBaseUrl(), + teamId: getProjectId(), + }); - while (true) { - if (watcher.stopped || watcher.failed) return null; - try { - const page = await fetchSessionLogs(watcher.taskId, watcher.runId, { - limit: SESSION_LOG_PAGE_LIMIT, - offset, - }); + let stopped = false; - for (const entry of page.entries) { - entries.push(entry); - } - if (!page.hasMore || page.entries.length === 0) { - return entries; + return { + stop: () => { + if (stopped) { + return; } - offset += page.entries.length; - } catch (error) { - if (error instanceof HttpError) { - log.warn("Cloud task session logs fetch failed", { - runId: watcher.runId, - status: error.status, - offset, - }); - if (shouldFailWatcherForFetchStatus(error.status)) { - failWatcher(watcher, createStreamStatusError(error.status).details); - } - return null; + stopped = true; + engine.off(CloudTaskEvent.Update, listener); + engine.unwatch(taskId, runId); + }, + reconnectIfDisconnected: () => { + if (!stopped) { + engine.reconnectIfDisconnected(taskId, runId); } - log.warn("Cloud task session logs fetch error", { - runId: watcher.runId, - offset, - error, - }); - return null; - } - } + }, + }; } diff --git a/apps/mobile/src/features/tasks/lib/sseParser.ts b/apps/mobile/src/features/tasks/lib/sseParser.ts deleted file mode 100644 index 4c626fd657..0000000000 --- a/apps/mobile/src/features/tasks/lib/sseParser.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { logger } from "@/lib/logger"; - -const log = logger.scope("sse-parser"); - -export interface SseEvent { - event?: string; - id?: string; - data: unknown; -} - -export class SseEventParser { - private buffer = ""; - private currentEventName: string | null = null; - private currentEventId: string | null = null; - private currentData: string[] = []; - - parse(chunk: string): SseEvent[] { - this.buffer += chunk; - const lines = this.buffer.split("\n"); - this.buffer = lines.pop() || ""; - - const events: SseEvent[] = []; - - for (const rawLine of lines) { - const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; - - if (line === "") { - const event = this.flushEvent(); - if (event) { - events.push(event); - } - continue; - } - - if (line.startsWith(":")) { - continue; - } - - if (line.startsWith("event:")) { - this.currentEventName = line.slice(6).trim() || null; - continue; - } - - if (line.startsWith("id:")) { - this.currentEventId = line.slice(3).trim() || null; - continue; - } - - if (line.startsWith("data:")) { - this.currentData.push(line.slice(5).trimStart()); - } - } - - return events; - } - - reset(): void { - this.buffer = ""; - this.currentEventName = null; - this.currentEventId = null; - this.currentData = []; - } - - private flushEvent(): SseEvent | null { - if (this.currentData.length === 0) { - this.currentEventName = null; - this.currentEventId = null; - return null; - } - - const rawData = this.currentData.join("\n"); - this.currentData = []; - - try { - const data = JSON.parse(rawData); - return { - event: this.currentEventName ?? undefined, - id: this.currentEventId ?? undefined, - data, - }; - } catch { - log.warn("SSE event JSON parse failure", { rawData }); - return null; - } finally { - this.currentEventName = null; - this.currentEventId = null; - } - } -} diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index b0b49e1ef1..864ca6271e 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -1,5 +1,13 @@ +import type { + CloudTaskUpdatePayload, + StoredLogEntry, + Task, + TaskRun, +} from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; +const { mockGetTask } = vi.hoisted(() => ({ mockGetTask: vi.fn() })); + vi.mock("expo-haptics", () => ({ impactAsync: vi.fn(), notificationAsync: vi.fn(), @@ -18,19 +26,16 @@ vi.mock("@/features/notifications/lib/notifications", () => ({ })); vi.mock("../api", () => ({ CloudCommandError: class CloudCommandError extends Error {}, - getTask: vi.fn(), runTaskInCloud: vi.fn(), sendCloudCommand: vi.fn(), })); +vi.mock("@/lib/posthogApiClient", () => ({ + getPostHogApiClient: () => ({ getTask: mockGetTask }), +})); + import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; -import { getTask, runTaskInCloud } from "../api"; -import type { - CloudTaskUpdatePayload, - StoredLogEntry, - Task, - TaskRun, -} from "../types"; +import { runTaskInCloud } from "../api"; import { useMessageQueueStore } from "./messageQueueStore"; import { mapTerminalStatus, @@ -234,7 +239,6 @@ describe("flushQueuedMessagesIfIdle", () => { }); describe("_resumeCloudRun", () => { - const mockGetTask = vi.mocked(getTask); const mockRunTaskInCloud = vi.mocked(runTaskInCloud); function previousTask(latestRun: Partial): Task { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index e71f284ab5..a47638c679 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,13 +1,19 @@ +import { + type CloudTaskUpdatePayload, + isTerminalStatus, + type StoredLogEntry, + type Task, +} from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { AppState } from "react-native"; import { create } from "zustand"; import { presentLocalNotification } from "@/features/notifications/lib/notifications"; import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore"; import { logger } from "@/lib/logger"; +import { getPostHogApiClient } from "@/lib/posthogApiClient"; import { CloudCommandError, cancelRun, - getTask, runTaskInCloud, sendCloudCommand, } from "../api"; @@ -18,16 +24,12 @@ import { type WatchCloudTaskHandle, watchCloudTask, } from "../lib/cloudTaskStream"; -import { - type CloudPendingPermissionRequest, - type CloudTaskUpdatePayload, - isTerminalStatus, - type SessionEvent, - type SessionNotification, - type SessionNotificationAttachment, - type StoredLogEntry, - type Task, - type TerminalStatus, +import type { + CloudPendingPermissionRequest, + SessionEvent, + SessionNotification, + SessionNotificationAttachment, + TerminalStatus, } from "../types"; import { convertStoredEntriesToEvents } from "../utils/parseSessionLogs"; import { playbackRateForTaskDuration } from "../utils/playbackRate"; @@ -1178,7 +1180,7 @@ export const useTaskSessionStore = create((set, get) => ({ previousRunId: string, prompt: string, ) => { - const freshTask = await getTask(taskId); + const freshTask = await getPostHogApiClient().getTask(taskId); const previousRun = freshTask.latest_run; const previousBranch = previousRun?.branch ?? null; diff --git a/apps/mobile/src/features/tasks/stores/taskStore.test.ts b/apps/mobile/src/features/tasks/stores/taskStore.test.ts deleted file mode 100644 index 19f990c105..0000000000 --- a/apps/mobile/src/features/tasks/stores/taskStore.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Task } from "../types"; -import { filterAndSortTasks } from "./taskStore"; - -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-01T00:00:00Z", - origin_product: "tasks", - ...overrides, - }; -} - -describe("filterAndSortTasks", () => { - it.each([ - { name: "empty title and description", title: "", description: "" }, - { name: "whitespace-only fields", title: " ", description: "\n\t" }, - ])( - "hides warm-sandbox placeholder tasks ($name)", - ({ title, description }) => { - const placeholder = makeTask({ id: "warm", title, description }); - const real = makeTask({ id: "real" }); - - const result = filterAndSortTasks( - [placeholder, real], - "updated", - false, - "", - ); - - expect(result.map((t) => t.id)).toEqual(["real"]); - }, - ); - - it.each([ - { - name: "description only (title not landed yet)", - title: "", - description: "Fix login", - }, - { name: "title only", title: "Fix login", description: "" }, - ])("keeps a real task with $name", ({ title, description }) => { - const task = makeTask({ id: "real", title, description }); - - const result = filterAndSortTasks([task], "updated", false, ""); - - expect(result.map((t) => t.id)).toEqual(["real"]); - }); -}); diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 6c8daa14f2..456eae37a1 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,9 +1,8 @@ -import { isContentlessTask } from "@posthog/shared/domain-types"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { ExecutionMode, ReasoningEffort } from "../composer/options"; -import type { RepositorySelection, Task } from "../types"; +import type { RepositorySelection } from "../types"; export type OrganizeMode = "by-project" | "chronological"; export type SortMode = "created" | "updated"; @@ -106,48 +105,3 @@ export const useTaskStore = create()( }, ), ); - -export function taskActivityTimestamp(task: Task, sortMode: SortMode): number { - if (sortMode === "created") { - return new Date(task.created_at).getTime(); - } - // "updated" — take the most recent of task.updated_at and latest_run.updated_at. - const runUpdated = task.latest_run?.updated_at; - const taskUpdated = task.updated_at ?? task.created_at; - return Math.max( - runUpdated ? new Date(runUpdated).getTime() : 0, - new Date(taskUpdated).getTime(), - ); -} - -export function filterAndSortTasks( - tasks: Task[], - sortMode: SortMode, - showInternal: boolean, - filter: string, -): Task[] { - let filtered = tasks; - - // Warm-sandbox prewarming creates empty placeholder tasks; never surface them. - filtered = filtered.filter((task) => !isContentlessTask(task)); - - // Visibility filter — mirrors desktop radio: External hides internal, Internal shows only internal. - filtered = filtered.filter((task) => - showInternal ? task.internal === true : task.internal !== true, - ); - - if (filter) { - const lowerFilter = filter.toLowerCase(); - filtered = filtered.filter( - (task) => - task.title.toLowerCase().includes(lowerFilter) || - task.slug.toLowerCase().includes(lowerFilter) || - task.description?.toLowerCase().includes(lowerFilter), - ); - } - - return [...filtered].sort( - (a, b) => - taskActivityTimestamp(b, sortMode) - taskActivityTimestamp(a, sortMode), - ); -} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index e777246331..29a2754d7f 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -1,115 +1,19 @@ -export interface Task { - id: string; - task_number: number | null; - slug: string; - title: string; - description: string; - created_at: string; - updated_at: string; - origin_product: string; - /** Inbox report UUID when origin_product is "signal_report". */ - signal_report?: string | null; - repository?: string | null; - github_integration?: number | null; - internal?: boolean; - latest_run?: TaskRun; -} - -export interface TaskAutomation { - id: string; - name: string; - prompt: string; - repository: string; - github_integration?: number | null; - cron_expression: string; - timezone?: string | null; - template_id?: string | null; - enabled: boolean; - last_run_at: string | null; - last_run_status: string | null; - last_task_id: string | null; - last_task_run_id: string | null; - last_error: string | null; - created_at: string; - updated_at: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "started" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; - -// UI-facing terminal outcome for a run. `cancelled` maps to `stopped` (the user -// deliberately halted it) so the UI can distinguish it from a real failure. -export type TerminalStatus = "completed" | "failed" | "stopped"; - -export function isTerminalStatus( - status: TaskRunStatus | string | null | undefined, -): boolean { - return ( - status !== null && - status !== undefined && - TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) - ); -} - -export interface TaskRunArtifact { - id?: string; - storage_path?: string; -} - -export interface TaskRun { - id: string; - task: string; - team: number; - branch: string | null; - stage?: string | null; - environment?: "local" | "cloud"; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - reasoning_effort?: string | null; - output: Record | null; - state: Record; - artifacts?: TaskRunArtifact[]; - created_at: string; - updated_at: string; - completed_at: string | null; -} - -export interface StoredLogEntry { - type: string; - timestamp?: string; - notification?: { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; - }; +import type { + CloudPermissionOption, + CloudTaskPermissionRequestUpdate, + StoredLogEntry as SharedStoredLogEntry, + TaskRunStatus, +} from "@posthog/shared"; + +export interface MobileStoredLogEntry extends SharedStoredLogEntry { direction?: "client" | "agent"; } -export interface CloudArtifactRef { - runId: string; - artifactId: string; -} - export interface SessionNotificationAttachment { kind: "image" | "document"; uri: string; fileName: string; mimeType?: string; - // Set when the attachment was resolved from a cloud `session/prompt` entry. - // Its bytes live in S3 as a run artifact; the preview is fetched by presigning - // rather than read off the local device. - cloudArtifact?: CloudArtifactRef; } export interface SessionNotification { @@ -157,22 +61,6 @@ export interface SessionUpdateEvent { export type SessionEvent = AcpMessage | SessionUpdateEvent; -export interface CloudPermissionOption { - kind: string; - optionId: string; - name: string; - _meta?: Record; -} - -export interface CloudPermissionToolCall { - toolCallId: string; - title: string; - kind: string; - content?: unknown[]; - rawInput?: Record; - _meta?: Record; -} - export interface CloudPermissionResponseSelection { optionId: string; displayText: string; @@ -182,63 +70,11 @@ export interface CloudPermissionResponseSelection { export interface CloudPendingPermissionRequest { requestId: string; - toolCall: CloudPermissionToolCall; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; options: CloudPermissionOption[]; response?: CloudPermissionResponseSelection; } -interface CloudTaskUpdateBase { - taskId: string; - runId: string; -} - -export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase { - kind: "logs"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; -} - -export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase { - kind: "status"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; -} - -export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase { - kind: "snapshot"; - newEntries: StoredLogEntry[]; - totalEntryCount: number; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; -} - -export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { - kind: "error"; - errorTitle: string; - errorMessage: string; - retryable: boolean; -} - -export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { - kind: "permission_request"; - requestId: string; - toolCall: CloudPermissionToolCall; - options: CloudPermissionOption[]; -} - -export type CloudTaskUpdatePayload = - | CloudTaskLogsUpdate - | CloudTaskStatusUpdate - | CloudTaskSnapshotUpdate - | CloudTaskErrorUpdate - | CloudTaskPermissionRequestUpdate; - export interface TaskRunStateEvent { type: "task_run_state"; status?: TaskRunStatus; @@ -253,7 +89,7 @@ export interface TaskRunStateEvent { export interface PermissionRequestEventData { type: "permission_request"; requestId: string; - toolCall: CloudPermissionToolCall; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; options: CloudPermissionOption[]; } @@ -344,25 +180,3 @@ export interface CreateTaskOptions { * cloud runs. Preferred over `github_integration` for interactive tasks. */ github_user_integration?: string; } - -export interface CreateTaskAutomationOptions { - name: string; - prompt: string; - repository: string; - github_integration?: number | null; - cron_expression: string; - timezone: string; - enabled?: boolean; - template_id?: string | null; -} - -export interface UpdateTaskAutomationOptions { - name?: string; - prompt?: string; - repository?: string; - github_integration?: number | null; - cron_expression?: string; - timezone?: string; - enabled?: boolean; - template_id?: string | null; -} diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts index 6ad491c61f..730e0d98d6 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.test.ts @@ -1,58 +1,6 @@ import { Alert } from "react-native"; import { describe, expect, it, vi } from "vitest"; -import type { Task, TaskRunStatus } from "../types"; -import { confirmArchiveRunningTask, isTaskRunning } from "./archiveGuard"; - -function makeTask(status?: TaskRunStatus): Task { - return { - id: "task-1", - task_number: 1, - slug: "task-1", - title: "Test task", - description: "", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - origin_product: "code", - latest_run: status - ? { - id: "run-1", - task: "task-1", - team: 1, - branch: null, - stage: null, - environment: "cloud", - 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("treats a task with no run as not running", () => { - expect(isTaskRunning(makeTask())).toBe(false); - }); - - it.each(["not_started", "queued", "started", "in_progress"] as const)( - "treats %s as running", - (status) => { - expect(isTaskRunning(makeTask(status))).toBe(true); - }, - ); - - it.each(["completed", "failed", "cancelled"] as const)( - "treats %s as not running", - (status) => { - expect(isTaskRunning(makeTask(status))).toBe(false); - }, - ); -}); +import { confirmArchiveRunningTask } from "./archiveGuard"; describe("confirmArchiveRunningTask", () => { it("archives only when the user confirms", () => { diff --git a/apps/mobile/src/features/tasks/utils/archiveGuard.ts b/apps/mobile/src/features/tasks/utils/archiveGuard.ts index 377b13dd47..d5e8cd9687 100644 --- a/apps/mobile/src/features/tasks/utils/archiveGuard.ts +++ b/apps/mobile/src/features/tasks/utils/archiveGuard.ts @@ -1,10 +1,4 @@ import { Alert } from "react-native"; -import { isTerminalStatus, type Task } from "../types"; - -export function isTaskRunning(task: Task): boolean { - const status = task.latest_run?.status; - return status !== undefined && !isTerminalStatus(status); -} export function confirmArchiveRunningTask( taskTitle: string, diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts deleted file mode 100644 index af54fb0162..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCronExpression, - createDefaultScheduleDraft, - deriveAutomationName, - formatScheduleSummary, - parseCronExpression, -} from "./automationSchedule"; - -describe("automationSchedule", () => { - it("builds cron expressions for common schedule presets", () => { - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "hourly", - minute: "15", - }), - ).toBe("15 * * * *"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "daily", - hour: "09", - minute: "15", - }), - ).toBe("15 9 * * *"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekdays", - hour: "10", - minute: "00", - }), - ).toBe("0 10 * * 1-5"); - - expect( - buildCronExpression({ - ...createDefaultScheduleDraft(), - mode: "weekly", - hour: "11", - minute: "30", - weekday: "4", - }), - ).toBe("30 11 * * 4"); - }); - - it("parses common cron expressions back into schedule drafts", () => { - expect(parseCronExpression("15 * * * *")).toMatchObject({ - mode: "hourly", - minute: "15", - }); - - expect(parseCronExpression("0 9 * * *")).toMatchObject({ - mode: "daily", - hour: "09", - minute: "00", - }); - - expect(parseCronExpression("0 9 * * 1-5")).toMatchObject({ - mode: "weekdays", - hour: "09", - minute: "00", - }); - - expect(parseCronExpression("30 14 * * 2")).toMatchObject({ - mode: "weekly", - weekday: "2", - hour: "14", - minute: "30", - }); - }); - - it("keeps custom cron expressions in custom mode", () => { - expect(parseCronExpression("*/15 * * * *")).toMatchObject({ - mode: "custom", - rawCron: "*/15 * * * *", - }); - }); - - it("derives a readable automation name from the prompt", () => { - expect( - deriveAutomationName( - "\n Review every open PostHog PR for stale comments \n", - ), - ).toBe("Review every open PostHog PR for stale comments"); - }); - - it("formats schedule summaries with timezone context", () => { - expect(formatScheduleSummary("15 * * * *", "Europe/London")).toBe( - "Every hour at :15 · Europe/London", - ); - expect(formatScheduleSummary("0 9 * * 1-5", "Europe/London")).toBe( - "Weekdays at 09:00 · Europe/London", - ); - expect(formatScheduleSummary("*/15 * * * *", "UTC")).toBe( - "Custom schedule · UTC", - ); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/automationSchedule.ts b/apps/mobile/src/features/tasks/utils/automationSchedule.ts deleted file mode 100644 index 06cec5faf3..0000000000 --- a/apps/mobile/src/features/tasks/utils/automationSchedule.ts +++ /dev/null @@ -1,213 +0,0 @@ -import type { TaskAutomation } from "../types"; - -export type AutomationScheduleMode = - | "hourly" - | "daily" - | "weekdays" - | "weekly" - | "custom"; - -export interface AutomationScheduleDraft { - mode: AutomationScheduleMode; - hour: string; - minute: string; - weekday: string; - rawCron: string; -} - -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: Pick, -): string { - return formatScheduleSummary( - automation.cron_expression, - automation.timezone ?? null, - ); -} diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts index 3a3305451b..7a24d5d050 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.test.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.test.ts @@ -8,9 +8,7 @@ describe("automationStatus", () => { lastRunStatus: "running", lastTaskRunStatus: "queued", }), - ).toMatchObject({ - label: "Queued", - }); + ).toMatchObject({ label: "Queued" }); }); it("hides the running badge while the linked task run is actively in progress", () => { @@ -24,19 +22,13 @@ describe("automationStatus", () => { it("hides the running badge when only the automation-level status is available", () => { expect( - getAutomationStatusPresentation({ - lastRunStatus: "running", - }), + getAutomationStatusPresentation({ lastRunStatus: "running" }), ).toBeNull(); }); it("falls back to automation status when task-run detail is unavailable", () => { expect( - getAutomationStatusPresentation({ - lastRunStatus: "success", - }), - ).toMatchObject({ - label: "Success", - }); + getAutomationStatusPresentation({ lastRunStatus: "success" }), + ).toMatchObject({ label: "Success" }); }); }); diff --git a/apps/mobile/src/features/tasks/utils/automationStatus.ts b/apps/mobile/src/features/tasks/utils/automationStatus.ts index e5dd7c8fe3..5e8b7f553f 100644 --- a/apps/mobile/src/features/tasks/utils/automationStatus.ts +++ b/apps/mobile/src/features/tasks/utils/automationStatus.ts @@ -1,4 +1,4 @@ -import type { TaskRun } from "../types"; +import type { TaskRun } from "@posthog/shared"; export interface AutomationStatusInput { lastRunStatus: string | null; @@ -21,7 +21,6 @@ export function getAutomationStatusPresentation({ label: "Queued", className: "bg-status-warning/20 text-status-warning", }; - case "started": case "in_progress": return null; case "completed": diff --git a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts index d899a29b9b..2f42d89acc 100644 --- a/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts +++ b/apps/mobile/src/features/tasks/utils/automationTemplatePresentation.ts @@ -1,5 +1,5 @@ +import type { TaskAutomation } from "@posthog/api-client/posthog-client"; import { parseSkillTemplateId } from "../skills/skillTemplateIds"; -import type { TaskAutomation } from "../types"; export interface AutomationTemplatePresentation { templateName: string | null; diff --git a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts index 9cce3995d2..a6d512d59d 100644 --- a/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts +++ b/apps/mobile/src/features/tasks/utils/parseSessionLogs.ts @@ -1,12 +1,9 @@ -import type { - SessionEvent, - SessionNotification, - StoredLogEntry, -} from "../types"; +import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; +import type { MobileStoredLogEntry, SessionNotification } from "../types"; export interface ParsedSessionLogs { notifications: SessionNotification[]; - rawEntries: StoredLogEntry[]; + rawEntries: MobileStoredLogEntry[]; } export function parseSessionLogs(content: string): ParsedSessionLogs { @@ -15,11 +12,11 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { } const notifications: SessionNotification[] = []; - const rawEntries: StoredLogEntry[] = []; + const rawEntries: MobileStoredLogEntry[] = []; for (const line of content.trim().split("\n")) { try { - const stored = JSON.parse(line) as StoredLogEntry; + const stored = JSON.parse(line) as MobileStoredLogEntry; const msg = stored.notification; if (msg) { @@ -53,81 +50,5 @@ export function parseSessionLogs(content: string): ParsedSessionLogs { return { notifications, rawEntries }; } -export function convertRawEntriesToEvents( - rawEntries: StoredLogEntry[], - notifications: SessionNotification[], -): SessionEvent[] { - const events: SessionEvent[] = []; - let notificationIdx = 0; - - for (const entry of rawEntries) { - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - events.push({ - type: "acp_message", - direction: entry.direction ?? "agent", - ts, - message: entry.notification, - }); - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - notificationIdx < notifications.length - ) { - events.push({ - type: "session_update", - ts, - notification: notifications[notificationIdx], - }); - notificationIdx++; - } - } - - return events; -} - -function inferDirection(entry: StoredLogEntry): "client" | "agent" { - if (entry.direction) return entry.direction; - const msg = entry.notification; - if (!msg) return "agent"; - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - if (hasId && hasMethod) return "client"; - if (hasId && hasResult) return "agent"; - return "agent"; -} - -export function convertStoredEntriesToEvents( - entries: StoredLogEntry[], -): SessionEvent[] { - const events: SessionEvent[] = []; - for (const entry of entries) { - const ts = entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(); - - events.push({ - type: "acp_message", - direction: inferDirection(entry), - ts, - message: entry.notification, - }); - - if ( - entry.type === "notification" && - entry.notification?.method === "session/update" && - entry.notification?.params - ) { - events.push({ - type: "session_update", - ts, - notification: entry.notification.params as SessionNotification, - }); - } - } - return events; -} +export const convertStoredEntriesToEvents = + convertStoredEntriesToPortableSessionEvents; diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts deleted file mode 100644 index aef0f08b44..0000000000 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { SessionEvent } from "../types"; -import { - countUserMessages, - getSessionActivityPhase, - isSessionAwaitingUserInput, -} from "./sessionActivity"; - -function buildUserMessage(text: string): SessionEvent { - return { - type: "session_update", - ts: 1, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text }, - }, - }, - } satisfies SessionEvent; -} - -describe("countUserMessages", () => { - it("counts only user_message_chunk events", () => { - expect( - countUserMessages([ - buildUserMessage("hello"), - buildQuestionToolCall("pending"), - buildUserMessage("again"), - ]), - ).toBe(2); - }); - - it("returns 0 for no events", () => { - expect(countUserMessages()).toBe(0); - }); -}); - -function buildQuestionToolCall( - status: "pending" | "in_progress" | "completed", -) { - return { - type: "session_update", - ts: 1, - notification: { - update: { - sessionUpdate: "tool_call", - toolCallId: "question-1", - status, - rawInput: { - questions: [{ question: "Proceed?", options: [] }], - }, - _meta: { - claudeCode: { - toolName: "AskUserQuestion", - }, - }, - }, - }, - } satisfies SessionEvent; -} - -describe("getSessionActivityPhase", () => { - it("treats retrying as connecting", () => { - expect( - getSessionActivityPhase({ - retrying: true, - session: { isPromptPending: true, awaitingAgentOutput: false }, - }), - ).toBe("connecting"); - }); - - it("stays connecting until the agent emits visible output", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: true, awaitingAgentOutput: true }, - }), - ).toBe("connecting"); - }); - - it("shows working only after the agent is actively in a turn", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: true, awaitingAgentOutput: false }, - }), - ).toBe("working"); - }); - - it("returns idle once the agent is no longer working", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { isPromptPending: false, awaitingAgentOutput: false }, - }), - ).toBe("idle"); - - expect( - getSessionActivityPhase({ - retrying: false, - session: { - isPromptPending: true, - awaitingAgentOutput: false, - terminalStatus: "completed", - }, - }), - ).toBe("idle"); - }); - - it("returns idle while the agent is paused on a question tool", () => { - expect( - getSessionActivityPhase({ - retrying: false, - session: { - isPromptPending: true, - awaitingAgentOutput: false, - events: [buildQuestionToolCall("pending")], - }, - }), - ).toBe("idle"); - }); -}); - -describe("isSessionAwaitingUserInput", () => { - it("detects unresolved question tools", () => { - expect(isSessionAwaitingUserInput([buildQuestionToolCall("pending")])).toBe( - true, - ); - }); - - it("clears the waiting state once the user responds", () => { - const events: SessionEvent[] = [ - buildQuestionToolCall("pending"), - { - type: "session_update", - ts: 2, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "Yes" }, - }, - }, - }, - ]; - - expect(isSessionAwaitingUserInput(events)).toBe(false); - }); - - it("honors explicit awaiting-user-input backend markers", () => { - const events: SessionEvent[] = [ - { - type: "acp_message", - direction: "agent", - ts: 1, - message: { method: "_posthog/awaiting_user_input" }, - }, - ]; - - expect(isSessionAwaitingUserInput(events)).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/tasks/utils/sessionActivity.ts b/apps/mobile/src/features/tasks/utils/sessionActivity.ts deleted file mode 100644 index 982d4db8b6..0000000000 --- a/apps/mobile/src/features/tasks/utils/sessionActivity.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { - SessionEvent, - SessionNotification, - TerminalStatus, -} from "../types"; - -export type SessionActivityPhase = "idle" | "connecting" | "working"; - -interface SessionActivityState { - isPromptPending?: boolean; - awaitingAgentOutput?: boolean; - terminalStatus?: TerminalStatus; - events?: SessionEvent[]; -} - -function isQuestionNotification(notification: SessionNotification): 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?: "pending" | "in_progress" | "completed" | "failed" | null, -): boolean { - return status === null || status === "pending" || status === "in_progress"; -} - -export function isSessionAwaitingUserInput( - events: SessionEvent[] = [], -): boolean { - let awaitingUserInput = false; - const questionStatuses = new Map< - string, - "pending" | "in_progress" | "completed" | "failed" | null | 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") && - isQuestionNotification(event.notification) - ) { - questionStatuses.set( - update?.toolCallId ?? `question-${event.ts}`, - update?.status, - ); - awaitingUserInput = [...questionStatuses.values()].some((status) => - isPendingQuestionStatus(status), - ); - } - - continue; - } - - const method = - event.message && typeof event.message === "object" - ? (event.message as { method?: string }).method - : undefined; - - if (method === "_posthog/awaiting_user_input") { - awaitingUserInput = true; - continue; - } - - if ( - method === "_posthog/turn_complete" || - method === "_posthog/task_complete" || - method === "_posthog/error" - ) { - awaitingUserInput = false; - questionStatuses.clear(); - } - } - - return awaitingUserInput; -} - -export function countUserMessages(events: SessionEvent[] = []): number { - return events.filter( - (e) => - e.type === "session_update" && - e.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/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 0bee789344..e7665ba661 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -1,5 +1,4 @@ -import type { PostHogEventProperties } from "@posthog/core"; -import { usePostHog } from "posthog-react-native"; +import { type PostHog, usePostHog } from "posthog-react-native"; import { useEffect, useMemo } from "react"; /** @@ -199,6 +198,8 @@ export interface Analytics { ): void; } +type PostHogCaptureProperties = Parameters[1]; + // Client discriminator stamped on inbox events so the shared PostHog project // can be sliced by surface (desktop sends "code", the web frontend sends // "cloud"). Mirrors packages/ui/src/shell/posthogAnalyticsImpl.ts. @@ -221,12 +222,9 @@ export function useAnalytics(): Analytics { const enriched = INBOX_ANALYTICS_EVENT_NAMES.has(eventName) ? { inbox_client: INBOX_CLIENT, ...properties } : properties; - // Our typed property interfaces don't carry an index signature; cast - // to the wider PostHog event-properties shape without losing the - // narrower call-site type-check. posthog?.capture( eventName, - enriched as unknown as PostHogEventProperties, + enriched as unknown as PostHogCaptureProperties, ); }, }), diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 58603788d3..ccc2a97a09 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -3,9 +3,20 @@ import Constants from "expo-constants"; import { useAuthStore } from "@/features/auth"; import { logger } from "@/lib/logger"; +export class HttpError extends Error { + constructor( + readonly status: number, + readonly statusText: string, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + // Derive the init shape directly from expo/fetch so we don't import from // expo's internal build output (which can move between versions). -type FetchInit = NonNullable[1]>; +export type FetchInit = NonNullable[1]>; const log = logger.scope("api"); @@ -66,7 +77,7 @@ export function createTimeoutSignal(ms: number): AbortSignal { // pending refresh across all callers and reset it once it settles. let pendingRefresh: Promise | null = null; -async function refreshAccessTokenOnce(): Promise { +export async function refreshAccessTokenOnce(): Promise { if (pendingRefresh) return pendingRefresh; const promise = useAuthStore .getState() diff --git a/apps/mobile/src/lib/posthogApiClient.test.ts b/apps/mobile/src/lib/posthogApiClient.test.ts new file mode 100644 index 0000000000..2aae2a0ed2 --- /dev/null +++ b/apps/mobile/src/lib/posthogApiClient.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authState: { + cloudRegion: "us" as string | null, + getCloudUrlFromRegion: vi.fn(() => "https://us.posthog.com"), + oauthAccessToken: "access-token" as string | null, + projectId: 123 as number | null, + refreshAccessToken: vi.fn(async () => {}), + }, + expoApplication: { + nativeApplicationVersion: "1.2.3" as string | null, + }, + expoConstants: { + expoConfig: { version: "9.9.9" } as { version?: string } | null, + }, + expoFetch: vi.fn(), + instances: [] as Array<{ + apiHost: string; + getAccessToken: () => Promise; + refreshAccessToken: () => Promise; + teamId: number | undefined; + options: Record; + setTeamId: ReturnType; + }>, +})); + +vi.mock("@posthog/api-client/posthog-client", () => ({ + PostHogAPIClient: class { + setTeamId = vi.fn(); + + constructor( + apiHost: string, + getAccessToken: () => Promise, + refreshAccessToken: () => Promise, + teamId: number | undefined, + options: Record, + ) { + mocks.instances.push({ + apiHost, + getAccessToken, + refreshAccessToken, + teamId, + options, + setTeamId: this.setTeamId, + }); + } + }, +})); + +vi.mock("expo-application", () => ({ + get nativeApplicationVersion() { + return mocks.expoApplication.nativeApplicationVersion; + }, +})); + +vi.mock("expo-constants", () => ({ + default: { + get expoConfig() { + return mocks.expoConstants.expoConfig; + }, + }, +})); + +vi.mock("expo/fetch", () => ({ fetch: mocks.expoFetch })); + +vi.mock("@/features/auth", () => ({ + useAuthStore: { + getState: () => mocks.authState, + }, +})); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.instances.length = 0; + mocks.authState.cloudRegion = "us"; + mocks.authState.oauthAccessToken = "access-token"; + mocks.authState.projectId = 123; + mocks.authState.getCloudUrlFromRegion.mockReturnValue( + "https://us.posthog.com", + ); + mocks.authState.refreshAccessToken.mockImplementation(async () => {}); + mocks.expoApplication.nativeApplicationVersion = "1.2.3"; + mocks.expoConstants.expoConfig = { version: "9.9.9" }; +}); + +describe("createPostHogApiClient", () => { + it("configures the shared client for the mobile host", async () => { + const { createPostHogApiClient } = await import("./posthogApiClient"); + + createPostHogApiClient(); + + expect(mocks.instances).toHaveLength(1); + expect(mocks.instances[0]).toMatchObject({ + apiHost: "https://us.posthog.com", + teamId: 123, + options: { + appVersion: "1.2.3", + fetch: mocks.expoFetch, + githubConnectFrom: "posthog_mobile", + userAgent: "posthog/mobile.hog.dev; version: 1.2.3", + }, + }); + }); + + it("falls back to the Expo config version", async () => { + mocks.expoApplication.nativeApplicationVersion = null; + mocks.expoConstants.expoConfig = { version: "4.5.6" }; + const { createPostHogApiClient } = await import("./posthogApiClient"); + + createPostHogApiClient(); + + expect(mocks.instances[0]?.options).toMatchObject({ + appVersion: "4.5.6", + userAgent: "posthog/mobile.hog.dev; version: 4.5.6", + }); + }); + + it("returns the refreshed token from the current auth store state", async () => { + mocks.authState.refreshAccessToken.mockImplementation(async () => { + mocks.authState.oauthAccessToken = "refreshed-token"; + }); + const { createPostHogApiClient } = await import("./posthogApiClient"); + createPostHogApiClient(); + + await expect(mocks.instances[0]?.refreshAccessToken()).resolves.toBe( + "refreshed-token", + ); + expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce(); + }); + + it("shares one refresh across concurrent client retries", async () => { + let resolveRefresh: (() => void) | undefined; + mocks.authState.refreshAccessToken.mockImplementation( + () => + new Promise((resolve) => { + resolveRefresh = () => { + mocks.authState.oauthAccessToken = "refreshed-token"; + resolve(); + }; + }), + ); + const { createPostHogApiClient } = await import("./posthogApiClient"); + createPostHogApiClient(); + + const refreshes = [ + mocks.instances[0]?.refreshAccessToken(), + mocks.instances[0]?.refreshAccessToken(), + ]; + expect(mocks.authState.refreshAccessToken).toHaveBeenCalledOnce(); + resolveRefresh?.(); + + await expect(Promise.all(refreshes)).resolves.toEqual([ + "refreshed-token", + "refreshed-token", + ]); + }); +}); + +describe("getPostHogApiClient", () => { + it("reuses the regional client and updates its project", async () => { + const { getPostHogApiClient } = await import("./posthogApiClient"); + + const first = getPostHogApiClient(); + mocks.authState.projectId = 456; + const second = getPostHogApiClient(); + + expect(second).toBe(first); + expect(mocks.instances).toHaveLength(1); + expect(mocks.instances[0]?.setTeamId).toHaveBeenCalledWith(456); + }); + + it("creates a new client when the cloud region changes", async () => { + const { getPostHogApiClient } = await import("./posthogApiClient"); + + const first = getPostHogApiClient(); + mocks.authState.cloudRegion = "eu"; + mocks.authState.getCloudUrlFromRegion.mockReturnValue( + "https://eu.posthog.com", + ); + const second = getPostHogApiClient(); + + expect(second).not.toBe(first); + expect(mocks.instances.map(({ apiHost }) => apiHost)).toEqual([ + "https://us.posthog.com", + "https://eu.posthog.com", + ]); + }); +}); diff --git a/apps/mobile/src/lib/posthogApiClient.ts b/apps/mobile/src/lib/posthogApiClient.ts new file mode 100644 index 0000000000..c094452f44 --- /dev/null +++ b/apps/mobile/src/lib/posthogApiClient.ts @@ -0,0 +1,84 @@ +import type { FetchImplementation } from "@posthog/api-client/fetcher"; +import { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { fetch } from "expo/fetch"; +import * as Application from "expo-application"; +import Constants from "expo-constants"; +import { useAuthStore } from "@/features/auth"; +import { refreshAccessTokenOnce } from "@/lib/api"; + +const MOBILE_GITHUB_CONNECT_FROM = "posthog_mobile"; + +let posthogApiClient: PostHogAPIClient | null = null; +let posthogApiHost: string | null = null; + +function getAppVersion(): string { + return ( + Application.nativeApplicationVersion ?? + Constants.expoConfig?.version ?? + "unknown" + ); +} + +function getAuthenticatedContext(): { + apiHost: string; + projectId: number; +} { + const { cloudRegion, getCloudUrlFromRegion, projectId } = + useAuthStore.getState(); + + if (!cloudRegion) { + throw new Error("No cloud region set"); + } + if (!projectId) { + throw new Error("No project ID set"); + } + + return { + apiHost: getCloudUrlFromRegion(cloudRegion), + projectId, + }; +} + +async function getAccessToken(): Promise { + const { oauthAccessToken } = useAuthStore.getState(); + if (!oauthAccessToken) { + throw new Error("Not authenticated"); + } + return oauthAccessToken; +} + +async function refreshAccessToken(): Promise { + await refreshAccessTokenOnce(); + return getAccessToken(); +} + +export function createPostHogApiClient(): PostHogAPIClient { + const { apiHost, projectId } = getAuthenticatedContext(); + const appVersion = getAppVersion(); + + return new PostHogAPIClient( + apiHost, + getAccessToken, + refreshAccessToken, + projectId, + { + appVersion, + fetch: fetch as FetchImplementation, + githubConnectFrom: MOBILE_GITHUB_CONNECT_FROM, + userAgent: `posthog/mobile.hog.dev; version: ${appVersion}`, + }, + ); +} + +export function getPostHogApiClient(): PostHogAPIClient { + const { apiHost, projectId } = getAuthenticatedContext(); + + if (!posthogApiClient || posthogApiHost !== apiHost) { + posthogApiClient = createPostHogApiClient(); + posthogApiHost = apiHost; + } else { + posthogApiClient.setTeamId(projectId); + } + + return posthogApiClient; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cdfefdf93..4f35ccf04a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,12 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) + '@posthog/api-client': + specifier: workspace:* + version: link:../../packages/api-client + '@posthog/core': + specifier: workspace:* + version: link:../../packages/core '@posthog/shared': specifier: workspace:* version: link:../../packages/shared From 5299c52f6cf4090546cba9f0f6d6cf8881013843 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 25 Jul 2026 01:29:34 +0300 Subject: [PATCH 25/25] fix(mobile): preserve permission mode on resume Generated-By: PostHog Code Task-Id: c1bbe3cf-742b-4b24-bf96-d11a18b4cf22 --- apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts | 1 + apps/mobile/src/features/tasks/stores/taskSessionStore.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index 864ca6271e..c38504a1c3 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -274,6 +274,7 @@ describe("_resumeCloudRun", () => { expect(mockRunTaskInCloud).toHaveBeenCalledWith("t1", { branch: "feature", + runtimeAdapter: "claude", resumeFromRunId: "prev-run", pendingUserMessage: "hi", reasoningEffort: "low", diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index a47638c679..462589a58e 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1197,6 +1197,7 @@ export const useTaskSessionStore = create((set, get) => ({ const updatedTask = await runTaskInCloud(taskId, { branch: previousBranch, + runtimeAdapter: "claude", resumeFromRunId: previousRunId, pendingUserMessage: prompt, reasoningEffort,