@posthog/agent 2.3.678 → 2.3.703

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.678",
3
+ "version": "2.3.703",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -106,9 +106,9 @@
106
106
  "tsx": "^4.20.6",
107
107
  "typescript": "^5.5.0",
108
108
  "vitest": "^2.1.8",
109
- "@posthog/git": "1.0.0",
110
109
  "@posthog/shared": "1.0.0",
111
- "@posthog/enricher": "1.0.0"
110
+ "@posthog/enricher": "1.0.0",
111
+ "@posthog/git": "1.0.0"
112
112
  },
113
113
  "dependencies": {
114
114
  "@agentclientprotocol/sdk": "0.19.0",
@@ -0,0 +1,153 @@
1
+ import type { AgentSideConnection } from "@agentclientprotocol/sdk";
2
+ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk";
5
+ import { Pushable } from "../../utils/streams";
6
+
7
+ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
8
+ query: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("./mcp/tool-metadata", () => ({
12
+ fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined),
13
+ getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
14
+ setMcpToolApprovalStates: vi.fn(),
15
+ isMcpToolReadOnly: vi.fn().mockReturnValue(false),
16
+ getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
17
+ getMcpToolApprovalState: vi.fn().mockReturnValue(undefined),
18
+ }));
19
+
20
+ const { ClaudeAcpAgent } = await import("./claude-agent");
21
+ type Agent = InstanceType<typeof ClaudeAcpAgent>;
22
+
23
+ interface ClientMocks {
24
+ sessionUpdate: ReturnType<typeof vi.fn>;
25
+ extNotification: ReturnType<typeof vi.fn>;
26
+ }
27
+
28
+ function makeAgent(): { agent: Agent; client: ClientMocks } {
29
+ const client: ClientMocks = {
30
+ sessionUpdate: vi.fn().mockResolvedValue(undefined),
31
+ extNotification: vi.fn().mockResolvedValue(undefined),
32
+ };
33
+ const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection);
34
+ return { agent, client };
35
+ }
36
+
37
+ function installFakeSession(agent: Agent, sessionId: string): MockQuery {
38
+ const query = createMockQuery();
39
+ const input = new Pushable();
40
+ const abortController = new AbortController();
41
+
42
+ const session = {
43
+ query,
44
+ queryOptions: { sessionId, cwd: "/tmp/repo", abortController },
45
+ input,
46
+ cancelled: false,
47
+ interruptReason: undefined,
48
+ settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" },
49
+ permissionMode: "default" as const,
50
+ abortController,
51
+ accumulatedUsage: {
52
+ inputTokens: 0,
53
+ outputTokens: 0,
54
+ cachedReadTokens: 0,
55
+ cachedWriteTokens: 0,
56
+ },
57
+ configOptions: [],
58
+ promptRunning: false,
59
+ pendingMessages: new Map(),
60
+ nextPendingOrder: 0,
61
+ cwd: "/tmp/repo",
62
+ notificationHistory: [] as unknown[],
63
+ taskRunId: "run-1",
64
+ lastContextWindowSize: 200_000,
65
+ modelId: "claude-sonnet-4-6",
66
+ };
67
+
68
+ (agent as unknown as { session: typeof session }).session = session;
69
+ (agent as unknown as { sessionId: string }).sessionId = sessionId;
70
+
71
+ return query;
72
+ }
73
+
74
+ function findUnsupportedChunkText(
75
+ calls: ClientMocks["sessionUpdate"]["mock"]["calls"],
76
+ ): string | undefined {
77
+ const match = calls.find(([call]) => {
78
+ const update = (
79
+ call as {
80
+ update?: { sessionUpdate?: string; content?: { text?: string } };
81
+ }
82
+ ).update;
83
+ return (
84
+ update?.sessionUpdate === "agent_message_chunk" &&
85
+ update?.content?.text?.toLowerCase().includes("unsupported")
86
+ );
87
+ });
88
+ return (match?.[0] as { update: { content: { text: string } } } | undefined)
89
+ ?.update.content.text;
90
+ }
91
+
92
+ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ });
96
+
97
+ const cases = [
98
+ {
99
+ label: "unsupported slash command surfaces error and ends turn",
100
+ sessionId: "s-slash",
101
+ prompt: "/plugin install slack",
102
+ expectsUnsupportedChunk: true,
103
+ commandInMessage: "/plugin",
104
+ },
105
+ {
106
+ label: "non-slash prompt with early idle is silently skipped",
107
+ sessionId: "s-regular",
108
+ prompt: "hello",
109
+ expectsUnsupportedChunk: false,
110
+ commandInMessage: null,
111
+ },
112
+ ] as const;
113
+
114
+ it.each(cases)("$label", async (tc) => {
115
+ const { agent, client } = makeAgent();
116
+ const query = installFakeSession(agent, tc.sessionId);
117
+
118
+ const promptPromise = agent.prompt({
119
+ sessionId: tc.sessionId,
120
+ prompt: [{ type: "text", text: tc.prompt }],
121
+ });
122
+
123
+ // Let the prompt loop start awaiting the first SDK message.
124
+ await new Promise((resolve) => setImmediate(resolve));
125
+
126
+ query._mockHelpers.sendMessage({
127
+ type: "system",
128
+ subtype: "session_state_changed",
129
+ state: "idle",
130
+ } as unknown as SDKMessage);
131
+ query._mockHelpers.complete();
132
+
133
+ if (tc.expectsUnsupportedChunk) {
134
+ const result = await promptPromise;
135
+ expect(result.stopReason).toBe("end_turn");
136
+
137
+ const text = findUnsupportedChunkText(client.sessionUpdate.mock.calls);
138
+ expect(text).toBeDefined();
139
+ if (tc.commandInMessage) {
140
+ expect(text).toContain(tc.commandInMessage);
141
+ }
142
+ } else {
143
+ // No unsupported chunk; loop falls through to the existing
144
+ // "Session did not end in result" failure path.
145
+ await expect(promptPromise).rejects.toThrow(
146
+ /Session did not end in result/,
147
+ );
148
+ expect(
149
+ findUnsupportedChunkText(client.sessionUpdate.mock.calls),
150
+ ).toBeUndefined();
151
+ }
152
+ });
153
+ });
@@ -68,6 +68,14 @@ import { Pushable } from "../../utils/streams";
68
68
  import { BaseAcpAgent } from "../base-acp-agent";
