@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/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
- package/dist/adapters/claude/mcp/tool-metadata.d.ts +4 -1
- package/dist/adapters/claude/mcp/tool-metadata.js +4 -0
- package/dist/adapters/claude/mcp/tool-metadata.js.map +1 -1
- package/dist/adapters/claude/tools.js.map +1 -1
- package/dist/agent.js +287 -28
- package/dist/agent.js.map +1 -1
- package/dist/handoff-checkpoint.js +70 -0
- package/dist/handoff-checkpoint.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/resume.js +70 -0
- package/dist/resume.js.map +1 -1
- package/dist/server/agent-server.js +436 -179
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +429 -172
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/adapters/claude/claude-agent.slash-command.test.ts +153 -0
- package/src/adapters/claude/claude-agent.ts +84 -1
- package/src/adapters/claude/context-breakdown.test.ts +130 -0
- package/src/adapters/claude/context-breakdown.ts +127 -0
- package/src/adapters/claude/conversion/acp-to-sdk.test.ts +39 -0
- package/src/adapters/claude/conversion/acp-to-sdk.ts +20 -24
- package/src/adapters/claude/mcp/tool-metadata.ts +6 -0
- package/src/adapters/claude/types.ts +3 -0
- package/src/adapters/codex/codex-agent.ts +46 -5
- package/src/adapters/codex/codex-client.test.ts +49 -1
- package/src/adapters/codex/session-state.ts +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
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:
|
|
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
|
-
|
|
12
|
-
|
|
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 (
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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",
|