Skip to content

Commit 86b24ca

Browse files
authored
feat(agent): show PostHog products used below each turn
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
1 parent bf90d93 commit 86b24ca

14 files changed

Lines changed: 490 additions & 3 deletions

File tree

apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,38 @@ function turnCompleteMsg(ts: number, stopReason = "end_turn"): AcpMessage {
7474
};
7575
}
7676

77+
function agentMessageMsg(ts: number, text: string): AcpMessage {
78+
return {
79+
type: "acp_message",
80+
ts,
81+
message: {
82+
jsonrpc: "2.0",
83+
method: "session/update",
84+
params: {
85+
update: {
86+
sessionUpdate: "agent_message_chunk",
87+
content: { type: "text", text },
88+
},
89+
},
90+
},
91+
};
92+
}
93+
94+
function resourcesUsedMsg(
95+
ts: number,
96+
products: { id: string; label: string }[],
97+
): AcpMessage {
98+
return {
99+
type: "acp_message",
100+
ts,
101+
message: {
102+
jsonrpc: "2.0",
103+
method: "_posthog/resources_used",
104+
params: { sessionId: "session-1", products },
105+
},
106+
};
107+
}
108+
77109
describe("buildConversationItems", () => {
78110
it("extracts cloud prompt attachments into user messages", () => {
79111
const uri = makeAttachmentUri("/tmp/hello world.txt");
@@ -421,6 +453,63 @@ describe("buildConversationItems", () => {
421453
expect(findProgressGroups(result.items)).toHaveLength(0);
422454
});
423455
});
456+
457+
describe("resources_used", () => {
458+
it("renders a resources_used item after the turn's final message", () => {
459+
const products = [
460+
{ id: "experiments", label: "Experiments" },
461+
{ id: "sql", label: "SQL" },
462+
];
463+
const events: AcpMessage[] = [
464+
userPromptMsg(1, 1, "list my experiments"),
465+
agentMessageMsg(2, "Here are your experiments."),
466+
resourcesUsedMsg(3, products),
467+
promptResponseMsg(4, 1),
468+
];
469+
470+
const result = buildConversationItems(events, false);
471+
472+
const idx = result.items.findIndex(
473+
(i) =>
474+
i.type === "session_update" &&
475+
i.update.sessionUpdate === "resources_used",
476+
);
477+
expect(idx).toBeGreaterThanOrEqual(0);
478+
479+
// Must land after the agent's final message in the turn.
480+
const msgIdx = result.items.findIndex(
481+
(i) =>
482+
i.type === "session_update" &&
483+
i.update.sessionUpdate === "agent_message_chunk",
484+
);
485+
expect(idx).toBeGreaterThan(msgIdx);
486+
487+
const item = result.items[idx];
488+
if (
489+
item.type === "session_update" &&
490+
item.update.sessionUpdate === "resources_used"
491+
) {
492+
expect(item.update.products).toEqual(products);
493+
}
494+
});
495+
496+
it("ignores a resources_used notification with no products", () => {
497+
const events: AcpMessage[] = [
498+
userPromptMsg(1, 1, "hi"),
499+
resourcesUsedMsg(2, []),
500+
promptResponseMsg(3, 1),
501+
];
502+
503+
const result = buildConversationItems(events, false);
504+
expect(
505+
result.items.some(
506+
(i) =>
507+
i.type === "session_update" &&
508+
i.update.sessionUpdate === "resources_used",
509+
),
510+
).toBe(false);
511+
});
512+
});
424513
});
425514

426515
// Local alias kept intentionally narrow to the shape we care about in tests.