69
69
  import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
70
70
  import { resolveTaskId } from "../session-meta";
71
+ import {
72
+ buildBreakdown,
73
+ emptyBaseline,
74
+ estimateMcpTokens,
75
+ estimateRulesTokens,
76
+ estimateSkillsTokens,
77
+ estimateSystemPrompt,
78
+ } from "./context-breakdown";
71
79
  import { promptToClaude } from "./conversion/acp-to-sdk";
72
80
  import {
73
81
  handleResultMessage,
@@ -79,6 +87,7 @@ import type { EnrichedReadCache } from "./hooks";
79
87
  import { createLocalToolsMcpServer } from "./mcp/local-tools";
80
88
  import {
81
89
  fetchMcpToolMetadata,
90
+ getCachedMcpTools,
82
91
  getConnectedMcpServerNames,
83
92
  setMcpToolApprovalStates,
84
93
  } from "./mcp/tool-metadata";
@@ -118,6 +127,22 @@ const SESSION_VALIDATION_TIMEOUT_MS = 30_000;
118
127
  const MAX_TITLE_LENGTH = 256;
119
128
  const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
120
129
 
130
+ // Best-effort: silent on ENOENT, logs other errors so permission failures
131
+ // aren't masked.
132
+ function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined {
133
+ try {
134
+ return fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf-8");
135
+ } catch (err) {
136
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
137
+ logger.warn("Failed to read CLAUDE.md for context breakdown", {
138
+ cwd,
139
+ error: err instanceof Error ? err.message : String(err),
140
+ });
141
+ }
142
+ return undefined;
143
+ }
144
+ }
145
+
121
146
  function sanitizeTitle(text: string): string {
122
147
  const sanitized = text
123
148
  .replace(/[\r\n]+/g, " ")
@@ -471,6 +496,28 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
471
496
  (message as Record<string, unknown>).state === "idle"
472
497
  ) {
473
498
  if (!promptReplayed) {
499
+ // The SDK consumed a slash command we do not handle locally
500
+ // and produced no output (e.g. /plugin in a non-interactive
501
+ // context). Without this branch we would loop forever waiting
502
+ // for an echo that never comes; surface a clear error instead.
503
+ if (commandMatch) {
504
+ const cmd = commandMatch[1];
505
+ this.logger.warn(
506
+ "Slash command produced no output; treating as unsupported",
507
+ { sessionId: params.sessionId, command: cmd },
508
+ );
509
+ await this.client.sessionUpdate({
510
+ sessionId: params.sessionId,
511
+ update: {
512
+ sessionUpdate: "agent_message_chunk",
513
+ content: {
514
+ type: "text",
515
+ text: `Unsupported slash command: \`${cmd}\`. PostHog Code does not implement this command.`,
516
+ },
517
+ },
518
+ });
519
+ return { stopReason: "end_turn" };
520
+ }
474
521
  this.logger.debug("Skipping idle state before prompt replay", {
475
522
  sessionId: params.sessionId,
476
523
  });
@@ -556,6 +603,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
556
603
  });
