diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 9d1456b39..22c8922b0 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -57,6 +57,7 @@ import { } from "../../enrichment/file-enricher"; import { classifyPostHogExecCall, + isUnclassifiedPostHogSubTool, POSTHOG_PRODUCTS, type PostHogProductId, } from "../../posthog-products"; @@ -2005,6 +2006,12 @@ 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) => { + // Surface PostHog calls whose domain we don't recognize yet, so the gap + // can be closed in `DOMAIN_PRODUCT` rather than the call silently + // surfacing no chip. Deliberately-suppressed admin domains don't log. + if (isUnclassifiedPostHogSubTool(subTool)) { + this.logger.debug("Unclassified PostHog MCP sub-tool", { subTool }); + } this.recordSessionResources( classifyPostHogExecCall(subTool, commandText), ); diff --git a/packages/agent/src/posthog-products.test.ts b/packages/agent/src/posthog-products.test.ts index e8cdfd444..f1c2c1bd1 100644 --- a/packages/agent/src/posthog-products.test.ts +++ b/packages/agent/src/posthog-products.test.ts @@ -3,6 +3,7 @@ import { classifyPostHogExecCall, classifyPostHogSqlQuery, classifyPostHogSubTool, + isUnclassifiedPostHogSubTool, POSTHOG_PRODUCTS, type PostHogProductId, } from "./posthog-products"; @@ -23,6 +24,23 @@ describe("classifyPostHogSubTool", () => { expect(classifyPostHogSubTool(subTool)).toBe(product); }); + // Domains whose canonical token differs from the longer per-sub-tool name we + // used to key on — these previously fell through to the generic fallback. + it.each([ + ["llma-evaluation-list", "llm_analytics"], + ["llma-clustering-get", "llm_analytics"], + ["llma-trace-get", "llm_analytics"], + ["notebook-create", "product_analytics"], + ["cdp-function-update", "cdp"], + ["cdp-function-templates-list", "cdp"], + ["external-data-schemas-list", "data_warehouse"], + ["event-definition-list", "product_analytics"], + ["web-analytics-weekly-digest-get", "web_analytics"], + ["vision-scanners-create", "session_replay"], + ])("maps newly-covered sub-tool %s to %s", (subTool, product) => { + expect(classifyPostHogSubTool(subTool)).toBe(product); + }); + it.each([ ["query-trends", "product_analytics"], ["query-trends-actors", "product_analytics"], @@ -69,8 +87,8 @@ describe("classifyPostHogSubTool", () => { }, ); - it("falls back to the generic product for unrecognized domains", () => { - expect(classifyPostHogSubTool("brand-new-thing-list")).toBe("posthog"); + it("returns null for unrecognized domains rather than a generic chip", () => { + expect(classifyPostHogSubTool("brand-new-thing-list")).toBeNull(); }); it.each(["", " "])("returns null for empty input %j", (subTool) => { @@ -92,6 +110,35 @@ describe("classifyPostHogSubTool", () => { }); }); +describe("isUnclassifiedPostHogSubTool", () => { + it.each(["brand-new-thing-list", "totally-made-up-get"])( + "flags genuinely-unknown domain %s", + (subTool) => { + expect(isUnclassifiedPostHogSubTool(subTool)).toBe(true); + }, + ); + + it.each([ + // Mapped product domains. + "experiment-list", + "llma-evaluation-list", + "vision-scanners-create", + // Deliberately-suppressed admin/meta domains — recognized, not unknown. + "project-get", + "docs-search", + "tasks-list", + // Special-cased call shapes. + "query-trends", + "execute-sql", + "activity-log-list", + // Empty input. + "", + " ", + ])("does not flag recognized or special-cased call %j", (subTool) => { + expect(isUnclassifiedPostHogSubTool(subTool)).toBe(false); + }); +}); + describe("classifyPostHogSqlQuery", () => { it.each([ ["SELECT count() FROM feature_flags", ["feature_flags"]], diff --git a/packages/agent/src/posthog-products.ts b/packages/agent/src/posthog-products.ts index 14cd42490..870958c1f 100644 --- a/packages/agent/src/posthog-products.ts +++ b/packages/agent/src/posthog-products.ts @@ -28,8 +28,6 @@ export const POSTHOG_PRODUCTS = { 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; @@ -38,7 +36,8 @@ 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. + * surfaces nothing — every chip in the bar is already a PostHog resource, so a + * generic "PostHog" fallback chip would be redundant. */ const DOMAIN_PRODUCT: Record = { // Experiments @@ -54,22 +53,21 @@ const DOMAIN_PRODUCT: Record = { "visual-review": "session_replay", // Surveys survey: "surveys", - // LLM analytics + // Session replay (Replay Vision) + vision: "session_replay", + // LLM analytics. `llm` covers `llm-total-costs`; `llma` covers the whole + // `llma-*` family (evaluation, clustering, prompt, sentiment, trace, …) in + // one token rather than one entry per sub-tool. llm: "llm_analytics", - "llma-evaluation-judge-models": "llm_analytics", - "llma-personal-spend": "llm_analytics", - "llma-tagger-test-hog": "llm_analytics", + llma: "llm_analytics", "agent-feedback": "llm_analytics", - // Data warehouse - "external-data-sources": "data_warehouse", - "external-data-schemas": "data_warehouse", - "external-data-sync-logs": "data_warehouse", + // Data warehouse. `external-data` covers sources/schemas/sync-logs. + "external-data": "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", + // Data pipelines (CDP). `cdp-function` covers `cdp-function-templates` too. + "cdp-function": "cdp", "hog-flows-logs": "cdp", "hog-flows-metrics": "cdp", workflows: "cdp", @@ -79,7 +77,7 @@ const DOMAIN_PRODUCT: Record = { // SQL "execute-sql": "sql", // Web analytics - "web-analytics-weekly-digest": "web_analytics", + "web-analytics": "web_analytics", // Product analytics insight: "product_analytics", dashboard: "product_analytics", @@ -87,12 +85,13 @@ const DOMAIN_PRODUCT: Record = { cohorts: "product_analytics", persons: "product_analytics", annotation: "product_analytics", + "event-definition": "product_analytics", endpoint: "product_analytics", view: "product_analytics", "usage-metrics": "product_analytics", subscriptions: "product_analytics", alert: "product_analytics", - notebooks: "product_analytics", + notebook: "product_analytics", // Admin / meta / introspection — recognized but not surfaced. project: null, user: null, @@ -337,8 +336,9 @@ function classifyQuery(type: string): PostHogProductId | null { /** * 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. + * to a product id. Returns `null` when the name is empty, or when the domain is + * one we don't surface — either a known admin/meta domain or an unrecognized + * one (no point in a generic "PostHog" chip inside a PostHog-resources bar). */ export function classifyPostHogSubTool( subTool: string, @@ -350,15 +350,45 @@ export function classifyPostHogSubTool( 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. + const best = matchDomain(name); + if (best === null) return null; + return DOMAIN_PRODUCT[best]; +} + +/** + * Best (longest) matching known domain for a sub-tool name, or `null` if none + * match. Longest wins so `feature-flag` beats a hypothetical `feature` and + * multi-word domains aren't shadowed by shorter prefixes. + */ +function matchDomain(name: string): string | null { let best: string | null = null; for (const [domain, re] of DOMAIN_PATTERNS) { if (re.test(name)) { if (best === null || domain.length > best.length) best = domain; } } + return best; +} - if (best === null) return "posthog"; - return DOMAIN_PRODUCT[best]; +/** + * True when a `call` sub-tool is a PostHog resource call we don't recognize at + * all: not a query/execute-sql/activity-log call and matching no known domain, + * so it surfaces no product chip. Distinct from a domain we deliberately + * suppress (project, docs-search, …), which is recognized and returns `null` on + * purpose. Lets callers log genuinely-unknown calls so `DOMAIN_PRODUCT` can be + * expanded deliberately instead of silently dropping them. + */ +export function isUnclassifiedPostHogSubTool(subTool: string): boolean { + const name = subTool.trim().toLowerCase(); + if (!name) return false; + if (name === "query" || name.startsWith("query-")) return false; + if (name === "execute-sql" || name === "execute_sql") return false; + if ( + name === "activity-log" || + name.startsWith("activity-log-") || + name.startsWith("advanced-activity-logs") + ) { + return false; + } + return matchDomain(name) === null; } diff --git a/packages/ui/src/features/sessions/components/SessionResourcesBar.tsx b/packages/ui/src/features/sessions/components/SessionResourcesBar.tsx index 286287789..c8607d979 100644 --- a/packages/ui/src/features/sessions/components/SessionResourcesBar.tsx +++ b/packages/ui/src/features/sessions/components/SessionResourcesBar.tsx @@ -41,7 +41,6 @@ const PRODUCT_ICON: Record> = { logs: FileTextIcon, apm: GaugeIcon, sql: TableIcon, - posthog: SparkleIcon, }; /** @@ -63,7 +62,6 @@ const PRODUCT_DOC_URL: Partial> = { cdp: "https://posthog.com/docs/cdp", logs: "https://posthog.com/docs/logs", sql: "https://posthog.com/docs/sql", - posthog: "https://posthog.com/docs", }; interface SessionResourcesBarProps {