From 2f2f9b61a1d9c48cdfbb73239c3fb3fd7ac24ac5 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:24:43 +0300 Subject: [PATCH 1/6] 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 | 103 +------------ 8 files changed, 216 insertions(+), 238 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 58ea8697a9..47343950d1 100644 --- a/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx +++ b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx @@ -1,110 +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", - "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", -]; - 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 93ed007d9dc0a0692ed231169dcbd674c8ebac86 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:25:57 +0300 Subject: [PATCH 2/6] 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 baf7bc3df8..cc2aece37e 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, @@ -207,11 +199,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, + }); } } @@ -247,6 +250,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"; @@ -784,7 +822,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) { @@ -2483,7 +2521,7 @@ export class PostHogAPIClient { }, ); - return taskAutomationListSchema.parse(data).results; + return data.results.map(normalizeTaskAutomation); } async getTaskAutomation(automationId: string): Promise { @@ -2495,24 +2533,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); } @@ -2523,17 +2559,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); } @@ -2556,7 +2590,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); } @@ -2601,16 +2637,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 41d237b9fdb7c7dad9d5159222f5587f9737147e Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:26:21 +0300 Subject: [PATCH 3/6] 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 bb31915e35..e8ca16fe34 100644 --- a/packages/core/src/inbox/engagement.ts +++ b/packages/core/src/inbox/engagement.ts @@ -173,13 +173,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 { @@ -191,7 +194,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; } @@ -206,7 +209,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 = { @@ -236,11 +240,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, @@ -248,7 +261,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 267550c4dcd30967ab60bc68461f7a4b73de1b55 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:26:59 +0300 Subject: [PATCH 4/6] 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 abba8a37019bee418367503dbc35073dbd1e2fa6 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:27:37 +0300 Subject: [PATCH 5/6] 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 539172e04d8792c57786d36e3ec8e90d89f91400 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Fri, 24 Jul 2026 19:28:04 +0300 Subject: [PATCH 6/6] 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;