557
604
  }
558
605
 
606
+ // `result.usage` is cumulative across the agentic loop; the
607
+ // outermost-model stream snapshot is what's actually resident.
608
+ const breakdownInputTokens =
609
+ lastStreamUsage.input_tokens +
610
+ lastStreamUsage.cache_read_input_tokens +
611
+ lastStreamUsage.cache_creation_input_tokens;
559
612
  await this.client.extNotification(
560
613
  POSTHOG_NOTIFICATIONS.USAGE_UPDATE,
561
614
  {
@@ -567,6 +620,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
567
620
  cachedWriteTokens: message.usage.cache_creation_input_tokens,
568
621
  },
569
622
  cost: message.total_cost_usd,
623
+ breakdown: buildBreakdown(
624
+ this.session.contextBreakdownBaseline ?? emptyBaseline(),
625
+ breakdownInputTokens,
626
+ ),
570
627
  },
571
628
  );
572
629
 
@@ -1221,6 +1278,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1221
1278
  pendingMessages: new Map(),
1222
1279
  nextPendingOrder: 0,
1223
1280
  emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages ?? false,
1281
+ contextBreakdownBaseline: {
1282
+ ...emptyBaseline(),
1283
+ systemPrompt: estimateSystemPrompt(systemPrompt),
1284
+ rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)),
1285
+ },
1224
1286
 
1225
1287
  // Custom properties
1226
1288
  cwd,
@@ -1547,13 +1609,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1547
1609
 
1548
1610
  private async sendAvailableCommandsUpdate(): Promise<void> {
1549
1611
  const commands = await this.session.query.supportedCommands();
1612
+ const available = getAvailableSlashCommands(commands);
1550
1613
  await this.client.sessionUpdate({
1551
1614
  sessionId: this.sessionId,
1552
1615
  update: {
1553
1616
  sessionUpdate: "available_commands_update",
1554
- availableCommands: getAvailableSlashCommands(commands),
1617
+ availableCommands: available,
1555
1618
  },
1556
1619
  });
1620
+ this.updateBreakdownCategory("skills", estimateSkillsTokens(available));
1621
+ }
1622
+
1623
+ /** Update one category of the context-breakdown baseline so the next
1624
+ * `_posthog/usage_update` carries fresher numbers. No-op when the baseline
1625
+ * hasn't been initialized yet (e.g. in a unit-test session). */
1626
+ private updateBreakdownCategory(
1627
+ key: keyof NonNullable<Session["contextBreakdownBaseline"]>,
1628
+ tokens: number,
1629
+ ): void {
1630
+ if (!this.session?.contextBreakdownBaseline) return;
1631
+ if (this.session.contextBreakdownBaseline[key] === tokens) return;
1632
+ this.session.contextBreakdownBaseline = {
1633
+ ...this.session.contextBreakdownBaseline,
1634
+ [key]: tokens,
1635
+ };
1557
1636
  }
