diff --git a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx new file mode 100644 index 000000000..26b06f47d --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx @@ -0,0 +1,121 @@ +import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; +import type { IconProps } from "@phosphor-icons/react"; +import { + BrainIcon, + BugIcon, + ChartLineIcon, + ClipboardTextIcon, + CodeIcon, + DatabaseIcon, + FileTextIcon, + FlagIcon, + FlaskIcon, + GaugeIcon, + GlobeIcon, + PlugIcon, + SparkleIcon, + TableIcon, + VideoIcon, +} from "@phosphor-icons/react"; +import type { PostHogProductId } from "@posthog/agent"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import type { AcpMessage } from "@shared/types/session-events"; +import { openUrlInBrowser } from "@utils/browser"; +import { type ComponentType, useMemo } from "react"; +import { accumulateSessionResources } from "./accumulateSessionResources"; + +/** + * Icon per PostHog product. `Record` keeps this exhaustive: + * adding a product id in `@posthog/agent` forces an icon here at compile time. + */ +const PRODUCT_ICON: Record> = { + product_analytics: ChartLineIcon, + web_analytics: GlobeIcon, + feature_flags: FlagIcon, + experiments: FlaskIcon, + error_tracking: BugIcon, + session_replay: VideoIcon, + surveys: ClipboardTextIcon, + llm_analytics: BrainIcon, + data_warehouse: DatabaseIcon, + cdp: PlugIcon, + logs: FileTextIcon, + apm: GaugeIcon, + sql: TableIcon, + code: CodeIcon, + posthog: SparkleIcon, +}; + +/** + * Docs page on posthog.com per product, so a chip links to the relevant + * product docs. `Partial` on purpose — products without a dedicated docs page + * (e.g. apm, which PostHog folds into LLM analytics / Logs) render as a plain, + * non-clickable badge rather than linking somewhere misleading. + */ +const PRODUCT_DOC_URL: Partial> = { + product_analytics: "https://posthog.com/docs/product-analytics", + web_analytics: "https://posthog.com/docs/web-analytics", + feature_flags: "https://posthog.com/docs/feature-flags", + experiments: "https://posthog.com/docs/experiments", + error_tracking: "https://posthog.com/docs/error-tracking", + session_replay: "https://posthog.com/docs/session-replay", + surveys: "https://posthog.com/docs/surveys", + llm_analytics: "https://posthog.com/docs/ai-observability", + data_warehouse: "https://posthog.com/docs/data-warehouse", + cdp: "https://posthog.com/docs/cdp", + logs: "https://posthog.com/docs/logs", + sql: "https://posthog.com/docs/sql", + code: "https://posthog.com/code", + posthog: "https://posthog.com/docs", +}; + +interface SessionResourcesBarProps { + events: AcpMessage[]; +} + +/** + * Persistent bar above the composer listing the PostHog products the agent has + * touched so far this session — via the MCP `exec` tool, or by reading a file + * from the codebase (the "Code" chip). Each product appears once and is added + * the moment it's first used. Hidden until at least one product has been used. + * Mirrors PlanStatusBar's placement and styling. + */ +export function SessionResourcesBar({ events }: SessionResourcesBarProps) { + const products = useMemo(() => accumulateSessionResources(events), [events]); + + if (products.length === 0) return null; + + return ( + + + + + PostHog resources used + + {products.map((product) => { + const Icon = PRODUCT_ICON[product.id] ?? SparkleIcon; + const docUrl = PRODUCT_DOC_URL[product.id]; + return ( + void openUrlInBrowser(docUrl) : undefined + } + title={docUrl ? `Open ${product.label} docs` : undefined} + > + + {product.label} + + ); + })} + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 000df4227..2c371a6e6 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -48,6 +48,7 @@ import { PendingChatView } from "./PendingChatView"; import { PlanStatusBar } from "./PlanStatusBar"; import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; import { RawLogsView } from "./raw-logs/RawLogsView"; +import { SessionResourcesBar } from "./SessionResourcesBar"; interface SessionViewProps { events: AcpMessage[]; @@ -604,6 +605,8 @@ export function SessionView({ compact={compact} /> + + {hasError && !showInlineBanner ? ( diff --git a/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.test.ts b/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.test.ts new file mode 100644 index 000000000..9a0fdf3ab --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.test.ts @@ -0,0 +1,66 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { describe, expect, it } from "vitest"; +import { accumulateSessionResources } from "./accumulateSessionResources"; + +function resourcesUsedMsg( + ts: number, + products: { id: string; label: string }[], +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/resources_used", + params: { sessionId: "session-1", products }, + }, + }; +} + +describe("accumulateSessionResources", () => { + it("collects products across notifications in first-seen order", () => { + const events: AcpMessage[] = [ + resourcesUsedMsg(1, [{ id: "feature_flags", label: "Feature flags" }]), + resourcesUsedMsg(2, [ + { id: "product_analytics", label: "Product analytics" }, + ]), + ]; + + expect(accumulateSessionResources(events)).toEqual([ + { id: "feature_flags", label: "Feature flags" }, + { id: "product_analytics", label: "Product analytics" }, + ]); + }); + + it("de-duplicates a product used across multiple turns", () => { + const events: AcpMessage[] = [ + resourcesUsedMsg(1, [{ id: "feature_flags", label: "Feature flags" }]), + resourcesUsedMsg(2, [{ id: "experiments", label: "Experiments" }]), + // feature_flags used again on a later turn — must not appear twice. + resourcesUsedMsg(3, [{ id: "feature_flags", label: "Feature flags" }]), + ]; + + const result = accumulateSessionResources(events); + expect(result).toEqual([ + { id: "feature_flags", label: "Feature flags" }, + { id: "experiments", label: "Experiments" }, + ]); + }); + + it("ignores unrelated events and empty payloads", () => { + const events: AcpMessage[] = [ + { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "_posthog/turn_complete", + params: { stopReason: "end_turn" }, + }, + }, + resourcesUsedMsg(2, []), + ]; + + expect(accumulateSessionResources(events)).toEqual([]); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.ts b/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.ts new file mode 100644 index 000000000..dd6f04e22 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.ts @@ -0,0 +1,44 @@ +import { + isNotification, + POSTHOG_NOTIFICATIONS, + type PostHogProductId, +} from "@posthog/agent"; +import { + type AcpMessage, + isJsonRpcNotification, +} from "@shared/types/session-events"; + +export interface ResourceProduct { + id: PostHogProductId; + label: string; +} + +/** + * Accumulate the de-duplicated, first-seen-ordered list of PostHog products + * used across the whole session, from its `_posthog/resources_used` + * notifications. Works for both live streaming and log replay, since both feed + * the same `events` array. A product used on several turns appears once. + * + * Kept in its own module (no React / tRPC imports) so it stays a cheap, + * dependency-free unit to test. + */ +export function accumulateSessionResources( + events: AcpMessage[], +): ResourceProduct[] { + const byId = new Map(); + for (const event of events) { + const msg = event.message; + if (!isJsonRpcNotification(msg)) continue; + if (!isNotification(msg.method, POSTHOG_NOTIFICATIONS.RESOURCES_USED)) { + continue; + } + const products = ( + msg.params as { products?: ResourceProduct[] } | undefined + )?.products; + if (!products) continue; + for (const product of products) { + if (product && !byId.has(product.id)) byId.set(product.id, product); + } + } + return [...byId.values()]; +} diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts index 0bdc3d3d8..dc7c41b79 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts @@ -74,6 +74,38 @@ function turnCompleteMsg(ts: number, stopReason = "end_turn"): AcpMessage { }; } +function agentMessageMsg(ts: number, text: string): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + }, + }, + }; +} + +function resourcesUsedMsg( + ts: number, + products: { id: string; label: string }[], +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/resources_used", + params: { sessionId: "session-1", products }, + }, + }; +} + describe("buildConversationItems", () => { it("extracts cloud prompt attachments into user messages", () => { const uri = makeAttachmentUri("/tmp/hello world.txt"); @@ -421,6 +453,30 @@ describe("buildConversationItems", () => { expect(findProgressGroups(result.items)).toHaveLength(0); }); }); + + describe("resources_used", () => { + it("does not render an inline item (surfaced in the persistent bar)", () => { + const events: AcpMessage[] = [ + userPromptMsg(1, 1, "list my experiments"), + agentMessageMsg(2, "Here are your experiments."), + resourcesUsedMsg(3, [{ id: "experiments", label: "Experiments" }]), + promptResponseMsg(4, 1), + ]; + + const result = buildConversationItems(events, false); + + // The notification must not produce any conversation item — it's now + // handled out-of-band by SessionResourcesBar / accumulateSessionResources. + expect( + result.items.some( + (i) => + i.type === "session_update" && + // biome-ignore lint/suspicious/noExplicitAny: removed union member + (i.update.sessionUpdate as any) === "resources_used", + ), + ).toBe(false); + }); + }); }); // Local alias kept intentionally narrow to the shape we care about in tests. diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index fbd0d1ee4..ecd0572a2 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -380,6 +380,10 @@ function handleNotification( return; } + // `_posthog/resources_used` is intentionally NOT rendered inline here — the + // products are surfaced as a persistent, de-duplicated bar above the composer + // (see accumulateSessionResources / SessionResourcesBar). + if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)) { const params = msg.params as { stopReason?: string } | undefined; if (!b.currentTurn) return; diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index e00c95ebb..332972e79 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -67,6 +67,9 @@ export const POSTHOG_NOTIFICATIONS = { /** Token usage update for a session turn */ USAGE_UPDATE: "_posthog/usage_update", + /** PostHog products used during a turn (derived from MCP exec calls) */ + RESOURCES_USED: "_posthog/resources_used", + /** Response to a relayed permission request (plan approval, question) */ PERMISSION_RESPONSE: "_posthog/permission_response", diff --git a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts index 0671f6c3c..580ec929c 100644 --- a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts @@ -97,6 +97,7 @@ function installFakeSession( cachedReadTokens: 0, cachedWriteTokens: 0, }, + sessionResources: new Set(), configOptions: [], promptRunning: false, pendingMessages: new Map(), diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 2ab70025c..810f34e4d 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -58,6 +58,7 @@ function installFakeSession( cachedReadTokens: 0, cachedWriteTokens: 0, }, + sessionResources: new Set(), configOptions: [], promptRunning: false, pendingMessages: new Map(), diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index b7d7971a5..8a18fe966 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -57,6 +57,11 @@ import { type Enrichment, type FileEnrichmentDeps, } from "../../enrichment/file-enricher"; +import { + classifyPostHogExecCall, + POSTHOG_PRODUCTS, + type PostHogProductId, +} from "../../posthog-products"; import type { PostHogAPIConfig } from "../../types"; import { isCloudRun, unreachable, withTimeout } from "../../utils/common"; import { resolveGithubToken } from "../../utils/github-token"; @@ -435,6 +440,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }; + // sessionResources is intentionally NOT reset here — the products list + // accumulates across the whole session and is deduped, not per-turn. await this.broadcastUserMessage(params); @@ -1405,6 +1412,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { outputFormat, settingsManager, onModeChange: this.createOnModeChange(), + onPostHogResourceUsed: this.createOnPostHogResourceUsed(), + onCodeFileRead: this.createOnCodeFileRead(), onProcessSpawned: this.options?.onProcessSpawned, onProcessExited: this.options?.onProcessExited, effort, @@ -1442,6 +1451,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }, + sessionResources: new Set(), effort, configOptions: [], promptRunning: false, @@ -1644,6 +1654,43 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + /** Records the PostHog product behind an executed MCP exec `call` and emits + * any newly-seen product so the client's persistent list can update live. */ + private createOnPostHogResourceUsed() { + return (subTool: string, commandText?: string) => { + this.recordSessionResources( + classifyPostHogExecCall(subTool, commandText), + ); + }; + } + + /** Records the `code` product the first time the agent reads a file from the + * codebase, so working with code surfaces a chip just like an MCP call does. */ + private createOnCodeFileRead() { + return () => this.recordSessionResources(["code"]); + } + + /** Adds products to the session-wide set and emits any newly-seen ones. + * Session-wide dedup: only the first use of a product emits, so the client's + * persistent list shows each chip once across all turns. */ + private recordSessionResources(products: PostHogProductId[]): void { + if (!this.session) return; + const added = products.filter((p) => !this.session.sessionResources.has(p)); + if (added.length === 0) return; + for (const product of added) this.session.sessionResources.add(product); + void this.emitResourcesUsed(added); + } + + /** Emits newly-seen PostHog products as soon as they're used, so the client + * can append them to a persistent, de-duplicated list in real time. */ + private async emitResourcesUsed(added: PostHogProductId[]): Promise { + const products = added.map((id) => ({ id, label: POSTHOG_PRODUCTS[id] })); + await this.client.extNotification(POSTHOG_NOTIFICATIONS.RESOURCES_USED, { + sessionId: this.sessionId, + products, + }); + } + private getExistingSessionState( sessionId: string, ): NewSessionResponse | null { diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index 2e3dc5ab4..bf7d2bd5d 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -10,6 +10,7 @@ vi.mock("../../enrichment/file-enricher", () => ({ import { Logger } from "../../utils/logger"; import type { TaskState } from "./conversion/task-state"; import { + createPostToolUseHook, createPreToolUseHook, createReadEnrichmentHook, createSignedCommitGuardHook, @@ -200,6 +201,38 @@ describe("createReadEnrichmentHook", () => { }); }); +describe("createPostToolUseHook onCodeFileRead", () => { + const signal = { signal: new AbortController().signal }; + + test("fires onCodeFileRead when a file is read", async () => { + const onCodeFileRead = vi.fn(); + const hook = createPostToolUseHook({ onCodeFileRead }); + await hook(buildReadHookInput({ tool_name: "Read" }), "toolu_1", signal); + + expect(onCodeFileRead).toHaveBeenCalledTimes(1); + }); + + test("does not fire onCodeFileRead for non-Read tools", async () => { + const onCodeFileRead = vi.fn(); + const hook = createPostToolUseHook({ onCodeFileRead }); + await hook(buildReadHookInput({ tool_name: "Bash" }), "toolu_1", signal); + + expect(onCodeFileRead).not.toHaveBeenCalled(); + }); + + test("does not fire onCodeFileRead for non-PostToolUse events", async () => { + const onCodeFileRead = vi.fn(); + const hook = createPostToolUseHook({ onCodeFileRead }); + await hook( + { hook_event_name: "PreToolUse", tool_name: "Read" } as HookInput, + "toolu_1", + signal, + ); + + expect(onCodeFileRead).not.toHaveBeenCalled(); + }); +}); + function buildPreToolUseHookInput( toolName: string, toolInput: Record, diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 391d0ee2c..ba5c007d8 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -176,10 +176,20 @@ export type OnModeChange = (mode: CodeExecutionMode) => Promise; interface CreatePostToolUseHookParams { onModeChange?: OnModeChange; + /** Called after a PostHog MCP `call` exec executes, with the sub-tool name + * and the raw command (the command embeds the SQL for execute-sql). */ + onPostHogResourceUsed?: (subTool: string, commandText?: string) => void; + /** Called after the agent reads a file from the codebase, to surface the + * "Code" resource chip. */ + onCodeFileRead?: () => void; } export const createPostToolUseHook = - ({ onModeChange }: CreatePostToolUseHookParams): HookCallback => + ({ + onModeChange, + onPostHogResourceUsed, + onCodeFileRead, + }: CreatePostToolUseHookParams): HookCallback => async ( input: HookInput, toolUseID: string | undefined, @@ -191,6 +201,25 @@ export const createPostToolUseHook = await onModeChange("plan"); } + // Any file read counts as "used PostHog Code" — surface the Code chip. + if (onCodeFileRead && toolName === "Read") { + onCodeFileRead(); + } + + // Record PostHog product usage from the MCP exec dispatcher. Only the + // `call ` verb counts as "used a resource" — extractPostHogSubTool + // matches that verb and ignores introspection (tools/info/schema/search). + if (onPostHogResourceUsed && isPostHogExecTool(toolName)) { + const subTool = extractPostHogSubTool(input.tool_input); + if (subTool) { + const command = (input.tool_input as { command?: unknown })?.command; + onPostHogResourceUsed( + subTool, + typeof command === "string" ? command : undefined, + ); + } + } + if (toolUseID) { const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook; diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index c87fb4f09..efd2c3c40 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -58,6 +58,11 @@ export interface BuildOptionsParams { effort?: EffortLevel; enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; + /** Records PostHog product usage from MCP exec calls (deduped, session-wide). */ + onPostHogResourceUsed?: (subTool: string, commandText?: string) => void; + /** Records the `code` product when the agent reads a file from the codebase + * (deduped, session-wide). */ + onCodeFileRead?: () => void; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; /** Per-session task state populated by createTaskHook from SDK Task* events. */ @@ -160,6 +165,10 @@ function buildEnvironment(): Record { function buildHooks( userHooks: Options["hooks"], onModeChange: OnModeChange | undefined, + onPostHogResourceUsed: + | ((subTool: string, commandText?: string) => void) + | undefined, + onCodeFileRead: (() => void) | undefined, settingsManager: SettingsManager, logger: Logger, enrichmentDeps: FileEnrichmentDeps | undefined, @@ -169,7 +178,13 @@ function buildHooks( taskState: TaskState, onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { - const postToolUseHooks = [createPostToolUseHook({ onModeChange })]; + const postToolUseHooks = [ + createPostToolUseHook({ + onModeChange, + onPostHogResourceUsed, + onCodeFileRead, + }), + ]; if (enrichmentDeps && enrichedReadCache) { postToolUseHooks.push( createReadEnrichmentHook(enrichmentDeps, enrichedReadCache), @@ -393,6 +408,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { hooks: buildHooks( params.userProvidedOptions?.hooks, params.onModeChange, + params.onPostHogResourceUsed, + params.onCodeFileRead, params.settingsManager, params.logger, params.enrichmentDeps, diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index c7aeca3df..ddbc88f05 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -8,6 +8,7 @@ import type { Query, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import type { PostHogProductId } from "../../posthog-products"; import type { Pushable } from "../../utils/streams"; import type { BaseSession } from "../base-acp-agent"; import type { ContextBreakdownBaseline } from "./context-breakdown"; @@ -57,6 +58,11 @@ export type Session = BaseSession & { effort?: EffortLevel; configOptions: SessionConfigOption[]; accumulatedUsage: AccumulatedUsage; + /** PostHog products used during this session, derived from MCP exec calls. + * Accumulates for the whole session (deduped); each newly-seen product is + * emitted immediately so the client can show a persistent, de-duplicated + * list. Never reset between turns. */ + sessionResources: Set; /** Latest context window usage (total tokens from last assistant message) */ contextUsed?: number; /** Context window size in tokens */ diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 05dc8bae5..c73615f41 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -4,3 +4,4 @@ export { isMcpToolReadOnly, type McpToolMetadata, } from "./adapters/claude/mcp/tool-metadata"; +export type { PostHogProductId } from "./posthog-products"; diff --git a/packages/agent/src/posthog-products.test.ts b/packages/agent/src/posthog-products.test.ts new file mode 100644 index 000000000..a81beb07f --- /dev/null +++ b/packages/agent/src/posthog-products.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + classifyPostHogExecCall, + classifyPostHogSqlQuery, + classifyPostHogSubTool, + POSTHOG_PRODUCTS, +} from "./posthog-products"; + +describe("classifyPostHogSubTool", () => { + it.each([ + ["experiment-list", "experiments"], + ["feature-flag-update", "feature_flags"], + ["early-access-feature-create", "feature_flags"], + ["error-tracking-issue-update", "error_tracking"], + ["session-recording-get", "session_replay"], + ["survey-create", "surveys"], + ["execute-sql", "sql"], + ["external-data-sources-list", "data_warehouse"], + ["cdp-functions-list", "cdp"], + ["insight-create", "product_analytics"], + ])("maps resource sub-tool %s to %s", (subTool, product) => { + expect(classifyPostHogSubTool(subTool)).toBe(product); + }); + + it.each([ + ["query-trends", "product_analytics"], + ["query-trends-actors", "product_analytics"], + ["query-paths", "product_analytics"], + ["query-error-tracking-issues-list", "error_tracking"], + ["query-session-recordings-list", "session_replay"], + ["query-llm-traces-list", "llm_analytics"], + ["query-logs", "logs"], + ["query-apm-spans", "apm"], + ])("classifies query sub-tool %s as %s", (subTool, product) => { + expect(classifyPostHogSubTool(subTool)).toBe(product); + }); + + // `llm` must not swallow the distinct `llma-*` domains. + it.each([ + ["llm-costs", "llm_analytics"], + ["llma-personal-spend", "llm_analytics"], + ])( + "does not let a short domain shadow a longer one: %s", + (subTool, product) => { + expect(classifyPostHogSubTool(subTool)).toBe(product); + }, + ); + + it.each(["project-get", "activity-log-list", "docs-search", "tasks-list"])( + "returns null for admin/meta/introspection domain %s", + (subTool) => { + expect(classifyPostHogSubTool(subTool)).toBeNull(); + }, + ); + + it("falls back to the generic product for unrecognized domains", () => { + expect(classifyPostHogSubTool("brand-new-thing-list")).toBe("posthog"); + }); + + it.each(["", " "])("returns null for empty input %j", (subTool) => { + expect(classifyPostHogSubTool(subTool)).toBeNull(); + }); + + it("only emits ids that exist in POSTHOG_PRODUCTS", () => { + const ids = [ + "experiment-list", + "query-trends", + "execute-sql", + "brand-new-thing-list", + ] + .map(classifyPostHogSubTool) + .filter((id): id is NonNullable => id !== null); + for (const id of ids) { + expect(POSTHOG_PRODUCTS[id]).toBeDefined(); + } + }); +}); + +describe("classifyPostHogSqlQuery", () => { + it.each([ + ["SELECT count() FROM feature_flags", ["feature_flags"]], + ["select * from experiments", ["experiments"]], + ["SELECT * FROM events LIMIT 10", ["product_analytics"]], + ])("attributes %s to the product behind its tables", (sql, expected) => { + expect(classifyPostHogSqlQuery(sql)).toEqual(expected); + }); + + it("resolves a schema-qualified table by its bare name", () => { + expect( + classifyPostHogSqlQuery("SELECT count() FROM system.feature_flags"), + ).toEqual(["feature_flags"]); + }); + + it("handles quoted/back-ticked identifiers", () => { + expect(classifyPostHogSqlQuery("SELECT * FROM `feature_flags`")).toEqual([ + "feature_flags", + ]); + }); + + it("collects products across joins, deduped", () => { + const products = classifyPostHogSqlQuery( + "SELECT * FROM events e JOIN persons p ON e.person_id = p.id JOIN feature_flags f ON true", + ); + expect(products).toContain("product_analytics"); + expect(products).toContain("feature_flags"); + // events + persons both map to product_analytics — deduped to one entry. + expect(products.filter((p) => p === "product_analytics")).toHaveLength(1); + }); + + it.each(["SELECT 1", "SELECT * FROM some_warehouse_table"])( + "returns nothing when no referenced table maps: %s", + (sql) => { + expect(classifyPostHogSqlQuery(sql)).toEqual([]); + }, + ); + + // Exact-name match only — a similarly-named warehouse table is left alone. + it.each([ + "SELECT * FROM statsig_feature_flags", + "SELECT * FROM feature_flags_archive", + ])( + "does not match warehouse table that merely contains a name: %s", + (sql) => { + expect(classifyPostHogSqlQuery(sql)).toEqual([]); + }, + ); + + // A non-PostHog schema prefix (a warehouse source) is a different table. + it.each([ + "SELECT * FROM stripe.feature_flags", + "SELECT * FROM my_source.events", + ])("does not match a non-PostHog-schema-qualified table: %s", (sql) => { + expect(classifyPostHogSqlQuery(sql)).toEqual([]); + }); +}); + +describe("classifyPostHogExecCall", () => { + it("attributes execute-sql to the queried product, not generic SQL", () => { + expect( + classifyPostHogExecCall( + "execute-sql", + 'call execute-sql {"query":"SELECT count() FROM feature_flags"}', + ), + ).toEqual(["feature_flags"]); + }); + + it("falls back to the sql product when no table maps", () => { + expect( + classifyPostHogExecCall( + "execute-sql", + 'call execute-sql {"query":"SELECT 1"}', + ), + ).toEqual(["sql"]); + // No command text at all → still surfaces something rather than vanishing. + expect(classifyPostHogExecCall("execute-sql")).toEqual(["sql"]); + }); + + it("delegates non-sql sub-tools to the domain classifier", () => { + expect(classifyPostHogExecCall("feature-flag-list")).toEqual([ + "feature_flags", + ]); + expect(classifyPostHogExecCall("experiment-get")).toEqual(["experiments"]); + }); + + it("returns an empty array for admin/meta sub-tools", () => { + expect(classifyPostHogExecCall("project-get")).toEqual([]); + }); +}); diff --git a/packages/agent/src/posthog-products.ts b/packages/agent/src/posthog-products.ts new file mode 100644 index 000000000..8ec186ca1 --- /dev/null +++ b/packages/agent/src/posthog-products.ts @@ -0,0 +1,260 @@ +/** + * PostHog product classification for MCP `exec` sub-tools. + * + * The PostHog MCP exposes a single `exec` dispatcher whose `call …` + * verb invokes a concrete resource tool (e.g. `experiment-list`, + * `feature-flag-update`, `execute-sql`, `query-trends`). The sub-tool name is + * `-` (or `query-`), and the domain identifies which + * PostHog product the call touched. + * + * `classifyPostHogSubTool` turns a sub-tool name into a stable product id so the + * agent can report, per turn, which products an answer was grounded in. This is + * the single source of truth for the product id → label set; the renderer maps + * ids to icons/styling for display. + */ + +/** Canonical PostHog products, keyed by stable id with a display label. */ +export const POSTHOG_PRODUCTS = { + product_analytics: "Product analytics", + web_analytics: "Web analytics", + feature_flags: "Feature flags", + experiments: "Experiments", + error_tracking: "Error tracking", + session_replay: "Session replay", + surveys: "Surveys", + llm_analytics: "LLM analytics", + data_warehouse: "Data warehouse", + cdp: "Data pipelines", + logs: "Logs", + apm: "APM", + sql: "SQL", + /** Sourced from the agent reading a file in the user's codebase, not from an + * MCP sub-tool — see the PostToolUse hook's Read handling. */ + code: "Code", + /** Generic fallback for a recognized-PostHog call we don't classify yet. */ + posthog: "PostHog", +} as const; + +export type PostHogProductId = keyof typeof POSTHOG_PRODUCTS; + +/** + * Domain prefix → product, or `null` for admin/meta/introspection domains we + * deliberately do not surface (listing projects, reading the activity log, + * managing tasks, searching docs, …). A sub-tool whose domain is absent here + * falls back to the generic `posthog` product rather than disappearing. + */ +const DOMAIN_PRODUCT: Record = { + // Experiments + experiment: "experiments", + // Feature flags + "feature-flag": "feature_flags", + "early-access-feature": "feature_flags", + "scheduled-changes": "feature_flags", + // Error tracking + "error-tracking": "error_tracking", + // Session replay + "session-recording": "session_replay", + "visual-review": "session_replay", + // Surveys + survey: "surveys", + // LLM analytics + llm: "llm_analytics", + "llma-evaluation-judge-models": "llm_analytics", + "llma-personal-spend": "llm_analytics", + "llma-tagger-test-hog": "llm_analytics", + "agent-feedback": "llm_analytics", + // Data warehouse + "external-data-sources": "data_warehouse", + "external-data-schemas": "data_warehouse", + "external-data-sync-logs": "data_warehouse", + "read-data-warehouse-schema": "data_warehouse", + "read-data-schema": "data_warehouse", + "batch-export": "data_warehouse", + // Data pipelines (CDP) + "cdp-functions": "cdp", + "cdp-function-templates": "cdp", + "hog-flows-logs": "cdp", + "hog-flows-metrics": "cdp", + workflows: "cdp", + // Logs / APM + logs: "logs", + apm: "apm", + // SQL + "execute-sql": "sql", + // Web analytics + "web-analytics-weekly-digest": "web_analytics", + // Product analytics + insight: "product_analytics", + dashboard: "product_analytics", + action: "product_analytics", + cohorts: "product_analytics", + persons: "product_analytics", + annotation: "product_analytics", + endpoint: "product_analytics", + view: "product_analytics", + "usage-metrics": "product_analytics", + subscriptions: "product_analytics", + alert: "product_analytics", + notebooks: "product_analytics", + // Admin / meta / introspection — recognized but not surfaced. + project: null, + user: null, + accounts: null, + integration: null, + "activity-log": null, + "advanced-activity-logs": null, + "approval-policy": null, + "approval-policies": null, + "change-request": null, + "docs-search": null, + "sdk-doctor": null, + tasks: null, + "inbox-reports": null, + "inbox-source-configs": null, + "signals-scout-runs": null, + "signals-scout-scratchpad-search": null, + comment: null, +}; + +const KNOWN_DOMAINS = Object.keys(DOMAIN_PRODUCT); + +/** + * HogQL/PostHog table name → product. Lets an `execute-sql` call be attributed + * to the product whose data it reads (e.g. `SELECT count() FROM feature_flags` + * → Feature flags) instead of a generic "SQL" chip. Keys are EXACT table names + * (lowercased) — `statsig_feature_flags` does not match `feature_flags`. Tables + * we can't confidently map are omitted: they contribute nothing, and a query + * touching no mapped table falls back to the "sql" product. + */ +const TABLE_PRODUCT: Record = { + feature_flags: "feature_flags", + experiments: "experiments", + events: "product_analytics", + person: "product_analytics", + persons: "product_analytics", + person_distinct_ids: "product_analytics", + groups: "product_analytics", + cohort_people: "product_analytics", + cohortpeople: "product_analytics", + sessions: "product_analytics", + raw_sessions: "product_analytics", + session_replay_events: "session_replay", + raw_session_replay_events: "session_replay", + surveys: "surveys", + logs: "logs", +}; + +/** + * Schemas whose tables are PostHog product tables. A qualified reference is + * only treated as a PostHog table when its schema is one of these — so a data + * warehouse table like `stripe.feature_flags` or `my_source.events` is left + * unmapped and can't be mislabeled as a PostHog product. + */ +const POSTHOG_SCHEMAS = new Set(["system"]); + +/** + * Normalize a FROM/JOIN table reference to the PostHog table name to look up, + * or `null` if it's qualified with a non-PostHog (e.g. warehouse) schema. + * Unqualified names pass through as-is; `system.feature_flags` → `feature_flags`. + */ +function normalizePostHogTableRef(ref: string): string | null { + const parts = ref.toLowerCase().split("."); + if (parts.length === 1) return parts[0] ?? null; + const schema = parts[0] ?? ""; + const table = parts[parts.length - 1] ?? ""; + return POSTHOG_SCHEMAS.has(schema) ? table : null; +} + +/** Extract PostHog table names referenced after FROM/JOIN in a SQL/HogQL string. */ +function extractSqlTables(sql: string): string[] { + const tables: string[] = []; + // Match `from`/`join` followed by an optionally back-tick/quoted identifier. + // Subqueries (`from (`) don't match the identifier class and are skipped; + // their inner FROM clauses are still picked up by the global scan. + const re = /\b(?:from|join)\s+(["'`]?)([a-zA-Z_][a-zA-Z0-9_.]*)\1/gi; + let match: RegExpExecArray | null = re.exec(sql); + while (match !== null) { + const ref = match[2]; + if (ref) { + const table = normalizePostHogTableRef(ref); + if (table) tables.push(table); + } + match = re.exec(sql); + } + return tables; +} + +/** + * Map a HogQL/SQL query to the products whose tables it reads. A query can + * touch several tables, so this returns 0..n products (deduped). Returns an + * empty array when no referenced table maps to a known product. + */ +export function classifyPostHogSqlQuery(sql: string): PostHogProductId[] { + const products = new Set(); + for (const table of extractSqlTables(sql)) { + const product = TABLE_PRODUCT[table]; + if (product) products.add(product); + } + return [...products]; +} + +/** + * Classify an executed MCP exec `call` into the products it touched. For + * `execute-sql` the query text is inspected so the call is attributed to the + * product whose tables it reads (e.g. Feature flags), falling back to the + * generic "sql" product only when no table maps. All other sub-tools resolve + * to their single domain product (or none, for admin/meta domains). + * + * `commandText` is the raw exec command (which embeds the SQL for execute-sql). + */ +export function classifyPostHogExecCall( + subTool: string, + commandText?: string, +): PostHogProductId[] { + const name = subTool.trim().toLowerCase(); + if (name === "execute-sql" || name === "execute_sql") { + const fromTables = commandText ? classifyPostHogSqlQuery(commandText) : []; + return fromTables.length > 0 ? fromTables : ["sql"]; + } + const product = classifyPostHogSubTool(subTool); + return product ? [product] : []; +} + +/** Classify a `query-` sub-tool by its query type. */ +function classifyQuery(type: string): PostHogProductId | null { + if (type.startsWith("error-tracking")) return "error_tracking"; + if (type.startsWith("session-recording")) return "session_replay"; + if (type.startsWith("llm")) return "llm_analytics"; + if (type === "logs") return "logs"; + if (type.startsWith("apm")) return "apm"; + // trends / funnel / retention / lifecycle / stickiness / paths (+ -actors) + return "product_analytics"; +} + +/** + * Map a PostHog MCP `call` sub-tool (e.g. `feature-flag-update`, `query-trends`) + * to a product id. Returns `null` when the sub-tool is an admin/meta domain we + * deliberately don't surface, or when the name is empty. + */ +export function classifyPostHogSubTool( + subTool: string, +): PostHogProductId | null { + const name = subTool.trim().toLowerCase(); + if (!name) return null; + + if (name === "query" || name.startsWith("query-")) { + return classifyQuery(name.slice("query-".length)); + } + + // Longest matching domain wins so `feature-flag` beats a hypothetical + // `feature` and multi-word domains aren't shadowed by shorter prefixes. + let best: string | null = null; + for (const domain of KNOWN_DOMAINS) { + if (name === domain || name.startsWith(`${domain}-`)) { + if (best === null || domain.length > best.length) best = domain; + } + } + + if (best === null) return "posthog"; + return DOMAIN_PRODUCT[best]; +}