From 86b24ca2fa4d22e292a9f4d896fddef44a5a60d1 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 3 Jun 2026 15:23:08 -0700 Subject: [PATCH 01/10] feat(agent): show PostHog products used below each turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent uses a PostHog resource via the MCP `exec` dispatcher during a turn, surface a chip row under that turn's final message listing the products touched (Experiments, Feature flags, SQL, Error tracking, …). A turn with no PostHog calls shows no row. Detection and classification live in the agent (main/business layer); the renderer is strictly UI: - packages/agent/src/posthog-products.ts: classify an exec sub-tool's domain into a stable product id (admin/meta domains hidden; unknown → generic "PostHog"). Exported via @posthog/agent. - PostToolUse hook records the product behind each executed `call` onto a per-turn Set on the session; reset at prompt start. - At turn end the agent emits a new `_posthog/resources_used` notification with the turn's products, then clears the accumulator. - Renderer: buildConversationItems handles the notification and places a `resources_used` item under the final message; ResourcesUsedView renders the chips. Tests: posthog-products classification, and buildConversationItems placement / empty-payload handling. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- .../components/buildConversationItems.test.ts | 89 ++++++++++ .../components/buildConversationItems.ts | 19 ++- .../session-update/ResourcesUsedView.tsx | 70 ++++++++ .../session-update/SessionUpdateView.tsx | 8 + packages/agent/src/acp-extensions.ts | 3 + .../claude/claude-agent.refresh.test.ts | 1 + .../claude/claude-agent.slash-command.test.ts | 1 + .../agent/src/adapters/claude/claude-agent.ts | 35 ++++ packages/agent/src/adapters/claude/hooks.ts | 15 +- .../src/adapters/claude/session/options.ts | 8 +- packages/agent/src/adapters/claude/types.ts | 4 + packages/agent/src/index.ts | 5 + packages/agent/src/posthog-products.test.ts | 80 +++++++++ packages/agent/src/posthog-products.ts | 155 ++++++++++++++++++ 14 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx create mode 100644 packages/agent/src/posthog-products.test.ts create mode 100644 packages/agent/src/posthog-products.ts 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 0bdc3d3d88..c515eedd3d 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,63 @@ describe("buildConversationItems", () => { expect(findProgressGroups(result.items)).toHaveLength(0); }); }); + + describe("resources_used", () => { + it("renders a resources_used item after the turn's final message", () => { + const products = [ + { id: "experiments", label: "Experiments" }, + { id: "sql", label: "SQL" }, + ]; + const events: AcpMessage[] = [ + userPromptMsg(1, 1, "list my experiments"), + agentMessageMsg(2, "Here are your experiments."), + resourcesUsedMsg(3, products), + promptResponseMsg(4, 1), + ]; + + const result = buildConversationItems(events, false); + + const idx = result.items.findIndex( + (i) => + i.type === "session_update" && + i.update.sessionUpdate === "resources_used", + ); + expect(idx).toBeGreaterThanOrEqual(0); + + // Must land after the agent's final message in the turn. + const msgIdx = result.items.findIndex( + (i) => + i.type === "session_update" && + i.update.sessionUpdate === "agent_message_chunk", + ); + expect(idx).toBeGreaterThan(msgIdx); + + const item = result.items[idx]; + if ( + item.type === "session_update" && + item.update.sessionUpdate === "resources_used" + ) { + expect(item.update.products).toEqual(products); + } + }); + + it("ignores a resources_used notification with no products", () => { + const events: AcpMessage[] = [ + userPromptMsg(1, 1, "hi"), + resourcesUsedMsg(2, []), + promptResponseMsg(3, 1), + ]; + + const result = buildConversationItems(events, false); + expect( + result.items.some( + (i) => + i.type === "session_update" && + i.update.sessionUpdate === "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 fbd0d1ee4b..77d52c398d 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -9,7 +9,11 @@ import { extractSkillButtonId, type SkillButtonId, } from "@features/skill-buttons/prompts"; -import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; +import { + isNotification, + POSTHOG_NOTIFICATIONS, + type PostHogProductId, +} from "@posthog/agent"; import { type AcpMessage, isJsonRpcNotification, @@ -380,6 +384,19 @@ function handleNotification( return; } + if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.RESOURCES_USED)) { + const params = msg.params as + | { products?: { id: PostHogProductId; label: string }[] } + | undefined; + if (!params?.products?.length) return; + if (!b.currentTurn) ensureImplicitTurn(b, ts); + pushItem(b, { + sessionUpdate: "resources_used", + products: params.products, + }); + return; + } + if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)) { const params = msg.params as { stopReason?: string } | undefined; if (!b.currentTurn) return; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx new file mode 100644 index 0000000000..38f1a217dc --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx @@ -0,0 +1,70 @@ +import type { IconProps } from "@phosphor-icons/react"; +import { + BrainIcon, + BugIcon, + ChartLineIcon, + ClipboardTextIcon, + 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 { ComponentType } from "react"; + +/** + * 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, + posthog: SparkleIcon, +}; + +interface ResourcesUsedViewProps { + products: { id: PostHogProductId; label: string }[]; +} + +/** + * A subtle chip row rendered under a completed turn, listing the PostHog + * products the agent touched (via the MCP `exec` tool) while answering. + */ +export function ResourcesUsedView({ products }: ResourcesUsedViewProps) { + if (products.length === 0) return null; + + return ( + + + PostHog resources used + {products.map((product) => { + const Icon = PRODUCT_ICON[product.id] ?? SparkleIcon; + return ( + + + {product.label} + + ); + })} + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx index 90eebd85bf..bcb59b1268 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx @@ -1,6 +1,7 @@ import type { Step } from "@components/ui/StepList"; import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; import type { SessionUpdate, ToolCall } from "@features/sessions/types"; +import type { PostHogProductId } from "@posthog/agent"; import { memo } from "react"; import { AgentMessage } from "./AgentMessage"; @@ -8,6 +9,7 @@ import { CompactBoundaryView } from "./CompactBoundaryView"; import { ConsoleMessage } from "./ConsoleMessage"; import { ErrorNotificationView } from "./ErrorNotificationView"; import { ProgressGroupView } from "./ProgressGroupView"; +import { ResourcesUsedView } from "./ResourcesUsedView"; import { StatusNotificationView } from "./StatusNotificationView"; import { TaskNotificationView } from "./TaskNotificationView"; import { ThoughtView } from "./ThoughtView"; @@ -48,6 +50,10 @@ export type RenderItem = sessionUpdate: "progress_group"; steps: Step[]; isActive: boolean; + } + | { + sessionUpdate: "resources_used"; + products: { id: PostHogProductId; label: string }[]; }; interface SessionUpdateViewProps { @@ -138,6 +144,8 @@ export const SessionUpdateView = memo(function SessionUpdateView({ turnComplete={turnComplete} /> ); + case "resources_used": + return ; default: return null; } diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 759d778080..a471bb24cf 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", } as const; 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 0671f6c3c0..f1019e0bd0 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, }, + turnResources: 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 2ab70025cb..5b6b774de6 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, }, + turnResources: 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 b7d7971a52..7f82e37dbc 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -52,6 +52,10 @@ import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS, } from "../../acp-extensions"; +import { + classifyPostHogSubTool, + POSTHOG_PRODUCTS, +} from "../../posthog-products"; import { createEnrichment, type Enrichment, @@ -435,6 +439,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }; + this.session.turnResources.clear(); await this.broadcastUserMessage(params); @@ -639,6 +644,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, }); + await this.emitResourcesUsed(params.sessionId); + return { stopReason: this.session.cancelled ? "cancelled" : "end_turn", }; @@ -728,6 +735,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, ); + await this.emitResourcesUsed(params.sessionId); + const usage: Usage = { inputTokens: this.session.accumulatedUsage.inputTokens, outputTokens: this.session.accumulatedUsage.outputTokens, @@ -1405,6 +1414,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { outputFormat, settingsManager, onModeChange: this.createOnModeChange(), + onPostHogResourceUsed: this.createOnPostHogResourceUsed(), onProcessSpawned: this.options?.onProcessSpawned, onProcessExited: this.options?.onProcessExited, effort, @@ -1442,6 +1452,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }, + turnResources: new Set(), effort, configOptions: [], promptRunning: false, @@ -1644,6 +1655,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + /** Records the PostHog product behind an executed MCP exec `call` so the + * current turn can report which products it touched. */ + private createOnPostHogResourceUsed() { + return (subTool: string) => { + const product = classifyPostHogSubTool(subTool); + if (product) this.session?.turnResources.add(product); + }; + } + + /** Emits the PostHog products used this turn, then clears the accumulator. + * Fires before the prompt response so the notification lands in the turn. */ + private async emitResourcesUsed(sessionId: string): Promise { + if (!this.session || this.session.turnResources.size === 0) return; + const products = [...this.session.turnResources].map((id) => ({ + id, + label: POSTHOG_PRODUCTS[id], + })); + this.session.turnResources.clear(); + await this.client.extNotification(POSTHOG_NOTIFICATIONS.RESOURCES_USED, { + sessionId, + products, + }); + } + private getExistingSessionState( sessionId: string, ): NewSessionResponse | null { diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 391d0ee2c9..42281edb64 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -176,10 +176,15 @@ export type OnModeChange = (mode: CodeExecutionMode) => Promise; interface CreatePostToolUseHookParams { onModeChange?: OnModeChange; + /** Called with the PostHog MCP sub-tool name after a `call` exec executes. */ + onPostHogResourceUsed?: (subTool: string) => void; } export const createPostToolUseHook = - ({ onModeChange }: CreatePostToolUseHookParams): HookCallback => + ({ + onModeChange, + onPostHogResourceUsed, + }: CreatePostToolUseHookParams): HookCallback => async ( input: HookInput, toolUseID: string | undefined, @@ -191,6 +196,14 @@ export const createPostToolUseHook = await onModeChange("plan"); } + // 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) onPostHogResourceUsed(subTool); + } + 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 c87fb4f096..92f6b700b7 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -58,6 +58,8 @@ export interface BuildOptionsParams { effort?: EffortLevel; enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; + /** Records PostHog product usage from MCP exec calls (per-turn summary). */ + onPostHogResourceUsed?: (subTool: string) => void; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; /** Per-session task state populated by createTaskHook from SDK Task* events. */ @@ -160,6 +162,7 @@ function buildEnvironment(): Record { function buildHooks( userHooks: Options["hooks"], onModeChange: OnModeChange | undefined, + onPostHogResourceUsed: ((subTool: string) => void) | undefined, settingsManager: SettingsManager, logger: Logger, enrichmentDeps: FileEnrichmentDeps | undefined, @@ -169,7 +172,9 @@ function buildHooks( taskState: TaskState, onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { - const postToolUseHooks = [createPostToolUseHook({ onModeChange })]; + const postToolUseHooks = [ + createPostToolUseHook({ onModeChange, onPostHogResourceUsed }), + ]; if (enrichmentDeps && enrichedReadCache) { postToolUseHooks.push( createReadEnrichmentHook(enrichmentDeps, enrichedReadCache), @@ -393,6 +398,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { hooks: buildHooks( params.userProvidedOptions?.hooks, params.onModeChange, + params.onPostHogResourceUsed, 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 c7aeca3dff..92e70c04ba 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,9 @@ export type Session = BaseSession & { effort?: EffortLevel; configOptions: SessionConfigOption[]; accumulatedUsage: AccumulatedUsage; + /** PostHog products used during the current turn, derived from MCP exec + * calls. Reset at the start of each prompt() and emitted at turn end. */ + turnResources: 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 05dc8bae59..aae5f5d8e5 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -4,3 +4,8 @@ export { isMcpToolReadOnly, type McpToolMetadata, } from "./adapters/claude/mcp/tool-metadata"; +export { + classifyPostHogSubTool, + POSTHOG_PRODUCTS, + 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 0000000000..84c06cf459 --- /dev/null +++ b/packages/agent/src/posthog-products.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { classifyPostHogSubTool, POSTHOG_PRODUCTS } from "./posthog-products"; + +describe("classifyPostHogSubTool", () => { + it("maps resource sub-tools to their product", () => { + expect(classifyPostHogSubTool("experiment-list")).toBe("experiments"); + expect(classifyPostHogSubTool("feature-flag-update")).toBe("feature_flags"); + expect(classifyPostHogSubTool("early-access-feature-create")).toBe( + "feature_flags", + ); + expect(classifyPostHogSubTool("error-tracking-issue-update")).toBe( + "error_tracking", + ); + expect(classifyPostHogSubTool("session-recording-get")).toBe( + "session_replay", + ); + expect(classifyPostHogSubTool("survey-create")).toBe("surveys"); + expect(classifyPostHogSubTool("execute-sql")).toBe("sql"); + expect(classifyPostHogSubTool("external-data-sources-list")).toBe( + "data_warehouse", + ); + expect(classifyPostHogSubTool("cdp-functions-list")).toBe("cdp"); + expect(classifyPostHogSubTool("insight-create")).toBe("product_analytics"); + }); + + it("classifies query-* sub-tools by query type", () => { + expect(classifyPostHogSubTool("query-trends")).toBe("product_analytics"); + expect(classifyPostHogSubTool("query-trends-actors")).toBe( + "product_analytics", + ); + expect(classifyPostHogSubTool("query-paths")).toBe("product_analytics"); + expect(classifyPostHogSubTool("query-error-tracking-issues-list")).toBe( + "error_tracking", + ); + expect(classifyPostHogSubTool("query-session-recordings-list")).toBe( + "session_replay", + ); + expect(classifyPostHogSubTool("query-llm-traces-list")).toBe( + "llm_analytics", + ); + expect(classifyPostHogSubTool("query-logs")).toBe("logs"); + expect(classifyPostHogSubTool("query-apm-spans")).toBe("apm"); + }); + + it("does not let a short domain shadow a longer one", () => { + // `llm` must not swallow the distinct `llma-*` domains. + expect(classifyPostHogSubTool("llm-costs")).toBe("llm_analytics"); + expect(classifyPostHogSubTool("llma-personal-spend")).toBe("llm_analytics"); + }); + + it("returns null for admin/meta/introspection domains", () => { + expect(classifyPostHogSubTool("project-get")).toBeNull(); + expect(classifyPostHogSubTool("activity-log-list")).toBeNull(); + expect(classifyPostHogSubTool("docs-search")).toBeNull(); + expect(classifyPostHogSubTool("tasks-list")).toBeNull(); + }); + + it("falls back to the generic product for unrecognized domains", () => { + expect(classifyPostHogSubTool("brand-new-thing-list")).toBe("posthog"); + }); + + it("returns null for empty input", () => { + expect(classifyPostHogSubTool("")).toBeNull(); + expect(classifyPostHogSubTool(" ")).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(); + } + }); +}); diff --git a/packages/agent/src/posthog-products.ts b/packages/agent/src/posthog-products.ts new file mode 100644 index 0000000000..80af9a9207 --- /dev/null +++ b/packages/agent/src/posthog-products.ts @@ -0,0 +1,155 @@ +/** + * 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", + /** 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); + +/** 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]; +} From da2e1da5c5fbbd090b55cc6e4253d9fb3cad8ff8 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 08:00:00 -0700 Subject: [PATCH 02/10] chore(agent): instrument resources_used flow for debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds [resources_used]-tagged debug logging across the full chain so we can see where the per-turn PostHog products row breaks: - PostToolUse hook (hooks.ts): logs the SDK's actual tool_name for any posthog tool, whether isPostHogExecTool matched, and the extracted sub-tool — catches a tool-name/regex mismatch. - claude-agent.ts: logs each classified resource as it's recorded, and whether emitResourcesUsed fires + what it emits. - buildConversationItems.ts (renderer): logs any resource notification the builder receives and whether the RESOURCES_USED branch matched. Agent logs land in the dev logs; renderer logs in chromium.log. Temporary diagnostic — to be reverted once the root cause is found. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- .../components/buildConversationItems.ts | 16 +++++++++++++++ .../agent/src/adapters/claude/claude-agent.ts | 20 +++++++++++++++---- packages/agent/src/adapters/claude/hooks.ts | 14 +++++++++++++ .../src/adapters/claude/session/options.ts | 2 +- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index 77d52c398d..25e1a5cfc9 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -21,6 +21,7 @@ import { isJsonRpcResponse, type UserShellExecuteParams, } from "@shared/types/session-events"; +import { logger } from "@utils/logger"; import { extractPromptDisplayContent } from "@utils/promptContent"; import { type GitActionType, parseGitActionMessage } from "./GitActionMessage"; import type { RenderItem } from "./session-update/SessionUpdateView"; @@ -355,6 +356,16 @@ function handleNotification( ts: number, options?: BuildConversationOptions, ) { + // [resources_used] instrumentation: surface any resources notification the + // builder sees (under any name form), confirming it reaches the renderer. + // Scoped to "resource" to avoid flooding on every usage_update/turn_complete. + if (typeof msg.method === "string" && msg.method.includes("resource")) { + logger.debug("[resources_used] handleNotification resource method", { + method: msg.method, + params: msg.params, + }); + } + if (msg.method === "_array/user_shell_execute") { const params = msg.params as UserShellExecuteParams; const existing = b.shellExecutes.get(params.id); @@ -388,6 +399,11 @@ function handleNotification( const params = msg.params as | { products?: { id: PostHogProductId; label: string }[] } | undefined; + logger.debug("[resources_used] matched RESOURCES_USED branch", { + method: msg.method, + productCount: params?.products?.length ?? 0, + hasCurrentTurn: !!b.currentTurn, + }); if (!params?.products?.length) return; if (!b.currentTurn) ensureImplicitTurn(b, ts); pushItem(b, { diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 7f82e37dbc..f613da27b1 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -52,15 +52,15 @@ import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS, } from "../../acp-extensions"; -import { - classifyPostHogSubTool, - POSTHOG_PRODUCTS, -} from "../../posthog-products"; import { createEnrichment, type Enrichment, type FileEnrichmentDeps, } from "../../enrichment/file-enricher"; +import { + classifyPostHogSubTool, + POSTHOG_PRODUCTS, +} from "../../posthog-products"; import type { PostHogAPIConfig } from "../../types"; import { isCloudRun, unreachable, withTimeout } from "../../utils/common"; import { resolveGithubToken } from "../../utils/github-token"; @@ -1660,6 +1660,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private createOnPostHogResourceUsed() { return (subTool: string) => { const product = classifyPostHogSubTool(subTool); + this.logger.debug("[resources_used] resource used", { + subTool, + product, + turnSizeBefore: this.session?.turnResources.size ?? 0, + hasSession: !!this.session, + }); if (product) this.session?.turnResources.add(product); }; } @@ -1667,12 +1673,18 @@ export class ClaudeAcpAgent extends BaseAcpAgent { /** Emits the PostHog products used this turn, then clears the accumulator. * Fires before the prompt response so the notification lands in the turn. */ private async emitResourcesUsed(sessionId: string): Promise { + this.logger.debug("[resources_used] emitResourcesUsed called", { + sessionId, + hasSession: !!this.session, + turnSize: this.session?.turnResources.size ?? 0, + }); if (!this.session || this.session.turnResources.size === 0) return; const products = [...this.session.turnResources].map((id) => ({ id, label: POSTHOG_PRODUCTS[id], })); this.session.turnResources.clear(); + this.logger.debug("[resources_used] emitting notification", { products }); await this.client.extNotification(POSTHOG_NOTIFICATIONS.RESOURCES_USED, { sessionId, products, diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 42281edb64..56729d3b37 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -178,12 +178,15 @@ interface CreatePostToolUseHookParams { onModeChange?: OnModeChange; /** Called with the PostHog MCP sub-tool name after a `call` exec executes. */ onPostHogResourceUsed?: (subTool: string) => void; + /** Optional logger for [resources_used] instrumentation. */ + logger?: Logger; } export const createPostToolUseHook = ({ onModeChange, onPostHogResourceUsed, + logger, }: CreatePostToolUseHookParams): HookCallback => async ( input: HookInput, @@ -199,6 +202,17 @@ export const createPostToolUseHook = // 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). + // [resources_used] instrumentation: log every tool whose name mentions + // posthog so we can see the SDK's actual tool_name and whether it matches. + if (toolName.toLowerCase().includes("posthog")) { + const matched = isPostHogExecTool(toolName); + logger?.debug("[resources_used] PostToolUse posthog tool", { + toolName, + matched, + subTool: matched ? extractPostHogSubTool(input.tool_input) : null, + hasCallback: !!onPostHogResourceUsed, + }); + } if (onPostHogResourceUsed && isPostHogExecTool(toolName)) { const subTool = extractPostHogSubTool(input.tool_input); if (subTool) onPostHogResourceUsed(subTool); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 92f6b700b7..a5a8d0f6da 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -173,7 +173,7 @@ function buildHooks( onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { const postToolUseHooks = [ - createPostToolUseHook({ onModeChange, onPostHogResourceUsed }), + createPostToolUseHook({ onModeChange, onPostHogResourceUsed, logger }), ]; if (enrichmentDeps && enrichedReadCache) { postToolUseHooks.push( From 2c8f38248cbe24b3641764080e918c0eb67d8900 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 08:23:25 -0700 Subject: [PATCH 03/10] feat(agent): attribute execute-sql resources to the queried product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn that counts feature flags via `SELECT count() FROM feature_flags` was tagged only "SQL" — the classifier saw the execute-sql sub-tool and had no visibility into what the query was about. The chip reflected the mechanism, not the product the user asked about. Now, for execute-sql, the raw command (which embeds the HogQL) is threaded through the PostToolUse hook to a new classifier that extracts FROM/JOIN tables and maps them to products (feature_flags -> Feature flags, experiments -> Experiments, events/persons -> Product analytics, session_replay_events -> Session replay, surveys, logs). A query touching no mapped table falls back to the generic "SQL" chip, so nothing vanishes. - posthog-products.ts: TABLE_PRODUCT map, extractSqlTables, classifyPostHogSqlQuery, classifyPostHogExecCall (returns 0..n products; SQL can touch several tables). - hooks.ts / options.ts: onPostHogResourceUsed now also receives the raw command. - claude-agent.ts: accumulates all products from classifyPostHogExecCall. - Exported the new helpers; added classifyPostHogSqlQuery / classifyPostHogExecCall tests. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- .../agent/src/adapters/claude/claude-agent.ts | 10 +-- packages/agent/src/adapters/claude/hooks.ts | 13 ++- .../src/adapters/claude/session/options.ts | 6 +- packages/agent/src/index.ts | 2 + packages/agent/src/posthog-products.test.ts | 83 ++++++++++++++++++- packages/agent/src/posthog-products.ts | 78 +++++++++++++++++ 6 files changed, 181 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index f613da27b1..f136572332 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -58,7 +58,7 @@ import { type FileEnrichmentDeps, } from "../../enrichment/file-enricher"; import { - classifyPostHogSubTool, + classifyPostHogExecCall, POSTHOG_PRODUCTS, } from "../../posthog-products"; import type { PostHogAPIConfig } from "../../types"; @@ -1658,15 +1658,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent { /** Records the PostHog product behind an executed MCP exec `call` so the * current turn can report which products it touched. */ private createOnPostHogResourceUsed() { - return (subTool: string) => { - const product = classifyPostHogSubTool(subTool); + return (subTool: string, commandText?: string) => { + const products = classifyPostHogExecCall(subTool, commandText); this.logger.debug("[resources_used] resource used", { subTool, - product, + products, turnSizeBefore: this.session?.turnResources.size ?? 0, hasSession: !!this.session, }); - if (product) this.session?.turnResources.add(product); + for (const product of products) this.session?.turnResources.add(product); }; } diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 56729d3b37..8f2904be5a 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -176,8 +176,9 @@ export type OnModeChange = (mode: CodeExecutionMode) => Promise; interface CreatePostToolUseHookParams { onModeChange?: OnModeChange; - /** Called with the PostHog MCP sub-tool name after a `call` exec executes. */ - onPostHogResourceUsed?: (subTool: string) => void; + /** 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; /** Optional logger for [resources_used] instrumentation. */ logger?: Logger; } @@ -215,7 +216,13 @@ export const createPostToolUseHook = } if (onPostHogResourceUsed && isPostHogExecTool(toolName)) { const subTool = extractPostHogSubTool(input.tool_input); - if (subTool) onPostHogResourceUsed(subTool); + if (subTool) { + const command = (input.tool_input as { command?: unknown })?.command; + onPostHogResourceUsed( + subTool, + typeof command === "string" ? command : undefined, + ); + } } if (toolUseID) { diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index a5a8d0f6da..ad283aeac4 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -59,7 +59,7 @@ export interface BuildOptionsParams { enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; /** Records PostHog product usage from MCP exec calls (per-turn summary). */ - onPostHogResourceUsed?: (subTool: string) => void; + onPostHogResourceUsed?: (subTool: string, commandText?: string) => void; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; /** Per-session task state populated by createTaskHook from SDK Task* events. */ @@ -162,7 +162,9 @@ function buildEnvironment(): Record { function buildHooks( userHooks: Options["hooks"], onModeChange: OnModeChange | undefined, - onPostHogResourceUsed: ((subTool: string) => void) | undefined, + onPostHogResourceUsed: + | ((subTool: string, commandText?: string) => void) + | undefined, settingsManager: SettingsManager, logger: Logger, enrichmentDeps: FileEnrichmentDeps | undefined, diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index aae5f5d8e5..8bf0de0ae7 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -5,6 +5,8 @@ export { type McpToolMetadata, } from "./adapters/claude/mcp/tool-metadata"; export { + classifyPostHogExecCall, + classifyPostHogSqlQuery, classifyPostHogSubTool, POSTHOG_PRODUCTS, type PostHogProductId, diff --git a/packages/agent/src/posthog-products.test.ts b/packages/agent/src/posthog-products.test.ts index 84c06cf459..ff53707651 100644 --- a/packages/agent/src/posthog-products.test.ts +++ b/packages/agent/src/posthog-products.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { classifyPostHogSubTool, POSTHOG_PRODUCTS } from "./posthog-products"; +import { + classifyPostHogExecCall, + classifyPostHogSqlQuery, + classifyPostHogSubTool, + POSTHOG_PRODUCTS, +} from "./posthog-products"; describe("classifyPostHogSubTool", () => { it("maps resource sub-tools to their product", () => { @@ -78,3 +83,79 @@ describe("classifyPostHogSubTool", () => { } }); }); + +describe("classifyPostHogSqlQuery", () => { + it("attributes a query to the product behind its tables", () => { + expect( + classifyPostHogSqlQuery("SELECT count() FROM feature_flags"), + ).toEqual(["feature_flags"]); + expect(classifyPostHogSqlQuery("select * from experiments")).toEqual([ + "experiments", + ]); + expect(classifyPostHogSqlQuery("SELECT * FROM events LIMIT 10")).toEqual([ + "product_analytics", + ]); + }); + + 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("returns nothing when no referenced table maps", () => { + expect(classifyPostHogSqlQuery("SELECT 1")).toEqual([]); + expect( + classifyPostHogSqlQuery("SELECT * FROM some_warehouse_table"), + ).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 index 80af9a9207..b818a6bec5 100644 --- a/packages/agent/src/posthog-products.ts +++ b/packages/agent/src/posthog-products.ts @@ -115,6 +115,84 @@ const DOMAIN_PRODUCT: Record = { 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. Keyed by the bare table + * name (last dotted segment, lowercased), so `system.feature_flags` matches too. + * Tables we can't confidently map are simply 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", +}; + +/** Extract bare 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 bare = match[2]?.split(".").pop(); + if (bare) tables.push(bare.toLowerCase()); + 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"; From 56cdfe8b2e49032008fc811c99e5dc4b14539f69 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 08:27:04 -0700 Subject: [PATCH 04/10] fix(agent): use exact table matching to avoid warehouse false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL table → product attribution is an exact-name match, but taking the last dotted segment meant a schema-qualified warehouse table like `stripe.feature_flags` would wrongly map to Feature flags. Now a qualified reference only maps when its schema is a known PostHog schema (`system`); any other prefix (a warehouse source) is left unmapped. Exact matching also means similarly-named tables such as `statsig_feature_flags` or `feature_flags_archive` never match a product — they fall back to the generic SQL chip. Added tests covering both cases. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- packages/agent/src/posthog-products.test.ts | 20 +++++++++++ packages/agent/src/posthog-products.ts | 38 +++++++++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/posthog-products.test.ts b/packages/agent/src/posthog-products.test.ts index ff53707651..e1f0af467a 100644 --- a/packages/agent/src/posthog-products.test.ts +++ b/packages/agent/src/posthog-products.test.ts @@ -125,6 +125,26 @@ describe("classifyPostHogSqlQuery", () => { classifyPostHogSqlQuery("SELECT * FROM some_warehouse_table"), ).toEqual([]); }); + + it("does not match warehouse tables that merely contain a product name", () => { + // Exact-name match only — a similarly-named warehouse table is left alone. + expect( + classifyPostHogSqlQuery("SELECT * FROM statsig_feature_flags"), + ).toEqual([]); + expect( + classifyPostHogSqlQuery("SELECT * FROM feature_flags_archive"), + ).toEqual([]); + }); + + it("does not match a warehouse table qualified with a non-PostHog schema", () => { + // `stripe.feature_flags` is a warehouse table, not the PostHog one. + expect( + classifyPostHogSqlQuery("SELECT * FROM stripe.feature_flags"), + ).toEqual([]); + expect(classifyPostHogSqlQuery("SELECT * FROM my_source.events")).toEqual( + [], + ); + }); }); describe("classifyPostHogExecCall", () => { diff --git a/packages/agent/src/posthog-products.ts b/packages/agent/src/posthog-products.ts index b818a6bec5..435d1e4184 100644 --- a/packages/agent/src/posthog-products.ts +++ b/packages/agent/src/posthog-products.ts @@ -118,10 +118,10 @@ 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. Keyed by the bare table - * name (last dotted segment, lowercased), so `system.feature_flags` matches too. - * Tables we can't confidently map are simply omitted — they contribute nothing, - * and a query touching no mapped table falls back to the "sql" product. + * → 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", @@ -141,7 +141,28 @@ const TABLE_PRODUCT: Record = { logs: "logs", }; -/** Extract bare table names referenced after FROM/JOIN in a SQL/HogQL string. */ +/** + * 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. @@ -150,8 +171,11 @@ function extractSqlTables(sql: string): string[] { 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 bare = match[2]?.split(".").pop(); - if (bare) tables.push(bare.toLowerCase()); + const ref = match[2]; + if (ref) { + const table = normalizePostHogTableRef(ref); + if (table) tables.push(table); + } match = re.exec(sql); } return tables; From 3248dc06c88b49cc182215423a4f6306a7e7de9b Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 08:38:49 -0700 Subject: [PATCH 05/10] feat: show PostHog resources in a persistent, deduped bar above the composer Reworks the resources-used display from a per-turn inline row into a persistent bar above the chat composer that fills in live as resources are used and shows each product only once for the whole session. Agent: - Session accumulator renamed turnResources -> sessionResources; it now lives for the whole session and is never reset between turns. - Detection emits `_posthog/resources_used` immediately for each newly-seen product (session-wide dedup), instead of buffering one emit at turn end. Removed the two turn-end emit sites and the per-prompt reset. Renderer: - New SessionResourcesBar (mirrors PlanStatusBar) rendered above the composer in SessionView. accumulateSessionResources derives the deduped, first-seen-ordered product list from the session's events, so it works for both live streaming and log replay. - Removed the inline conversation-item path: buildConversationItems no longer emits a resources_used item, the SessionUpdateView case/type are gone, and ResourcesUsedView is deleted. Tests updated/added: accumulateSessionResources (dedup, ordering, empty), buildConversationItems no longer renders an inline item, agent session-field rename. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- .../components/SessionResourcesBar.test.ts | 66 ++++++++++ .../components/SessionResourcesBar.tsx | 119 ++++++++++++++++++ .../sessions/components/SessionView.tsx | 3 + .../components/buildConversationItems.test.ts | 45 +------ .../components/buildConversationItems.ts | 37 +----- .../session-update/ResourcesUsedView.tsx | 70 ----------- .../session-update/SessionUpdateView.tsx | 8 -- .../claude/claude-agent.refresh.test.ts | 2 +- .../claude/claude-agent.slash-command.test.ts | 2 +- .../agent/src/adapters/claude/claude-agent.ts | 43 +++---- packages/agent/src/adapters/claude/types.ts | 8 +- 11 files changed, 224 insertions(+), 179 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts create mode 100644 apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx delete mode 100644 apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts new file mode 100644 index 0000000000..8158d1c386 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts @@ -0,0 +1,66 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { describe, expect, it } from "vitest"; +import { accumulateSessionResources } from "./SessionResourcesBar"; + +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/SessionResourcesBar.tsx b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx new file mode 100644 index 0000000000..c487176856 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx @@ -0,0 +1,119 @@ +import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; +import type { IconProps } from "@phosphor-icons/react"; +import { + BrainIcon, + BugIcon, + ChartLineIcon, + ClipboardTextIcon, + DatabaseIcon, + FileTextIcon, + FlagIcon, + FlaskIcon, + GaugeIcon, + GlobeIcon, + PlugIcon, + SparkleIcon, + TableIcon, + VideoIcon, +} from "@phosphor-icons/react"; +import { + isNotification, + POSTHOG_NOTIFICATIONS, + type PostHogProductId, +} from "@posthog/agent"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { + type AcpMessage, + isJsonRpcNotification, +} from "@shared/types/session-events"; +import { type ComponentType, useMemo } from "react"; + +/** + * 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, + posthog: SparkleIcon, +}; + +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. + */ +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()]; +} + +interface SessionResourcesBarProps { + events: AcpMessage[]; +} + +/** + * Persistent bar above the composer listing the PostHog products the agent has + * touched (via the MCP `exec` tool) so far this session. 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; + return ( + + + {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 9175280bf1..74a7555c98 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[]; @@ -552,6 +553,8 @@ export function SessionView({ compact={compact} /> + + {hasError ? ( 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 c515eedd3d..dc7c41b795 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts @@ -455,57 +455,24 @@ describe("buildConversationItems", () => { }); describe("resources_used", () => { - it("renders a resources_used item after the turn's final message", () => { - const products = [ - { id: "experiments", label: "Experiments" }, - { id: "sql", label: "SQL" }, - ]; + 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, products), + resourcesUsedMsg(3, [{ id: "experiments", label: "Experiments" }]), promptResponseMsg(4, 1), ]; const result = buildConversationItems(events, false); - const idx = result.items.findIndex( - (i) => - i.type === "session_update" && - i.update.sessionUpdate === "resources_used", - ); - expect(idx).toBeGreaterThanOrEqual(0); - - // Must land after the agent's final message in the turn. - const msgIdx = result.items.findIndex( - (i) => - i.type === "session_update" && - i.update.sessionUpdate === "agent_message_chunk", - ); - expect(idx).toBeGreaterThan(msgIdx); - - const item = result.items[idx]; - if ( - item.type === "session_update" && - item.update.sessionUpdate === "resources_used" - ) { - expect(item.update.products).toEqual(products); - } - }); - - it("ignores a resources_used notification with no products", () => { - const events: AcpMessage[] = [ - userPromptMsg(1, 1, "hi"), - resourcesUsedMsg(2, []), - promptResponseMsg(3, 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" && - i.update.sessionUpdate === "resources_used", + // biome-ignore lint/suspicious/noExplicitAny: removed union member + (i.update.sessionUpdate as any) === "resources_used", ), ).toBe(false); }); diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index 25e1a5cfc9..ecd0572a2d 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -9,11 +9,7 @@ import { extractSkillButtonId, type SkillButtonId, } from "@features/skill-buttons/prompts"; -import { - isNotification, - POSTHOG_NOTIFICATIONS, - type PostHogProductId, -} from "@posthog/agent"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; import { type AcpMessage, isJsonRpcNotification, @@ -21,7 +17,6 @@ import { isJsonRpcResponse, type UserShellExecuteParams, } from "@shared/types/session-events"; -import { logger } from "@utils/logger"; import { extractPromptDisplayContent } from "@utils/promptContent"; import { type GitActionType, parseGitActionMessage } from "./GitActionMessage"; import type { RenderItem } from "./session-update/SessionUpdateView"; @@ -356,16 +351,6 @@ function handleNotification( ts: number, options?: BuildConversationOptions, ) { - // [resources_used] instrumentation: surface any resources notification the - // builder sees (under any name form), confirming it reaches the renderer. - // Scoped to "resource" to avoid flooding on every usage_update/turn_complete. - if (typeof msg.method === "string" && msg.method.includes("resource")) { - logger.debug("[resources_used] handleNotification resource method", { - method: msg.method, - params: msg.params, - }); - } - if (msg.method === "_array/user_shell_execute") { const params = msg.params as UserShellExecuteParams; const existing = b.shellExecutes.get(params.id); @@ -395,23 +380,9 @@ function handleNotification( return; } - if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.RESOURCES_USED)) { - const params = msg.params as - | { products?: { id: PostHogProductId; label: string }[] } - | undefined; - logger.debug("[resources_used] matched RESOURCES_USED branch", { - method: msg.method, - productCount: params?.products?.length ?? 0, - hasCurrentTurn: !!b.currentTurn, - }); - if (!params?.products?.length) return; - if (!b.currentTurn) ensureImplicitTurn(b, ts); - pushItem(b, { - sessionUpdate: "resources_used", - products: params.products, - }); - 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; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx deleted file mode 100644 index 38f1a217dc..0000000000 --- a/apps/code/src/renderer/features/sessions/components/session-update/ResourcesUsedView.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { IconProps } from "@phosphor-icons/react"; -import { - BrainIcon, - BugIcon, - ChartLineIcon, - ClipboardTextIcon, - 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 { ComponentType } from "react"; - -/** - * 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, - posthog: SparkleIcon, -}; - -interface ResourcesUsedViewProps { - products: { id: PostHogProductId; label: string }[]; -} - -/** - * A subtle chip row rendered under a completed turn, listing the PostHog - * products the agent touched (via the MCP `exec` tool) while answering. - */ -export function ResourcesUsedView({ products }: ResourcesUsedViewProps) { - if (products.length === 0) return null; - - return ( - - - PostHog resources used - {products.map((product) => { - const Icon = PRODUCT_ICON[product.id] ?? SparkleIcon; - return ( - - - {product.label} - - ); - })} - - - ); -} diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx index bcb59b1268..90eebd85bf 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx @@ -1,7 +1,6 @@ import type { Step } from "@components/ui/StepList"; import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; import type { SessionUpdate, ToolCall } from "@features/sessions/types"; -import type { PostHogProductId } from "@posthog/agent"; import { memo } from "react"; import { AgentMessage } from "./AgentMessage"; @@ -9,7 +8,6 @@ import { CompactBoundaryView } from "./CompactBoundaryView"; import { ConsoleMessage } from "./ConsoleMessage"; import { ErrorNotificationView } from "./ErrorNotificationView"; import { ProgressGroupView } from "./ProgressGroupView"; -import { ResourcesUsedView } from "./ResourcesUsedView"; import { StatusNotificationView } from "./StatusNotificationView"; import { TaskNotificationView } from "./TaskNotificationView"; import { ThoughtView } from "./ThoughtView"; @@ -50,10 +48,6 @@ export type RenderItem = sessionUpdate: "progress_group"; steps: Step[]; isActive: boolean; - } - | { - sessionUpdate: "resources_used"; - products: { id: PostHogProductId; label: string }[]; }; interface SessionUpdateViewProps { @@ -144,8 +138,6 @@ export const SessionUpdateView = memo(function SessionUpdateView({ turnComplete={turnComplete} /> ); - case "resources_used": - return ; default: return null; } 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 f1019e0bd0..580ec929c2 100644 --- a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts @@ -97,7 +97,7 @@ function installFakeSession( cachedReadTokens: 0, cachedWriteTokens: 0, }, - turnResources: new Set(), + 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 5b6b774de6..810f34e4d8 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,7 +58,7 @@ function installFakeSession( cachedReadTokens: 0, cachedWriteTokens: 0, }, - turnResources: new Set(), + 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 f136572332..4ef67ec5b1 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -60,6 +60,7 @@ import { import { classifyPostHogExecCall, POSTHOG_PRODUCTS, + type PostHogProductId, } from "../../posthog-products"; import type { PostHogAPIConfig } from "../../types"; import { isCloudRun, unreachable, withTimeout } from "../../utils/common"; @@ -439,7 +440,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }; - this.session.turnResources.clear(); + // sessionResources is intentionally NOT reset here — the products list + // accumulates across the whole session and is deduped, not per-turn. await this.broadcastUserMessage(params); @@ -644,8 +646,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, }); - await this.emitResourcesUsed(params.sessionId); - return { stopReason: this.session.cancelled ? "cancelled" : "end_turn", }; @@ -735,8 +735,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, ); - await this.emitResourcesUsed(params.sessionId); - const usage: Usage = { inputTokens: this.session.accumulatedUsage.inputTokens, outputTokens: this.session.accumulatedUsage.outputTokens, @@ -1452,7 +1450,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }, - turnResources: new Set(), + sessionResources: new Set(), effort, configOptions: [], promptRunning: false, @@ -1659,34 +1657,31 @@ export class ClaudeAcpAgent extends BaseAcpAgent { * current turn can report which products it touched. */ private createOnPostHogResourceUsed() { return (subTool: string, commandText?: string) => { + if (!this.session) return; const products = classifyPostHogExecCall(subTool, commandText); + // Session-wide dedup: only the first use of a product emits, so the + // client's persistent list shows each chip once across all turns. + const added = products.filter( + (p) => !this.session.sessionResources.has(p), + ); + for (const product of added) this.session.sessionResources.add(product); this.logger.debug("[resources_used] resource used", { subTool, products, - turnSizeBefore: this.session?.turnResources.size ?? 0, - hasSession: !!this.session, + added, + sessionSize: this.session.sessionResources.size, }); - for (const product of products) this.session?.turnResources.add(product); + if (added.length > 0) void this.emitResourcesUsed(added); }; } - /** Emits the PostHog products used this turn, then clears the accumulator. - * Fires before the prompt response so the notification lands in the turn. */ - private async emitResourcesUsed(sessionId: string): Promise { - this.logger.debug("[resources_used] emitResourcesUsed called", { - sessionId, - hasSession: !!this.session, - turnSize: this.session?.turnResources.size ?? 0, - }); - if (!this.session || this.session.turnResources.size === 0) return; - const products = [...this.session.turnResources].map((id) => ({ - id, - label: POSTHOG_PRODUCTS[id], - })); - this.session.turnResources.clear(); + /** 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] })); this.logger.debug("[resources_used] emitting notification", { products }); await this.client.extNotification(POSTHOG_NOTIFICATIONS.RESOURCES_USED, { - sessionId, + sessionId: this.sessionId, products, }); } diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 92e70c04ba..ddbc88f056 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -58,9 +58,11 @@ export type Session = BaseSession & { effort?: EffortLevel; configOptions: SessionConfigOption[]; accumulatedUsage: AccumulatedUsage; - /** PostHog products used during the current turn, derived from MCP exec - * calls. Reset at the start of each prompt() and emitted at turn end. */ - turnResources: Set; + /** 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 */ From fc3ea4b787637af584f8eb14caa3b338be664d62 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 08:45:45 -0700 Subject: [PATCH 06/10] style: blend resources bar into background, drop gray band and top border The resources bar sat in a full-width `bg-gray-2` band with a top border, which read as a heavy separate strip. Remove both so it inherits the chat area's background and sits flush above the composer. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- .../features/sessions/components/SessionResourcesBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx index c487176856..dd3ca263ba 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx @@ -97,7 +97,7 @@ export function SessionResourcesBar({ events }: SessionResourcesBarProps) { if (products.length === 0) return null; return ( - + From f28a446b78df9120938e77e84098f59883e32225 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 08:51:55 -0700 Subject: [PATCH 07/10] feat: link resource chips to product docs, add bottom margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each resource chip now links to its product's page on posthog.com (e.g. Feature flags → /docs/feature-flags), opened via openUrlInBrowser (os.openExternal). Doc URLs verified against posthog.com/docs. The map is Partial — products without a dedicated docs page (apm) stay non-clickable rather than linking somewhere misleading. Clickable chips get a pointer cursor, hover state, and a title tooltip. Also adds bottom margin (mb-3) so the bar sits less tightly against the composer. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- .../components/SessionResourcesBar.tsx | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx index dd3ca263ba..0d6df15ced 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx @@ -26,6 +26,7 @@ import { type AcpMessage, isJsonRpcNotification, } from "@shared/types/session-events"; +import { openUrlInBrowser } from "@utils/browser"; import { type ComponentType, useMemo } from "react"; /** @@ -49,6 +50,28 @@ const PRODUCT_ICON: Record> = { 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", + posthog: "https://posthog.com/docs", +}; + interface ResourceProduct { id: PostHogProductId; label: string; @@ -97,16 +120,29 @@ export function SessionResourcesBar({ events }: SessionResourcesBarProps) { 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} From 3bb5e204364ffc0be241910e48574a3c0848c4e7 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 09:07:37 -0700 Subject: [PATCH 08/10] chore(agent): remove resources_used debug instrumentation Strips the temporary [resources_used]-tagged debug logging now that the feature is verified: the PostToolUse hook's posthog-tool log and its logger param, and the two debug logs in createOnPostHogResourceUsed / emitResourcesUsed. Detection, classification, and emission are unchanged. Generated-By: PostHog Code Task-Id: f2ff4d75-f51e-4618-9e64-68ca4be237fa --- packages/agent/src/adapters/claude/claude-agent.ts | 11 ++--------- packages/agent/src/adapters/claude/hooks.ts | 14 -------------- .../agent/src/adapters/claude/session/options.ts | 2 +- 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 4ef67ec5b1..d8534a62fe 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1653,8 +1653,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } - /** Records the PostHog product behind an executed MCP exec `call` so the - * current turn can report which products it touched. */ + /** 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) => { if (!this.session) return; @@ -1665,12 +1665,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent { (p) => !this.session.sessionResources.has(p), ); for (const product of added) this.session.sessionResources.add(product); - this.logger.debug("[resources_used] resource used", { - subTool, - products, - added, - sessionSize: this.session.sessionResources.size, - }); if (added.length > 0) void this.emitResourcesUsed(added); }; } @@ -1679,7 +1673,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent { * 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] })); - this.logger.debug("[resources_used] emitting notification", { products }); await this.client.extNotification(POSTHOG_NOTIFICATIONS.RESOURCES_USED, { sessionId: this.sessionId, products, diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 8f2904be5a..e840391dbe 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -179,15 +179,12 @@ interface CreatePostToolUseHookParams { /** 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; - /** Optional logger for [resources_used] instrumentation. */ - logger?: Logger; } export const createPostToolUseHook = ({ onModeChange, onPostHogResourceUsed, - logger, }: CreatePostToolUseHookParams): HookCallback => async ( input: HookInput, @@ -203,17 +200,6 @@ export const createPostToolUseHook = // 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). - // [resources_used] instrumentation: log every tool whose name mentions - // posthog so we can see the SDK's actual tool_name and whether it matches. - if (toolName.toLowerCase().includes("posthog")) { - const matched = isPostHogExecTool(toolName); - logger?.debug("[resources_used] PostToolUse posthog tool", { - toolName, - matched, - subTool: matched ? extractPostHogSubTool(input.tool_input) : null, - hasCallback: !!onPostHogResourceUsed, - }); - } if (onPostHogResourceUsed && isPostHogExecTool(toolName)) { const subTool = extractPostHogSubTool(input.tool_input); if (subTool) { diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index ad283aeac4..fd79579978 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -175,7 +175,7 @@ function buildHooks( onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { const postToolUseHooks = [ - createPostToolUseHook({ onModeChange, onPostHogResourceUsed, logger }), + createPostToolUseHook({ onModeChange, onPostHogResourceUsed }), ]; if (enrichmentDeps && enrichedReadCache) { postToolUseHooks.push( From a27e304082aa0dab04fa1fe941b25939e257cc16 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 09:31:35 -0700 Subject: [PATCH 09/10] chore(agent): tidy resources_used PR for review - Fix stale comment on onPostHogResourceUsed (it accumulates session-wide and deduped, not a per-turn summary). - Trim @posthog/agent index to only re-export the PostHogProductId type; the classifier functions and POSTHOG_PRODUCTS const aren't used outside the package (claude-agent.ts imports them via the relative path), so the re-exports were dead public-API surface. Generated-By: PostHog Code Task-Id: f110e589-9df9-40fc-9fff-7ff10356aca6 --- packages/agent/src/adapters/claude/session/options.ts | 2 +- packages/agent/src/index.ts | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index fd79579978..94a120209e 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -58,7 +58,7 @@ export interface BuildOptionsParams { effort?: EffortLevel; enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; - /** Records PostHog product usage from MCP exec calls (per-turn summary). */ + /** Records PostHog product usage from MCP exec calls (deduped, session-wide). */ onPostHogResourceUsed?: (subTool: string, commandText?: string) => void; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 8bf0de0ae7..c73615f419 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -4,10 +4,4 @@ export { isMcpToolReadOnly, type McpToolMetadata, } from "./adapters/claude/mcp/tool-metadata"; -export { - classifyPostHogExecCall, - classifyPostHogSqlQuery, - classifyPostHogSubTool, - POSTHOG_PRODUCTS, - type PostHogProductId, -} from "./posthog-products"; +export type { PostHogProductId } from "./posthog-products"; From 4fae035c5b72f90030dd2ce64079820055c096fd Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Thu, 4 Jun 2026 10:10:31 -0700 Subject: [PATCH 10/10] feat(agent): add Code resource chip, fix test, parameterise tests Adds a "Code" chip (links to posthog.com/code) to the session resources bar and surfaces it whenever the agent reads a file from the codebase: - posthog-products.ts: new `code` product. - PostToolUse hook: fires `onCodeFileRead` on any `Read` tool use; the agent records the `code` product through the same session-wide dedup + emit path as MCP resources. Detection lives in the hook (not the enrichment hook) so it isn't gated on PostHog instrumentation being present in the file. - SessionResourcesBar.tsx: CodeIcon + docs URL for the chip. Fixes the failing unit-test suite: SessionResourcesBar.test.ts imported the component, which transitively pulls in the tRPC client (via openUrlInBrowser) and throws "Could not find electronTRPC global" under vitest. Extracted the pure `accumulateSessionResources` into its own dependency-free module (mirroring buildConversationItems.ts) and pointed the renamed test at it. Addresses the review comment: parameterised the multi-assertion blocks in posthog-products.test.ts with `it.each` so a single failing case no longer masks the rest. Generated-By: PostHog Code Task-Id: f110e589-9df9-40fc-9fff-7ff10356aca6 --- .../components/SessionResourcesBar.tsx | 54 ++---- ....ts => accumulateSessionResources.test.ts} | 2 +- .../components/accumulateSessionResources.ts | 44 +++++ .../agent/src/adapters/claude/claude-agent.ts | 28 ++- .../agent/src/adapters/claude/hooks.test.ts | 33 ++++ packages/agent/src/adapters/claude/hooks.ts | 9 + .../src/adapters/claude/session/options.ts | 11 +- packages/agent/src/posthog-products.test.ts | 167 ++++++++---------- packages/agent/src/posthog-products.ts | 3 + 9 files changed, 207 insertions(+), 144 deletions(-) rename apps/code/src/renderer/features/sessions/components/{SessionResourcesBar.test.ts => accumulateSessionResources.test.ts} (96%) create mode 100644 apps/code/src/renderer/features/sessions/components/accumulateSessionResources.ts diff --git a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx index 0d6df15ced..26b06f47dd 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.tsx @@ -5,6 +5,7 @@ import { BugIcon, ChartLineIcon, ClipboardTextIcon, + CodeIcon, DatabaseIcon, FileTextIcon, FlagIcon, @@ -16,18 +17,12 @@ import { TableIcon, VideoIcon, } from "@phosphor-icons/react"; -import { - isNotification, - POSTHOG_NOTIFICATIONS, - type PostHogProductId, -} from "@posthog/agent"; +import type { PostHogProductId } from "@posthog/agent"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; -import { - type AcpMessage, - isJsonRpcNotification, -} from "@shared/types/session-events"; +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: @@ -47,6 +42,7 @@ const PRODUCT_ICON: Record> = { logs: FileTextIcon, apm: GaugeIcon, sql: TableIcon, + code: CodeIcon, posthog: SparkleIcon, }; @@ -69,50 +65,20 @@ const PRODUCT_DOC_URL: Partial> = { 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 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. - */ -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()]; -} - interface SessionResourcesBarProps { events: AcpMessage[]; } /** * Persistent bar above the composer listing the PostHog products the agent has - * touched (via the MCP `exec` tool) so far this session. 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. + * 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]); diff --git a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts b/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.test.ts similarity index 96% rename from apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts rename to apps/code/src/renderer/features/sessions/components/accumulateSessionResources.test.ts index 8158d1c386..9a0fdf3abf 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionResourcesBar.test.ts +++ b/apps/code/src/renderer/features/sessions/components/accumulateSessionResources.test.ts @@ -1,6 +1,6 @@ import type { AcpMessage } from "@shared/types/session-events"; import { describe, expect, it } from "vitest"; -import { accumulateSessionResources } from "./SessionResourcesBar"; +import { accumulateSessionResources } from "./accumulateSessionResources"; function resourcesUsedMsg( ts: number, 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 0000000000..dd6f04e22e --- /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/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index d8534a62fe..8a18fe9664 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1413,6 +1413,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { settingsManager, onModeChange: this.createOnModeChange(), onPostHogResourceUsed: this.createOnPostHogResourceUsed(), + onCodeFileRead: this.createOnCodeFileRead(), onProcessSpawned: this.options?.onProcessSpawned, onProcessExited: this.options?.onProcessExited, effort, @@ -1657,18 +1658,29 @@ export class ClaudeAcpAgent extends BaseAcpAgent { * any newly-seen product so the client's persistent list can update live. */ private createOnPostHogResourceUsed() { return (subTool: string, commandText?: string) => { - if (!this.session) return; - const products = classifyPostHogExecCall(subTool, commandText); - // Session-wide dedup: only the first use of a product emits, so the - // client's persistent list shows each chip once across all turns. - const added = products.filter( - (p) => !this.session.sessionResources.has(p), + this.recordSessionResources( + classifyPostHogExecCall(subTool, commandText), ); - for (const product of added) this.session.sessionResources.add(product); - if (added.length > 0) void this.emitResourcesUsed(added); }; } + /** 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 { diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index 2e3dc5ab4e..bf7d2bd5db 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 e840391dbe..ba5c007d87 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -179,12 +179,16 @@ interface CreatePostToolUseHookParams { /** 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, onPostHogResourceUsed, + onCodeFileRead, }: CreatePostToolUseHookParams): HookCallback => async ( input: HookInput, @@ -197,6 +201,11 @@ 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). diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 94a120209e..efd2c3c40d 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -60,6 +60,9 @@ export interface BuildOptionsParams { 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. */ @@ -165,6 +168,7 @@ function buildHooks( onPostHogResourceUsed: | ((subTool: string, commandText?: string) => void) | undefined, + onCodeFileRead: (() => void) | undefined, settingsManager: SettingsManager, logger: Logger, enrichmentDeps: FileEnrichmentDeps | undefined, @@ -175,7 +179,11 @@ function buildHooks( onTaskStateChange: (() => Promise) | undefined, ): Options["hooks"] { const postToolUseHooks = [ - createPostToolUseHook({ onModeChange, onPostHogResourceUsed }), + createPostToolUseHook({ + onModeChange, + onPostHogResourceUsed, + onCodeFileRead, + }), ]; if (enrichmentDeps && enrichedReadCache) { postToolUseHooks.push( @@ -401,6 +409,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.userProvidedOptions?.hooks, params.onModeChange, params.onPostHogResourceUsed, + params.onCodeFileRead, params.settingsManager, params.logger, params.enrichmentDeps, diff --git a/packages/agent/src/posthog-products.test.ts b/packages/agent/src/posthog-products.test.ts index e1f0af467a..a81beb07fa 100644 --- a/packages/agent/src/posthog-products.test.ts +++ b/packages/agent/src/posthog-products.test.ts @@ -7,66 +7,58 @@ import { } from "./posthog-products"; describe("classifyPostHogSubTool", () => { - it("maps resource sub-tools to their product", () => { - expect(classifyPostHogSubTool("experiment-list")).toBe("experiments"); - expect(classifyPostHogSubTool("feature-flag-update")).toBe("feature_flags"); - expect(classifyPostHogSubTool("early-access-feature-create")).toBe( - "feature_flags", - ); - expect(classifyPostHogSubTool("error-tracking-issue-update")).toBe( - "error_tracking", - ); - expect(classifyPostHogSubTool("session-recording-get")).toBe( - "session_replay", - ); - expect(classifyPostHogSubTool("survey-create")).toBe("surveys"); - expect(classifyPostHogSubTool("execute-sql")).toBe("sql"); - expect(classifyPostHogSubTool("external-data-sources-list")).toBe( - "data_warehouse", - ); - expect(classifyPostHogSubTool("cdp-functions-list")).toBe("cdp"); - expect(classifyPostHogSubTool("insight-create")).toBe("product_analytics"); - }); - - it("classifies query-* sub-tools by query type", () => { - expect(classifyPostHogSubTool("query-trends")).toBe("product_analytics"); - expect(classifyPostHogSubTool("query-trends-actors")).toBe( - "product_analytics", - ); - expect(classifyPostHogSubTool("query-paths")).toBe("product_analytics"); - expect(classifyPostHogSubTool("query-error-tracking-issues-list")).toBe( - "error_tracking", - ); - expect(classifyPostHogSubTool("query-session-recordings-list")).toBe( - "session_replay", - ); - expect(classifyPostHogSubTool("query-llm-traces-list")).toBe( - "llm_analytics", - ); - expect(classifyPostHogSubTool("query-logs")).toBe("logs"); - expect(classifyPostHogSubTool("query-apm-spans")).toBe("apm"); - }); - - it("does not let a short domain shadow a longer one", () => { - // `llm` must not swallow the distinct `llma-*` domains. - expect(classifyPostHogSubTool("llm-costs")).toBe("llm_analytics"); - expect(classifyPostHogSubTool("llma-personal-spend")).toBe("llm_analytics"); - }); - - it("returns null for admin/meta/introspection domains", () => { - expect(classifyPostHogSubTool("project-get")).toBeNull(); - expect(classifyPostHogSubTool("activity-log-list")).toBeNull(); - expect(classifyPostHogSubTool("docs-search")).toBeNull(); - expect(classifyPostHogSubTool("tasks-list")).toBeNull(); - }); + 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("returns null for empty input", () => { - expect(classifyPostHogSubTool("")).toBeNull(); - expect(classifyPostHogSubTool(" ")).toBeNull(); + it.each(["", " "])("returns null for empty input %j", (subTool) => { + expect(classifyPostHogSubTool(subTool)).toBeNull(); }); it("only emits ids that exist in POSTHOG_PRODUCTS", () => { @@ -85,16 +77,12 @@ describe("classifyPostHogSubTool", () => { }); describe("classifyPostHogSqlQuery", () => { - it("attributes a query to the product behind its tables", () => { - expect( - classifyPostHogSqlQuery("SELECT count() FROM feature_flags"), - ).toEqual(["feature_flags"]); - expect(classifyPostHogSqlQuery("select * from experiments")).toEqual([ - "experiments", - ]); - expect(classifyPostHogSqlQuery("SELECT * FROM events LIMIT 10")).toEqual([ - "product_analytics", - ]); + 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", () => { @@ -119,31 +107,30 @@ describe("classifyPostHogSqlQuery", () => { expect(products.filter((p) => p === "product_analytics")).toHaveLength(1); }); - it("returns nothing when no referenced table maps", () => { - expect(classifyPostHogSqlQuery("SELECT 1")).toEqual([]); - expect( - classifyPostHogSqlQuery("SELECT * FROM some_warehouse_table"), - ).toEqual([]); - }); - - it("does not match warehouse tables that merely contain a product name", () => { - // Exact-name match only — a similarly-named warehouse table is left alone. - expect( - classifyPostHogSqlQuery("SELECT * FROM statsig_feature_flags"), - ).toEqual([]); - expect( - classifyPostHogSqlQuery("SELECT * FROM feature_flags_archive"), - ).toEqual([]); - }); - - it("does not match a warehouse table qualified with a non-PostHog schema", () => { - // `stripe.feature_flags` is a warehouse table, not the PostHog one. - expect( - classifyPostHogSqlQuery("SELECT * FROM stripe.feature_flags"), - ).toEqual([]); - expect(classifyPostHogSqlQuery("SELECT * FROM my_source.events")).toEqual( - [], - ); + 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([]); }); }); diff --git a/packages/agent/src/posthog-products.ts b/packages/agent/src/posthog-products.ts index 435d1e4184..8ec186ca1b 100644 --- a/packages/agent/src/posthog-products.ts +++ b/packages/agent/src/posthog-products.ts @@ -28,6 +28,9 @@ export const POSTHOG_PRODUCTS = { 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;