Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
} from "../../enrichment/file-enricher";
import {
classifyPostHogExecCall,
isUnclassifiedPostHogSubTool,
POSTHOG_PRODUCTS,
type PostHogProductId,
} from "../../posthog-products";
Expand Down Expand Up @@ -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),
);
Expand Down
51 changes: 49 additions & 2 deletions packages/agent/src/posthog-products.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
classifyPostHogExecCall,
classifyPostHogSqlQuery,
classifyPostHogSubTool,
isUnclassifiedPostHogSubTool,
POSTHOG_PRODUCTS,
type PostHogProductId,
} from "./posthog-products";
Expand All @@ -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"],
Expand Down Expand Up @@ -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) => {
Expand All @@ -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"]],
Expand Down
74 changes: 52 additions & 22 deletions packages/agent/src/posthog-products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, PostHogProductId | null> = {
// Experiments
Expand All @@ -54,22 +53,21 @@ const DOMAIN_PRODUCT: Record<string, PostHogProductId | null> = {
"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",
Expand All @@ -79,20 +77,21 @@ const DOMAIN_PRODUCT: Record<string, PostHogProductId | null> = {
// SQL
"execute-sql": "sql",
// Web analytics
"web-analytics-weekly-digest": "web_analytics",
"web-analytics": "web_analytics",
// Product analytics
insight: "product_analytics",
dashboard: "product_analytics",
action: "product_analytics",
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ const PRODUCT_ICON: Record<PostHogProductId, ComponentType<IconProps>> = {
logs: FileTextIcon,
apm: GaugeIcon,
sql: TableIcon,
posthog: SparkleIcon,
};

/**
Expand All @@ -63,7 +62,6 @@ const PRODUCT_DOC_URL: Partial<Record<PostHogProductId, string>> = {
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 {
Expand Down
Loading