@posthog/agent 2.3.678 → 2.3.696
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 +269 -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 +418 -179
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +411 -172
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/adapters/claude/claude-agent.ts +62 -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.696",
|
|
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/
|
|
109
|
+
"@posthog/enricher": "1.0.0",
|
|
110
110
|
"@posthog/shared": "1.0.0",
|
|
111
|
-
"@posthog/
|
|
111
|
+
"@posthog/git": "1.0.0"
|
|
112
112
|
},
|
|
113
113
|
"dependencies": {
|
|
114
114
|
"@agentclientprotocol/sdk": "0.19.0",
|
|
@@ -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, " ")
|
|
@@ -556,6 +581,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
556
581
|
});
|
|
557
582
|
}
|
|
558
583
|
|
|
584
|
+
// `result.usage` is cumulative across the agentic loop; the
|
|
585
|
+
// outermost-model stream snapshot is what's actually resident.
|
|
586
|
+
const breakdownInputTokens =
|
|
587
|
+
lastStreamUsage.input_tokens +
|
|
588
|
+
lastStreamUsage.cache_read_input_tokens +
|
|
589
|
+
lastStreamUsage.cache_creation_input_tokens;
|
|
559
590
|
await this.client.extNotification(
|
|
560
591
|
POSTHOG_NOTIFICATIONS.USAGE_UPDATE,
|
|
561
592
|
{
|
|
@@ -567,6 +598,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
567
598
|
cachedWriteTokens: message.usage.cache_creation_input_tokens,
|
|
568
599
|
},
|
|
569
600
|
cost: message.total_cost_usd,
|
|
601
|
+
breakdown: buildBreakdown(
|
|
602
|
+
this.session.contextBreakdownBaseline ?? emptyBaseline(),
|
|
603
|
+
breakdownInputTokens,
|
|
604
|
+
),
|
|
570
605
|
},
|
|
571
606
|
);
|
|
572
607
|
|
|
@@ -1221,6 +1256,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
1221
1256
|
pendingMessages: new Map(),
|
|
1222
1257
|
nextPendingOrder: 0,
|
|
1223
1258
|
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
1259
|
+
contextBreakdownBaseline: {
|
|
1260
|
+
...emptyBaseline(),
|
|
1261
|
+
systemPrompt: estimateSystemPrompt(systemPrompt),
|
|
1262
|
+
rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)),
|
|
1263
|
+
},
|
|
1224
1264
|
|
|
1225
1265
|
// Custom properties
|
|
1226
1266
|
cwd,
|
|
@@ -1547,13 +1587,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
1547
1587
|
|
|
1548
1588
|
private async sendAvailableCommandsUpdate(): Promise<void> {
|
|
1549
1589
|
const commands = await this.session.query.supportedCommands();
|
|
1590
|
+
const available = getAvailableSlashCommands(commands);
|
|
1550
1591
|
await this.client.sessionUpdate({
|
|
1551
1592
|
sessionId: this.sessionId,
|
|
1552
1593
|
update: {
|
|
1553
1594
|
sessionUpdate: "available_commands_update",
|
|
1554
|
-
availableCommands:
|
|
1595
|
+
availableCommands: available,
|
|
1555
1596
|
},
|
|
1556
1597
|
});
|
|
1598
|
+
this.updateBreakdownCategory("skills", estimateSkillsTokens(available));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/** Update one category of the context-breakdown baseline so the next
|
|
1602
|
+
* `_posthog/usage_update` carries fresher numbers. No-op when the baseline
|
|
1603
|
+
* hasn't been initialized yet (e.g. in a unit-test session). */
|
|
1604
|
+
private updateBreakdownCategory(
|
|
1605
|
+
key: keyof NonNullable<Session["contextBreakdownBaseline"]>,
|
|
1606
|
+
tokens: number,
|
|
1607
|
+
): void {
|
|
1608
|
+
if (!this.session?.contextBreakdownBaseline) return;
|
|
1609
|
+
if (this.session.contextBreakdownBaseline[key] === tokens) return;
|
|
1610
|
+
this.session.contextBreakdownBaseline = {
|
|
1611
|
+
...this.session.contextBreakdownBaseline,
|
|
1612
|
+
[key]: tokens,
|
|
1613
|
+
};
|
|
1557
1614
|
}
|
|
1558
1615
|
|
|
1559
1616
|
private async replaySessionHistory(sessionId: string): Promise<void> {
|
|
@@ -1610,6 +1667,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
1610
1667
|
this.sendAvailableCommandsUpdate(),
|
|
1611
1668
|
),
|
|
1612
1669
|
fetchMcpToolMetadata(q, this.logger).then(() => {
|
|
1670
|
+
this.updateBreakdownCategory(
|
|
1671
|
+
"mcp",
|
|
1672
|
+
estimateMcpTokens(getCachedMcpTools()),
|
|
1673
|
+
);
|
|
1613
1674
|
const serverNames = getConnectedMcpServerNames();
|
|
1614
1675
|
if (serverNames.length > 0) {
|
|
1615
1676
|
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",
|
|
@@ -116,6 +116,12 @@ export function getConnectedMcpServerNames(): string[] {
|
|
|
116
116
|
return [...names];
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** Snapshot of every tool currently in the metadata cache. Used by the
|
|
120
|
+
* context-breakdown estimator to size the MCP category. */
|
|
121
|
+
export function getCachedMcpTools(): McpToolMetadata[] {
|
|
122
|
+
return [...mcpToolMetadataCache.values()];
|
|
123
|
+
}
|
|
124
|
+
|
|
119
125
|
export function getMcpToolApprovalState(
|
|
120
126
|
toolName: string,
|
|
121
127
|
): McpToolApprovalState | undefined {
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
11
11
|
import type { Pushable } from "../../utils/streams";
|
|
12
12
|
import type { BaseSession } from "../base-acp-agent";
|
|
13
|
+
import type { ContextBreakdownBaseline } from "./context-breakdown";
|
|
13
14
|
import type { McpToolApprovals } from "./mcp/tool-metadata";
|
|
14
15
|
import type { SettingsManager } from "./session/settings";
|
|
15
16
|
import type { CodeExecutionMode } from "./tools";
|
|
@@ -65,6 +66,8 @@ export type Session = BaseSession & {
|
|
|
65
66
|
pendingMessages: Map<string, PendingMessage>;
|
|
66
67
|
nextPendingOrder: number;
|
|
67
68
|
emitRawSDKMessages: boolean | SDKMessageFilter[];
|
|
69
|
+
/** Refreshed at session init and on MCP/skill changes. */
|
|
70
|
+
contextBreakdownBaseline?: ContextBreakdownBaseline;
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
export type ToolUseCache = {
|
|
@@ -64,6 +64,12 @@ import {
|
|
|
64
64
|
nodeWritableToWebWritable,
|
|
65
65
|
} from "../../utils/streams";
|
|
66
66
|
import { BaseAcpAgent, type BaseSession } from "../base-acp-agent";
|
|
67
|
+
import {
|
|
68
|
+
buildBreakdown,
|
|
69
|
+
type ContextBreakdownBaseline,
|
|
70
|
+
emptyBaseline,
|
|
71
|
+
estimateTokens,
|
|
72
|
+
} from "../claude/context-breakdown";
|
|
67
73
|
import { classifyAgentError } from "../error-classification";
|
|
68
74
|
import {
|
|
69
75
|
enabledLocalTools,
|
|
@@ -76,6 +82,7 @@ import { normalizeCodexConfigOptions } from "./models";
|
|
|
76
82
|
import {
|
|
77
83
|
type CodexSessionState,
|
|
78
84
|
createSessionState,
|
|
85
|
+
resetSessionState,
|
|
79
86
|
resetUsage,
|
|
80
87
|
} from "./session-state";
|
|
81
88
|
import { CodexSettingsManager } from "./settings";
|
|
@@ -161,6 +168,19 @@ function classifyPromptError(error: unknown): unknown {
|
|
|
161
168
|
);
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
// codex-rs/protocol/src/protocol.rs BASELINE_TOKENS — the always-resident
|
|
172
|
+
// floor (MCP schemas, skills, preset prompt) we can't attribute per-source.
|
|
173
|
+
const CODEX_BASELINE_TOKENS = 12000;
|
|
174
|
+
|
|
175
|
+
function buildCodexBaseline(
|
|
176
|
+
meta: NewSessionMeta | undefined,
|
|
177
|
+
): ContextBreakdownBaseline {
|
|
178
|
+
const baseline = emptyBaseline();
|
|
179
|
+
baseline.systemPrompt =
|
|
180
|
+
CODEX_BASELINE_TOKENS + estimateTokens(meta?.systemPrompt);
|
|
181
|
+
return baseline;
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
const CODEX_NATIVE_MODE: Record<CodeExecutionMode, CodexNativeMode> = {
|
|
165
185
|
auto: "auto",
|
|
166
186
|
default: "auto",
|
|
@@ -392,8 +412,10 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
392
412
|
response.configOptions,
|
|
393
413
|
);
|
|
394
414
|
|
|
395
|
-
// Initialize session state
|
|
396
|
-
this
|
|
415
|
+
// Initialize session state. Mutate in place — codex-client closure-
|
|
416
|
+
// captured this object in the constructor and writes contextUsed/
|
|
417
|
+
// accumulatedUsage to it on every upstream usage_update.
|
|
418
|
+
resetSessionState(this.sessionState, response.sessionId, params.cwd, {
|
|
397
419
|
taskRunId: meta?.taskRunId,
|
|
398
420
|
taskId: resolveTaskId(meta),
|
|
399
421
|
modeId: response.modes?.currentModeId ?? "auto",
|
|
@@ -402,6 +424,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
402
424
|
});
|
|
403
425
|
this.sessionId = response.sessionId;
|
|
404
426
|
this.sessionState.configOptions = response.configOptions ?? [];
|
|
427
|
+
this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
|
|
405
428
|
|
|
406
429
|
await this.applyInitialPermissionMode(
|
|
407
430
|
response.sessionId,
|
|
@@ -445,7 +468,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
445
468
|
// notifications (TURN_COMPLETE, USAGE_UPDATE) after a reload. newSession
|
|
446
469
|
// and unstable_resumeSession both do this; loadSession historically did
|
|
447
470
|
// not, which silently broke task-completion tracking on re-attach.
|
|
448
|
-
this.sessionState
|
|
471
|
+
resetSessionState(this.sessionState, params.sessionId, params.cwd, {
|
|
449
472
|
taskRunId: meta?.taskRunId,
|
|
450
473
|
taskId: resolveTaskId(meta),
|
|
451
474
|
modeId: response.modes?.currentModeId ?? "auto",
|
|
@@ -453,6 +476,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
453
476
|
});
|
|
454
477
|
this.sessionId = params.sessionId;
|
|
455
478
|
this.sessionState.configOptions = response.configOptions ?? [];
|
|
479
|
+
this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
|
|
456
480
|
|
|
457
481
|
if (meta?.taskRunId) {
|
|
458
482
|
await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, {
|
|
@@ -491,7 +515,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
491
515
|
loadResponse.modes?.currentModeId,
|
|
492
516
|
meta?.permissionMode,
|
|
493
517
|
);
|
|
494
|
-
this.sessionState
|
|
518
|
+
resetSessionState(this.sessionState, params.sessionId, params.cwd, {
|
|
495
519
|
taskRunId: meta?.taskRunId,
|
|
496
520
|
taskId: resolveTaskId(meta),
|
|
497
521
|
modeId: loadResponse.modes?.currentModeId ?? "auto",
|
|
@@ -499,6 +523,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
499
523
|
});
|
|
500
524
|
this.sessionId = params.sessionId;
|
|
501
525
|
this.sessionState.configOptions = loadResponse.configOptions ?? [];
|
|
526
|
+
this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
|
|
502
527
|
|
|
503
528
|
if (meta?.taskRunId) {
|
|
504
529
|
await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, {
|
|
@@ -538,7 +563,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
538
563
|
);
|
|
539
564
|
|
|
540
565
|
const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
|
|
541
|
-
this.sessionState
|
|
566
|
+
resetSessionState(this.sessionState, newResponse.sessionId, params.cwd, {
|
|
542
567
|
taskRunId: meta?.taskRunId,
|
|
543
568
|
taskId: resolveTaskId(meta),
|
|
544
569
|
modeId: newResponse.modes?.currentModeId ?? "auto",
|
|
@@ -546,6 +571,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
546
571
|
});
|
|
547
572
|
this.sessionId = newResponse.sessionId;
|
|
548
573
|
this.sessionState.configOptions = newResponse.configOptions ?? [];
|
|
574
|
+
this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
|
|
549
575
|
|
|
550
576
|
await this.applyInitialPermissionMode(
|
|
551
577
|
newResponse.sessionId,
|
|
@@ -730,6 +756,21 @@ export class CodexAcpAgent extends BaseAcpAgent {
|
|
|
730
756
|
}
|
|
731
757
|
}
|
|
732
758
|
|
|
759
|
+
// Emit the per-source breakdown so the renderer's ContextBreakdownPopover
|
|
760
|
+
// has data to show. This fires regardless of `taskRunId` (local sessions
|
|
761
|
+
// need it too) and regardless of `response.usage` (codex-acp doesn't
|
|
762
|
+
// populate it — context size comes from upstream usage_update events,
|
|
763
|
+
// tracked on sessionState.contextUsed).
|
|
764
|
+
if (this.sessionState.contextUsed !== undefined) {
|
|
765
|
+
await this.client.extNotification(POSTHOG_NOTIFICATIONS.USAGE_UPDATE, {
|
|
766
|
+
sessionId: params.sessionId,
|
|
767
|
+
breakdown: buildBreakdown(
|
|
768
|
+
this.sessionState.contextBreakdownBaseline ?? emptyBaseline(),
|
|
769
|
+
this.sessionState.contextUsed,
|
|
770
|
+
),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
733
774
|
return response;
|
|
734
775
|
}
|
|
735
776
|
|