apps/code/src/renderer/features/sessions/components/buildConversationItems.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import {
99
extractSkillButtonId,
1010
type SkillButtonId,
1111
} from "@features/skill-buttons/prompts";
12-
import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent";
12+
import {
13+
isNotification,
14+
POSTHOG_NOTIFICATIONS,
15+
type PostHogProductId,
16+
} from "@posthog/agent";
1317
import {
1418
type AcpMessage,
1519
isJsonRpcNotification,
@@ -380,6 +384,19 @@ function handleNotification(
380384
return;
381385
}
382386

387+
if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.RESOURCES_USED)) {
388+
const params = msg.params as
389+
| { products?: { id: PostHogProductId; label: string }[] }
390+
| undefined;
391+
if (!params?.products?.length) return;
392+
if (!b.currentTurn) ensureImplicitTurn(b, ts);
393+
pushItem(b, {
394+
sessionUpdate: "resources_used",
395+
products: params.products,
396+
});
397+
return;
398+
}
399+
383400
if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)) {
384401
const params = msg.params as { stopReason?: string } | undefined;
385402
if (!b.currentTurn) return;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { IconProps } from "@phosphor-icons/react";
2+
import {
3+
BrainIcon,
4+
BugIcon,
5+
ChartLineIcon,
6+
ClipboardTextIcon,
7+
DatabaseIcon,
8+
FileTextIcon,
9+
FlagIcon,
10+
FlaskIcon,
11+
GaugeIcon,
12+
GlobeIcon,
13+
PlugIcon,
14+
SparkleIcon,
15+
TableIcon,
16+
VideoIcon,
17+
} from "@phosphor-icons/react";
18+
import type { PostHogProductId } from "@posthog/agent";
19+
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
20+
import type { ComponentType } from "react";
21+
22+
/**
23+
* Icon per PostHog product. `Record<PostHogProductId, …>` keeps this exhaustive:
24+
* adding a product id in `@posthog/agent` forces an icon here at compile time.
25+
*/
26+
const PRODUCT_ICON: Record<PostHogProductId, ComponentType<IconProps>> = {
27+
product_analytics: ChartLineIcon,
28+
web_analytics: GlobeIcon,
29+
feature_flags: FlagIcon,
30+
experiments: FlaskIcon,
31+
error_tracking: BugIcon,
32+
session_replay: VideoIcon,
33+
surveys: ClipboardTextIcon,
34+
llm_analytics: BrainIcon,
35+
data_warehouse: DatabaseIcon,
36+
cdp: PlugIcon,
37+
logs: FileTextIcon,
38+
apm: GaugeIcon,
39+
sql: TableIcon,
40+
posthog: SparkleIcon,
41+
};
42+
43+
interface ResourcesUsedViewProps {
44+
products: { id: PostHogProductId; label: string }[];
45+
}
46+
47+
/**
48+
* A subtle chip row rendered under a completed turn, listing the PostHog
49+
* products the agent touched (via the MCP `exec` tool) while answering.
50+
*/
51+
export function ResourcesUsedView({ products }: ResourcesUsedViewProps) {
52+
if (products.length === 0) return null;
53+
54+
return (
55+
<Box className="mt-0.5 mb-1 pl-3">
56+
<Flex align="center" gap="2" wrap="wrap">
57+
<Text className="text-[12px] text-gray-9">PostHog resources used</Text>
58+
{products.map((product) => {
59+
const Icon = PRODUCT_ICON[product.id] ?? SparkleIcon;
60+
return (
61+
<Badge key={product.id} size="1" color="gray" variant="soft">
62+
<Icon size={12} />
63+
{product.label}
64+
</Badge>
65+
);
66+
})}
67+
</Flex>
68+
</Box>
69+
);
70+
}

apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { Step } from "@components/ui/StepList";
22
import type { ConversationItem } from "@features/sessions/components/buildConversationItems";
33
import type { SessionUpdate, ToolCall } from "@features/sessions/types";
4+
import type { PostHogProductId } from "@posthog/agent";
45
import { memo } from "react";
56

67
import { AgentMessage } from "./AgentMessage";
78
import { CompactBoundaryView } from "./CompactBoundaryView";
89
import { ConsoleMessage } from "./ConsoleMessage";
910
import { ErrorNotificationView } from "./ErrorNotificationView";
1011
import { ProgressGroupView } from "./ProgressGroupView";
12+
import { ResourcesUsedView } from "./ResourcesUsedView";
1113
import { StatusNotificationView } from "./StatusNotificationView";
1214
import { TaskNotificationView } from "./TaskNotificationView";
1315
import { ThoughtView } from "./ThoughtView";
@@ -48,6 +50,10 @@ export type RenderItem =
4850
sessionUpdate: "progress_group";
4951
steps: Step[];
5052
isActive: boolean;
53+
}
54+
| {
55+
sessionUpdate: "resources_used";
56+
products: { id: PostHogProductId; label: string }[];
5157
};
5258

5359
interface SessionUpdateViewProps {
@@ -138,6 +144,8 @@ export const SessionUpdateView = memo(function SessionUpdateView({
138144
turnComplete={turnComplete}
139145
/>
140146
);
147+
case "resources_used":
148+
return <ResourcesUsedView products={item.products} />;
141149
default:
142150
return null;
143151
}

packages/agent/src/acp-extensions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ export const POSTHOG_NOTIFICATIONS = {
6767
/** Token usage update for a session turn */
6868
USAGE_UPDATE: "_posthog/usage_update",
6969

70+
/** PostHog products used during a turn (derived from MCP exec calls) */
71+
RESOURCES_USED: "_posthog/resources_used",
72+
7073
/** Response to a relayed permission request (plan approval, question) */
7174
PERMISSION_RESPONSE: "_posthog/permission_response",
7275
} as const;

packages/agent/src/adapters/claude/claude-agent.refresh.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function installFakeSession(
9797
cachedReadTokens: 0,
9898
cachedWriteTokens: 0,
9999
},
100+
turnResources: new Set(),
100101
configOptions: [],
101102
promptRunning: false,
102103
pendingMessages: new Map(),

packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function installFakeSession(
5858
cachedReadTokens: 0,
5959
cachedWriteTokens: 0,
6060
},
61+
turnResources: new Set(),
6162
configOptions: [],
6263
promptRunning: false,
6364
pendingMessages: new Map(),

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ import {
5252
POSTHOG_METHODS,
5353
POSTHOG_NOTIFICATIONS,
5454
} from "../../acp-extensions";
55+
import {
56+
classifyPostHogSubTool,
57+
POSTHOG_PRODUCTS,
58+
} from "../../posthog-products";
5559
import {
5660
createEnrichment,
5761
type Enrichment,
@@ -435,6 +439,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
435439
cachedReadTokens: 0,
436440
cachedWriteTokens: 0,
437441
};
442+
this.session.turnResources.clear();
438443

439444
await this.broadcastUserMessage(params);
440445

@@ -639,6 +644,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
639644
},
640645
});
641646