1558
1637
 
1559
1638
  private async replaySessionHistory(sessionId: string): Promise<void> {
@@ -1610,6 +1689,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1610
1689
  this.sendAvailableCommandsUpdate(),
1611
1690
  ),
1612
1691
  fetchMcpToolMetadata(q, this.logger).then(() => {
1692
+ this.updateBreakdownCategory(
1693
+ "mcp",
1694
+ estimateMcpTokens(getCachedMcpTools()),
1695
+ );
1613
1696
  const serverNames = getConnectedMcpServerNames();
1614
1697
  if (serverNames.length > 0) {
1615
1698
  this.options?.onMcpServersReady?.(serverNames);
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildBreakdown,
4
+ emptyBaseline,
5
+ estimateJsonTokens,
6
+ estimateMcpTokens,
7
+ estimateRulesTokens,
8
+ estimateSkillsTokens,
9
+ estimateSystemPrompt,
10
+ estimateTokens,
11
+ } from "./context-breakdown";
12
+
13
+ describe("estimateTokens", () => {
14
+ it("returns 0 for empty input", () => {
15
+ expect(estimateTokens("")).toBe(0);
16
+ expect(estimateTokens(undefined)).toBe(0);
17
+ expect(estimateTokens(null)).toBe(0);
18
+ });
19
+
20
+ it("scales roughly with input length", () => {
21
+ expect(estimateTokens("a".repeat(40))).toBe(10);
22
+ expect(estimateTokens("a".repeat(400))).toBe(100);
23
+ });
24
+ });
25
+
26
+ describe("estimateJsonTokens", () => {
27
+ it("counts JSON representation of objects", () => {
28
+ const tokens = estimateJsonTokens({ name: "Read", schema: { foo: 1 } });
29
+ expect(tokens).toBeGreaterThan(0);
30
+ });
31
+
32
+ it("returns 0 for non-serializable values", () => {
33
+ const circular: Record<string, unknown> = {};
34
+ circular.self = circular;
35
+ expect(estimateJsonTokens(circular)).toBe(0);
36
+ });
37
+ });
38
+
39
+ describe("estimateSystemPrompt", () => {
40
+ it("includes the Claude preset budget when preset is used", () => {
41
+ const noAppend = estimateSystemPrompt({ type: "preset" });
42
+ expect(noAppend).toBeGreaterThan(0);
43
+ });
44
+
45
+ it("adds the append portion on top of the preset", () => {
46
+ const append = "a".repeat(400);
47
+ const result = estimateSystemPrompt({ type: "preset", append });
48
+ const presetOnly = estimateSystemPrompt({ type: "preset" });
49
+ expect(result - presetOnly).toBe(100);
50
+ });
51
+
52
+ it("counts a raw string verbatim with no preset overhead", () => {
53
+ expect(estimateSystemPrompt("a".repeat(400))).toBe(100);
54
+ });
55
+
56
+ it("treats undefined as the bare preset", () => {
57
+ expect(estimateSystemPrompt(undefined)).toBe(
58
+ estimateSystemPrompt({ type: "preset" }),
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("estimateSkillsTokens", () => {
64
+ it("is 0 for an empty command list", () => {
65
+ expect(estimateSkillsTokens([])).toBe(0);
66
+ });
67
+
68
+ it("counts the JSON of name/description/hint", () => {
69
+ // [{"name":"review","description":"Review a PR","hint":"[pr]"}] ~ 55 chars
70
+ const result = estimateSkillsTokens([
71
+ { name: "review", description: "Review a PR", input: { hint: "[pr]" } },
72
+ ]);
73
+ expect(result).toBeGreaterThan(10);
74
+ expect(result).toBeLessThan(20);
75
+ });
76
+ });
77
+
78
+ describe("estimateMcpTokens", () => {
79
+ it("is 0 for no connected tools", () => {
80
+ expect(estimateMcpTokens([])).toBe(0);
81
+ });
82
+
83
+ it("scales with tool count", () => {
84
+ const one = estimateMcpTokens([{ name: "get_user", description: "x" }]);
85
+ const many = estimateMcpTokens(
86
+ Array.from({ length: 50 }, (_, i) => ({
87
+ name: `tool_${i}`,
88
+ description: "x",
89
+ })),
90
+ );
91
+ expect(many).toBeGreaterThan(one * 10);
92
+ });
93
+ });
94
+
95
+ describe("estimateRulesTokens", () => {
96
+ it("is 0 for missing rules", () => {
97
+ expect(estimateRulesTokens(undefined)).toBe(0);
98
+ expect(estimateRulesTokens("")).toBe(0);
99
+ });
100
+
101
+ it("counts the rules content", () => {
102
+ expect(estimateRulesTokens("a".repeat(400))).toBe(100);
103
+ });
104
+ });
105
+
106
+ describe("buildBreakdown", () => {
107
+ it("derives conversation from input - stable sum", () => {
108
+ const baseline = {
109
+ ...emptyBaseline(),
110
+ systemPrompt: 4000,
111
+ tools: 500,
112
+ };
113
+ const result = buildBreakdown(baseline, 10_000);
114
+ expect(result.systemPrompt).toBe(4000);
115
+ expect(result.tools).toBe(500);
116
+ expect(result.conversation).toBe(5500);
117
+ });
118
+
119
+ it("floors conversation at 0 when stable pieces exceed input", () => {
120
+ const baseline = { ...emptyBaseline(), systemPrompt: 5000 };
121
+ expect(buildBreakdown(baseline, 1000).conversation).toBe(0);
122
+ });
123
+
124
+ it("includes zero categories", () => {
125
+ const result = buildBreakdown(emptyBaseline(), 100);
126
+ expect(result.mcp).toBe(0);
127
+ expect(result.skills).toBe(0);
128
+ expect(result.subagents).toBe(0);
129
+ });
130
+ });
@@ -0,0 +1,127 @@
1
+ // Anthropic doesn't break `input_tokens` down by source; we estimate the bits
2
+ // we control via a chars-per-token heuristic. Indicative, not invoice-grade.
3
+
4
+ export type ContextCategory =
5
+ | "systemPrompt"
6
+ | "tools"
7
+ | "rules"
8
+ | "skills"
9
+ | "mcp"
10
+ | "subagents"
11
+ | "conversation";
12
+
13
+ export type ContextBreakdown = Record<ContextCategory, number>;
14
+
15
+ // The `claude_code` preset prompt is opaque to us; without this constant its
16
+ // tokens would bleed into the Conversation bucket and skew the chart.
17
+ const CLAUDE_PRESET_ESTIMATE_TOKENS = 4000;
18
+
19
+ const CHARS_PER_TOKEN = 4;
20
+
21
+ export function estimateTokens(text: string | undefined | null): number {
22
+ if (!text) return 0;
23
+ return Math.max(0, Math.round(text.length / CHARS_PER_TOKEN));
24
+ }
25
+
26
+ export function estimateJsonTokens(value: unknown): number {
27
+ try {
28
+ return estimateTokens(JSON.stringify(value));
29
+ } catch {
30
+ return 0;
31
+ }
32
+ }
33
+
34
+ interface SlashCommandLike {
35
+ name?: string;
36
+ description?: string;
37
+ input?: { hint?: string } | null;
38
+ }
39
+
40
+ export function estimateSkillsTokens(commands: SlashCommandLike[]): number {
41
+ if (!commands.length) return 0;
42
+ return estimateJsonTokens(
43
+ commands.map((c) => ({
44
+ name: c.name,
45
+ description: c.description,
46
+ hint: c.input?.hint,
47
+ })),
48
+ );
49
+ }
50
+
51
+ interface McpToolLike {
52
+ name?: string;
53
+ description?: string;
54
+ }
55
+
56
+ // The SDK relies on tool search rather than inlining full MCP schemas in the
57
+ // prompt, so name + description is a conservative estimate of what's resident.
58
+ export function estimateMcpTokens(tools: McpToolLike[]): number {
59
+ if (!tools.length) return 0;
60
+ return estimateJsonTokens(
61
+ tools.map((t) => ({ name: t.name, description: t.description })),
62
+ );
63
+ }
64
+
65
+ export function estimateRulesTokens(rules: string | undefined): number {
66
+ return estimateTokens(rules);
67
+ }
68
+
69
+ export interface ContextBreakdownBaseline {
70
+ systemPrompt: number;
71
+ tools: number;
72
+ rules: number;
73
+ skills: number;
74
+ mcp: number;
75
+ subagents: number;
76
+ }
77
+
78
+ export function emptyBaseline(): ContextBreakdownBaseline {
79
+ return {
80
+ systemPrompt: 0,
81
+ tools: 0,
82
+ rules: 0,
83
+ skills: 0,
84
+ mcp: 0,
85
+ subagents: 0,
86
+ };
87
+ }
88
+
89
+ export function estimateSystemPrompt(systemPrompt: unknown): number {
90
+ if (!systemPrompt) return CLAUDE_PRESET_ESTIMATE_TOKENS;
91
+ if (typeof systemPrompt === "string") return estimateTokens(systemPrompt);
92
+ if (typeof systemPrompt === "object") {
93
+ const obj = systemPrompt as { type?: string; append?: unknown };
94
+ const appendTokens =
95
+ typeof obj.append === "string" ? estimateTokens(obj.append) : 0;
96
+ if (obj.type === "preset") {
97
+ return CLAUDE_PRESET_ESTIMATE_TOKENS + appendTokens;
98
+ }
99
+ return appendTokens;
100
+ }
101
+ return 0;
102
+ }
103
+
104
+ // Conversation is floored at 0 so estimation drift in the stable categories
105
+ // can't surface a negative bucket.
106
+ export function buildBreakdown(
107
+ baseline: ContextBreakdownBaseline,
108
+ currentInputTokens: number,
109
+ ): ContextBreakdown {
110
+ const stableSum =
111
+ baseline.systemPrompt +
112
+ baseline.tools +
113
+ baseline.rules +
114
+ baseline.skills +
115
+ baseline.mcp +
116
+ baseline.subagents;
117
+ const conversation = Math.max(0, currentInputTokens - stableSum);
118
+ return {
119
+ systemPrompt: baseline.systemPrompt,
120
+ tools: baseline.tools,
121
+ rules: baseline.rules,
122
+ skills: baseline.skills,
123
+ mcp: baseline.mcp,
124
+ subagents: baseline.subagents,
125
+ conversation,
126
+ };
127
+ }
@@ -95,6 +95,45 @@ describe("promptToClaude", () => {
95
95
  expect(JSON.stringify(attachRes)).toContain("KEEP_ATTACH");
96
96
  });
97
97
 
98
+ it("forwards base64 image blocks with supported MIME types", () => {
99
+ const result = promptToClaude({
100
+ sessionId: "session-1",
101
+ prompt: [
102
+ {
103
+ type: "image",
104
+ data: "ZmFrZQ==",
105
+ mimeType: "image/png",
106
+ } as PromptRequest["prompt"][number],
107
+ ],
108
+ });
109
+
110
+ expect(result.message.content).toEqual([
111
+ {
112
+ type: "image",
113
+ source: { type: "base64", data: "ZmFrZQ==", media_type: "image/png" },
114
+ },
115
+ ]);
116
+ });
117
+
118
+ it("replaces unsupported base64 image MIME types with a text notice", () => {
119
+ const result = promptToClaude({
120
+ sessionId: "session-1",
121
+ prompt: [
122
+ {
123
+ type: "image",
124
+ data: "ZmFrZQ==",
125
+ mimeType: "image/heic",
126
+ } as PromptRequest["prompt"][number],
127
+ ],
128
+ });
129
+
130
+ expect(result.message.content).toHaveLength(1);
131
+ expect(result.message.content[0]).toMatchObject({
132
+ type: "text",
133
+ text: expect.stringContaining("image/heic"),
134
+ });
135
+ });
136
+
98
137
  it("maps file URI-only image blocks to workspace Read prompt text", () => {
99
138
  const req: PromptRequest = {
100
139
  sessionId: "session-1",
@@ -3,24 +3,12 @@ import { fileURLToPath } from "node:url";
3
3
  import type { PromptRequest } from "@agentclientprotocol/sdk";
4
4
  import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
5
5
  import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
6
-
7
- type ImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
6
+ import { isClaudeImageMimeType, isImageFile } from "@posthog/shared";
8
7
 
9
8
  const PDF_EXTENSIONS = new Set(["pdf"]);
10
9
 
11
- const COMMON_IMAGE_EXTENSIONS = new Set([
12
- "png",
13
- "jpg",
14
- "jpeg",
15
- "gif",
16
- "webp",
17
- "bmp",
18
- "svg",
19
- "heic",
20
- "tif",
21
- "tiff",
22
- ]);
23
-
10
+ // Video-only on purpose: audio formats get the default "large text" hint.
11
+ // Do not replace with AUDIO_VIDEO_EXTENSIONS from @posthog/shared.
24
12
  const VIDEO_EXTENSIONS = new Set([
25
13
  "mp4",
26
14
  "mov",
@@ -53,7 +41,7 @@ export function readToolGuidanceForPath(filePath: string): string {
53
41
  if (PDF_EXTENSIONS.has(ext)) {
54
42
  return 'Optional `pages` string (e.g. "1-5") per Read call instead of loading the entire PDF.';
55
43
  }
56
- if (COMMON_IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)) {
44
+ if (isImageFile(filePath) || VIDEO_EXTENSIONS.has(ext)) {
57
45
  return "Binary file — use Read with `file_path`; prefer bounded reads where supported.";
58
46
  }
59
47
  return "Large text — use multiple Read calls with optional `offset` and `limit`.";
@@ -131,14 +119,22 @@ function processPromptChunk(
131
119
 
132
120
  case "image":
133
121
  if (chunk.data) {
134
- content.push({
135
- type: "image",
136
- source: {
137
- type: "base64",
138
- data: chunk.data,
139
- media_type: chunk.mimeType as ImageMimeType,
140
- },
141
- });
122
+ if (isClaudeImageMimeType(chunk.mimeType)) {
123
+ content.push({
124
+ type: "image",
125
+ source: {
126
+ type: "base64",
127
+ data: chunk.data,
128
+ media_type: chunk.mimeType,
129
+ },
130
+ });
131
+ } else {
132
+ content.push(
133
+ sdkText(
134
+ `[Unsupported image MIME type: ${chunk.mimeType}. Supported: image/jpeg, image/png, image/gif, image/webp.]`,
135
+ ),
136
+ );
137
+ }
142
138
  } else if (chunk.uri?.startsWith("http")) {
143
139
  content.push({
144
140
  type: "image",