647+
await this.emitResourcesUsed(params.sessionId);
648+
642649
return {
643650
stopReason: this.session.cancelled ? "cancelled" : "end_turn",
644651
};
@@ -728,6 +735,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
728735
},
729736
);
730737

738+
await this.emitResourcesUsed(params.sessionId);
739+
731740
const usage: Usage = {
732741
inputTokens: this.session.accumulatedUsage.inputTokens,
733742
outputTokens: this.session.accumulatedUsage.outputTokens,
@@ -1405,6 +1414,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
14051414
outputFormat,
14061415
settingsManager,
14071416
onModeChange: this.createOnModeChange(),
1417+
onPostHogResourceUsed: this.createOnPostHogResourceUsed(),
14081418
onProcessSpawned: this.options?.onProcessSpawned,
14091419
onProcessExited: this.options?.onProcessExited,
14101420
effort,
@@ -1442,6 +1452,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
14421452
cachedReadTokens: 0,
14431453
cachedWriteTokens: 0,
14441454
},
1455+
turnResources: new Set(),
14451456
effort,
14461457
configOptions: [],
14471458
promptRunning: false,
@@ -1644,6 +1655,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
16441655
};
16451656
}
16461657

1658+
/** Records the PostHog product behind an executed MCP exec `call` so the
1659+
* current turn can report which products it touched. */
1660+
private createOnPostHogResourceUsed() {
1661+
return (subTool: string) => {
1662+
const product = classifyPostHogSubTool(subTool);
1663+
if (product) this.session?.turnResources.add(product);
1664+
};
1665+
}
1666+
1667+
/** Emits the PostHog products used this turn, then clears the accumulator.
1668+
* Fires before the prompt response so the notification lands in the turn. */
1669+
private async emitResourcesUsed(sessionId: string): Promise<void> {
1670+
if (!this.session || this.session.turnResources.size === 0) return;
1671+
const products = [...this.session.turnResources].map((id) => ({
1672+
id,
1673+
label: POSTHOG_PRODUCTS[id],
1674+
}));
1675+
this.session.turnResources.clear();
1676+
await this.client.extNotification(POSTHOG_NOTIFICATIONS.RESOURCES_USED, {
1677+
sessionId,
1678+
products,
1679+
});
1680+
}
1681+
16471682
private getExistingSessionState(
16481683
sessionId: string,
16491684
): NewSessionResponse | null {

packages/agent/src/adapters/claude/hooks.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,15 @@ export type OnModeChange = (mode: CodeExecutionMode) => Promise<void>;
176176

177177
interface CreatePostToolUseHookParams {
178178
onModeChange?: OnModeChange;
179+
/** Called with the PostHog MCP sub-tool name after a `call` exec executes. */
180+
onPostHogResourceUsed?: (subTool: string) => void;
179181
}
180182

181183
export const createPostToolUseHook =
182-
({ onModeChange }: CreatePostToolUseHookParams): HookCallback =>
184+
({
185+
onModeChange,
186+
onPostHogResourceUsed,
187+
}: CreatePostToolUseHookParams): HookCallback =>
183188
async (
184189
input: HookInput,
185190
toolUseID: string | undefined,
@@ -191,6 +196,14 @@ export const createPostToolUseHook =
191196
await onModeChange("plan");
192197
}
193198

199+
// Record PostHog product usage from the MCP exec dispatcher. Only the
200+
// `call <sub-tool>` verb counts as "used a resource" — extractPostHogSubTool
201+
// matches that verb and ignores introspection (tools/info/schema/search).
202+
if (onPostHogResourceUsed && isPostHogExecTool(toolName)) {
203+
const subTool = extractPostHogSubTool(input.tool_input);
204+
if (subTool) onPostHogResourceUsed(subTool);
205+
}
206+
194207
if (toolUseID) {
195208
const onPostToolUseHook =
196209
toolUseCallbacks[toolUseID]?.onPostToolUseHook;

0 commit comments

Comments
 (0)