@posthog/agent 2.3.508 → 2.3.513
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/agent.js +234 -110
- package/dist/agent.js.map +1 -1
- package/dist/handoff-checkpoint.js +212 -18
- package/dist/handoff-checkpoint.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +463 -145
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +445 -127
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +2 -2
- package/src/adapters/claude/claude-agent.ts +10 -0
- package/src/adapters/claude/conversion/sdk-to-acp.ts +67 -6
- package/src/adapters/claude/session/mcp-config.test.ts +112 -0
- package/src/adapters/claude/session/mcp-config.ts +45 -0
- package/src/adapters/claude/session/options.ts +4 -0
- package/src/adapters/claude/types.ts +10 -0
- package/src/utils/partial-json.test.ts +72 -0
- package/src/utils/partial-json.ts +68 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.513",
|
|
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": {
|
|
@@ -102,8 +102,8 @@
|
|
|
102
102
|
"tsx": "^4.20.6",
|
|
103
103
|
"typescript": "^5.5.0",
|
|
104
104
|
"vitest": "^2.1.8",
|
|
105
|
-
"@posthog/shared": "1.0.0",
|
|
106
105
|
"@posthog/git": "1.0.0",
|
|
106
|
+
"@posthog/shared": "1.0.0",
|
|
107
107
|
"@posthog/enricher": "1.0.0"
|
|
108
108
|
},
|
|
109
109
|
"dependencies": {
|
|
@@ -103,6 +103,7 @@ import type {
|
|
|
103
103
|
SDKMessageFilter,
|
|
104
104
|
Session,
|
|
105
105
|
ToolUseCache,
|
|
106
|
+
ToolUseStreamCache,
|
|
106
107
|
} from "./types";
|
|
107
108
|
|
|
108
109
|
const SESSION_VALIDATION_TIMEOUT_MS = 30_000;
|
|
@@ -145,6 +146,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
145
146
|
readonly adapterName = "claude";
|
|
146
147
|
declare session: Session;
|
|
147
148
|
toolUseCache: ToolUseCache;
|
|
149
|
+
toolUseStreamCache: ToolUseStreamCache;
|
|
148
150
|
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
|
|
149
151
|
clientCapabilities?: ClientCapabilities;
|
|
150
152
|
private options?: ClaudeAcpAgentOptions;
|
|
@@ -155,6 +157,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
155
157
|
super(client);
|
|
156
158
|
this.options = options;
|
|
157
159
|
this.toolUseCache = {};
|
|
160
|
+
this.toolUseStreamCache = new Map();
|
|
158
161
|
this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" });
|
|
159
162
|
this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger);
|
|
160
163
|
}
|
|
@@ -403,6 +406,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
403
406
|
sessionId: params.sessionId,
|
|
404
407
|
client: this.client,
|
|
405
408
|
toolUseCache: this.toolUseCache,
|
|
409
|
+
toolUseStreamCache: this.toolUseStreamCache,
|
|
406
410
|
fileContentCache: this.fileContentCache,
|
|
407
411
|
enrichedReadCache: this.enrichedReadCache,
|
|
408
412
|
logger: this.logger,
|
|
@@ -768,6 +772,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
768
772
|
}
|
|
769
773
|
throw error;
|
|
770
774
|
} finally {
|
|
775
|
+
// Drop any leftover streaming-input buffers. Normally cleared per index
|
|
776
|
+
// on `content_block_stop`, but a cancelled or errored turn may leave
|
|
777
|
+
// entries behind; without this they'd carry over into the next turn
|
|
778
|
+
// and collide with new content-block indices.
|
|
779
|
+
this.toolUseStreamCache.clear();
|
|
771
780
|
if (!handedOff) {
|
|
772
781
|
this.session.promptRunning = false;
|
|
773
782
|
// Resolve all remaining pending prompts so no callers get stuck.
|
|
@@ -1528,6 +1537,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
|
|
|
1528
1537
|
sessionId,
|
|
1529
1538
|
client: this.client,
|
|
1530
1539
|
toolUseCache: this.toolUseCache,
|
|
1540
|
+
toolUseStreamCache: this.toolUseStreamCache,
|
|
1531
1541
|
fileContentCache: this.fileContentCache,
|
|
1532
1542
|
enrichedReadCache: this.enrichedReadCache,
|
|
1533
1543
|
logger: this.logger,
|
|
@@ -21,8 +21,14 @@ import { POSTHOG_NOTIFICATIONS } from "@/acp-extensions";
|
|
|
21
21
|
import { image, text } from "../../../utils/acp-content";
|
|
22
22
|
import { unreachable } from "../../../utils/common";
|
|
23
23
|
import type { Logger } from "../../../utils/logger";
|
|
24
|
+
import { tryParsePartialJson } from "../../../utils/partial-json";
|
|
24
25
|
import { type EnrichedReadCache, registerHookCallback } from "../hooks";
|
|
25
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
Session,
|
|
28
|
+
ToolUpdateMeta,
|
|
29
|
+
ToolUseCache,
|
|
30
|
+
ToolUseStreamCache,
|
|
31
|
+
} from "../types";
|
|
26
32
|
import {
|
|
27
33
|
type ClaudePlanEntry,
|
|
28
34
|
planEntries,
|
|
@@ -67,6 +73,8 @@ export interface MessageHandlerContext {
|
|
|
67
73
|
sessionId: string;
|
|
68
74
|
client: AgentSideConnection;
|
|
69
75
|
toolUseCache: ToolUseCache;
|
|
76
|
+
/** Buffers `input_json_delta` partial JSON per content-block index. */
|
|
77
|
+
toolUseStreamCache: ToolUseStreamCache;
|
|
70
78
|
fileContentCache: { [key: string]: string };
|
|
71
79
|
enrichedReadCache?: EnrichedReadCache;
|
|
72
80
|
logger: Logger;
|
|
@@ -496,6 +504,7 @@ function streamEventToAcpNotifications(
|
|
|
496
504
|
message: SDKPartialAssistantMessage,
|
|
497
505
|
sessionId: string,
|
|
498
506
|
toolUseCache: ToolUseCache,
|
|
507
|
+
toolUseStreamCache: ToolUseStreamCache,
|
|
499
508
|
fileContentCache: { [key: string]: string },
|
|
500
509
|
client: AgentSideConnection,
|
|
501
510
|
logger: Logger,
|
|
@@ -507,9 +516,16 @@ function streamEventToAcpNotifications(
|
|
|
507
516
|
): SessionNotification[] {
|
|
508
517
|
const event = message.event;
|
|
509
518
|
switch (event.type) {
|
|
510
|
-
case "content_block_start":
|
|
519
|
+
case "content_block_start": {
|
|
520
|
+
const block = event.content_block;
|
|
521
|
+
if (block.type === "tool_use" || block.type === "mcp_tool_use") {
|
|
522
|
+
toolUseStreamCache.set(event.index, {
|
|
523
|
+
toolUseId: block.id,
|
|
524
|
+
partialJson: "",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
511
527
|
return toAcpNotifications(
|
|
512
|
-
[
|
|
528
|
+
[block],
|
|
513
529
|
"assistant",
|
|
514
530
|
sessionId,
|
|
515
531
|
toolUseCache,
|
|
@@ -523,7 +539,16 @@ function streamEventToAcpNotifications(
|
|
|
523
539
|
undefined,
|
|
524
540
|
enrichedReadCache,
|
|
525
541
|
);
|
|
526
|
-
|
|
542
|
+
}
|
|
543
|
+
case "content_block_delta": {
|
|
544
|
+
if (event.delta.type === "input_json_delta") {
|
|
545
|
+
return inputJsonDeltaToAcpNotifications(
|
|
546
|
+
event.index,
|
|
547
|
+
event.delta.partial_json,
|
|
548
|
+
sessionId,
|
|
549
|
+
toolUseStreamCache,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
527
552
|
return toAcpNotifications(
|
|
528
553
|
[event.delta],
|
|
529
554
|
"assistant",
|
|
@@ -539,10 +564,13 @@ function streamEventToAcpNotifications(
|
|
|
539
564
|
undefined,
|
|
540
565
|
enrichedReadCache,
|
|
541
566
|
);
|
|
567
|
+
}
|
|
568
|
+
case "content_block_stop":
|
|
569
|
+
toolUseStreamCache.delete(event.index);
|
|
570
|
+
return [];
|
|
542
571
|
case "message_start":
|
|
543
572
|
case "message_delta":
|
|
544
573
|
case "message_stop":
|
|
545
|
-
case "content_block_stop":
|
|
546
574
|
return [];
|
|
547
575
|
|
|
548
576
|
default:
|
|
@@ -551,6 +579,31 @@ function streamEventToAcpNotifications(
|
|
|
551
579
|
}
|
|
552
580
|
}
|
|
553
581
|
|
|
582
|
+
function inputJsonDeltaToAcpNotifications(
|
|
583
|
+
index: number,
|
|
584
|
+
partialJson: string,
|
|
585
|
+
sessionId: string,
|
|
586
|
+
toolUseStreamCache: ToolUseStreamCache,
|
|
587
|
+
): SessionNotification[] {
|
|
588
|
+
const entry = toolUseStreamCache.get(index);
|
|
589
|
+
if (!entry) return [];
|
|
590
|
+
entry.partialJson += partialJson;
|
|
591
|
+
|
|
592
|
+
const parsed = tryParsePartialJson(entry.partialJson);
|
|
593
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
594
|
+
|
|
595
|
+
return [
|
|
596
|
+
{
|
|
597
|
+
sessionId,
|
|
598
|
+
update: {
|
|
599
|
+
sessionUpdate: "tool_call_update" as const,
|
|
600
|
+
toolCallId: entry.toolUseId,
|
|
601
|
+
rawInput: parsed as Record<string, unknown>,
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
|
|
554
607
|
export async function handleSystemMessage(
|
|
555
608
|
message: Extract<SDKMessage, { type: "system" }>,
|
|
556
609
|
context: MessageHandlerContext,
|
|
@@ -743,13 +796,21 @@ export async function handleStreamEvent(
|
|
|
743
796
|
message: SDKPartialAssistantMessage,
|
|
744
797
|
context: MessageHandlerContext,
|
|
745
798
|
): Promise<void> {
|
|
746
|
-
const {
|
|
799
|
+
const {
|
|
800
|
+
sessionId,
|
|
801
|
+
client,
|
|
802
|
+
toolUseCache,
|
|
803
|
+
toolUseStreamCache,
|
|
804
|
+
fileContentCache,
|
|
805
|
+
logger,
|
|
806
|
+
} = context;
|
|
747
807
|
const parentToolCallId = message.parent_tool_use_id ?? undefined;
|
|
748
808
|
|
|
749
809
|
for (const notification of streamEventToAcpNotifications(
|
|
750
810
|
message,
|
|
751
811
|
sessionId,
|
|
752
812
|
toolUseCache,
|
|
813
|
+
toolUseStreamCache,
|
|
753
814
|
fileContentCache,
|
|
754
815
|
client,
|
|
755
816
|
logger,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
|
|
6
|
+
|
|
7
|
+
describe("loadUserClaudeJsonMcpServers", () => {
|
|
8
|
+
let tmpHome: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "claude-json-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
{ name: "~/.claude.json is missing", setup: () => undefined },
|
|
20
|
+
{
|
|
21
|
+
name: "~/.claude.json contains invalid JSON",
|
|
22
|
+
setup: (home: string) =>
|
|
23
|
+
fs.writeFileSync(path.join(home, ".claude.json"), "not json"),
|
|
24
|
+
},
|
|
25
|
+
])("returns empty when $name", ({ setup }) => {
|
|
26
|
+
setup(tmpHome);
|
|
27
|
+
expect(
|
|
28
|
+
loadUserClaudeJsonMcpServers("/some/cwd", undefined, tmpHome),
|
|
29
|
+
).toEqual({});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns top-level mcpServers", () => {
|
|
33
|
+
const cfg = {
|
|
34
|
+
mcpServers: {
|
|
35
|
+
top: { type: "stdio", command: "npx", args: ["pkg"] },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg));
|
|
39
|
+
const servers = loadUserClaudeJsonMcpServers(
|
|
40
|
+
"/some/cwd",
|
|
41
|
+
undefined,
|
|
42
|
+
tmpHome,
|
|
43
|
+
);
|
|
44
|
+
expect(servers.top).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns project-scoped mcpServers when cwd matches a project entry", () => {
|
|
48
|
+
const cwd = "/Users/jane/proj";
|
|
49
|
+
const cfg = {
|
|
50
|
+
projects: {
|
|
51
|
+
[cwd]: {
|
|
52
|
+
mcpServers: {
|
|
53
|
+
playwright: {
|
|
54
|
+
type: "stdio",
|
|
55
|
+
command: "npx",
|
|
56
|
+
args: ["@playwright/mcp@latest"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg));
|
|
63
|
+
const servers = loadUserClaudeJsonMcpServers(cwd, undefined, tmpHome);
|
|
64
|
+
expect(servers.playwright).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("project-scoped servers override top-level on key collision", () => {
|
|
68
|
+
const cwd = "/Users/jane/proj";
|
|
69
|
+
const cfg = {
|
|
70
|
+
mcpServers: {
|
|
71
|
+
shared: { type: "stdio", command: "global", args: [] },
|
|
72
|
+
},
|
|
73
|
+
projects: {
|
|
74
|
+
[cwd]: {
|
|
75
|
+
mcpServers: {
|
|
76
|
+
shared: { type: "stdio", command: "scoped", args: [] },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
fs.writeFileSync(path.join(tmpHome, ".claude.json"), JSON.stringify(cfg));
|
|
82
|
+
const servers = loadUserClaudeJsonMcpServers(cwd, undefined, tmpHome);
|
|
83
|
+
expect((servers.shared as { command: string }).command).toBe("scoped");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ignores CLAUDE_CONFIG_DIR redirect (reads real ~/.claude.json)", () => {
|
|
87
|
+
const altDir = fs.mkdtempSync(path.join(os.tmpdir(), "alt-claude-"));
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(altDir, ".claude.json"),
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
mcpServers: { wrong: { type: "stdio", command: "x" } },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
fs.writeFileSync(
|
|
95
|
+
path.join(tmpHome, ".claude.json"),
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
mcpServers: { right: { type: "stdio", command: "y" } },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
const original = process.env.CLAUDE_CONFIG_DIR;
|
|
101
|
+
process.env.CLAUDE_CONFIG_DIR = altDir;
|
|
102
|
+
try {
|
|
103
|
+
const servers = loadUserClaudeJsonMcpServers("/cwd", undefined, tmpHome);
|
|
104
|
+
expect(servers.right).toBeDefined();
|
|
105
|
+
expect(servers.wrong).toBeUndefined();
|
|
106
|
+
} finally {
|
|
107
|
+
if (original === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
108
|
+
else process.env.CLAUDE_CONFIG_DIR = original;
|
|
109
|
+
fs.rmSync(altDir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,5 +1,50 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
1
4
|
import type { NewSessionRequest } from "@agentclientprotocol/sdk";
|
|
2
5
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import type { Logger } from "../../../utils/logger";
|
|
7
|
+
|
|
8
|
+
export function loadUserClaudeJsonMcpServers(
|
|
9
|
+
cwd: string,
|
|
10
|
+
logger?: Logger,
|
|
11
|
+
homeDir: string = os.homedir(),
|
|
12
|
+
): Record<string, McpServerConfig> {
|
|
13
|
+
const claudeJsonPath = path.join(homeDir, ".claude.json");
|
|
14
|
+
|
|
15
|
+
let raw: string;
|
|
16
|
+
try {
|
|
17
|
+
raw = fs.readFileSync(claudeJsonPath, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let cfg: {
|
|
23
|
+
mcpServers?: unknown;
|
|
24
|
+
projects?: Record<string, { mcpServers?: unknown }>;
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
cfg = JSON.parse(raw);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger?.warn("Failed to parse ~/.claude.json", {
|
|
30
|
+
error: err instanceof Error ? err.message : String(err),
|
|
31
|
+
});
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const topLevel =
|
|
36
|
+
cfg.mcpServers && typeof cfg.mcpServers === "object"
|
|
37
|
+
? (cfg.mcpServers as Record<string, McpServerConfig>)
|
|
38
|
+
: {};
|
|
39
|
+
|
|
40
|
+
const project = cfg.projects?.[cwd];
|
|
41
|
+
const projectScoped =
|
|
42
|
+
project?.mcpServers && typeof project.mcpServers === "object"
|
|
43
|
+
? (project.mcpServers as Record<string, McpServerConfig>)
|
|
44
|
+
: {};
|
|
45
|
+
|
|
46
|
+
return { ...topLevel, ...projectScoped };
|
|
47
|
+
}
|
|
3
48
|
|
|
4
49
|
export function parseMcpServers(
|
|
5
50
|
params: Pick<NewSessionRequest, "mcpServers">,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import type { CodeExecutionMode } from "../tools";
|
|
25
25
|
import type { EffortLevel } from "../types";
|
|
26
26
|
import { APPENDED_INSTRUCTIONS } from "./instructions";
|
|
27
|
+
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
|
|
27
28
|
import { DEFAULT_MODEL } from "./models";
|
|
28
29
|
import type { SettingsManager } from "./settings";
|
|
29
30
|
|
|
@@ -91,8 +92,10 @@ export function buildSystemPrompt(
|
|
|
91
92
|
function buildMcpServers(
|
|
92
93
|
userServers: Record<string, McpServerConfig> | undefined,
|
|
93
94
|
acpServers: Record<string, McpServerConfig>,
|
|
95
|
+
projectScopedServers: Record<string, McpServerConfig>,
|
|
94
96
|
): Record<string, McpServerConfig> {
|
|
95
97
|
return {
|
|
98
|
+
...projectScopedServers,
|
|
96
99
|
...(userServers || {}),
|
|
97
100
|
...acpServers,
|
|
98
101
|
};
|
|
@@ -330,6 +333,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
|
|
|
330
333
|
mcpServers: buildMcpServers(
|
|
331
334
|
params.userProvidedOptions?.mcpServers,
|
|
332
335
|
params.mcpServers,
|
|
336
|
+
loadUserClaudeJsonMcpServers(params.cwd, params.logger),
|
|
333
337
|
),
|
|
334
338
|
env: buildEnvironment(),
|
|
335
339
|
hooks: buildHooks(
|
|
@@ -76,6 +76,16 @@ export type ToolUseCache = {
|
|
|
76
76
|
};
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Per-content-block-index buffer for tool inputs streamed via
|
|
81
|
+
* `input_json_delta` events. Keyed by the Anthropic content block index
|
|
82
|
+
* (which resets per assistant message). Cleared on `content_block_stop`.
|
|
83
|
+
*/
|
|
84
|
+
export type ToolUseStreamCache = Map<
|
|
85
|
+
number,
|
|
86
|
+
{ toolUseId: string; partialJson: string }
|
|
87
|
+
>;
|
|
88
|
+
|
|
79
89
|
export type TerminalInfo = {
|
|
80
90
|
terminal_id: string;
|
|
81
91
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { tryParsePartialJson } from "./partial-json";
|
|
3
|
+
|
|
4
|
+
describe("tryParsePartialJson", () => {
|
|
5
|
+
it("returns null for empty / whitespace input", () => {
|
|
6
|
+
expect(tryParsePartialJson("")).toBeNull();
|
|
7
|
+
expect(tryParsePartialJson(" ")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses complete JSON unchanged", () => {
|
|
11
|
+
expect(tryParsePartialJson('{"a":1}')).toEqual({ a: 1 });
|
|
12
|
+
expect(tryParsePartialJson("[1,2,3]")).toEqual([1, 2, 3]);
|
|
13
|
+
expect(tryParsePartialJson('"hello"')).toBe("hello");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("closes a single open object", () => {
|
|
17
|
+
expect(tryParsePartialJson("{")).toEqual({});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("closes a partial string value and the surrounding object", () => {
|
|
21
|
+
expect(tryParsePartialJson('{"command": "call execute-')).toEqual({
|
|
22
|
+
command: "call execute-",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("closes a complete string value with no closing brace", () => {
|
|
27
|
+
expect(tryParsePartialJson('{"command": "tools"')).toEqual({
|
|
28
|
+
command: "tools",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("strips a trailing comma after a complete entry", () => {
|
|
33
|
+
expect(tryParsePartialJson('{"a": 1,')).toEqual({ a: 1 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("drops a trailing partial key with no value", () => {
|
|
37
|
+
expect(tryParsePartialJson('{"a": 1, "b":')).toEqual({ a: 1 });
|
|
38
|
+
expect(tryParsePartialJson('{"a": 1, "b"')).toEqual({ a: 1 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles nested objects and arrays mid-stream", () => {
|
|
42
|
+
expect(tryParsePartialJson('{"q": {"sql": "SELECT 1')).toEqual({
|
|
43
|
+
q: { sql: "SELECT 1" },
|
|
44
|
+
});
|
|
45
|
+
expect(tryParsePartialJson('{"items": [1, 2, 3')).toEqual({
|
|
46
|
+
items: [1, 2, 3],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("respects escaped quotes inside strings", () => {
|
|
51
|
+
expect(tryParsePartialJson('{"q": "say \\"hi\\"')).toEqual({
|
|
52
|
+
q: 'say "hi"',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns null when nothing parseable can be reconstructed", () => {
|
|
57
|
+
// Garbage that can't be balanced into valid JSON.
|
|
58
|
+
expect(tryParsePartialJson("not json at all")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("parses a typical exec command incrementally", () => {
|
|
62
|
+
// Simulate growth of a streamed { command: "call dashboard-update {...}" }
|
|
63
|
+
expect(tryParsePartialJson('{"command":')).toEqual({});
|
|
64
|
+
expect(tryParsePartialJson('{"command": "ca')).toEqual({ command: "ca" });
|
|
65
|
+
expect(
|
|
66
|
+
tryParsePartialJson('{"command": "call dashboard-update {\\"id\\":'),
|
|
67
|
+
).toEqual({ command: 'call dashboard-update {"id":' });
|
|
68
|
+
expect(
|
|
69
|
+
tryParsePartialJson('{"command": "call dashboard-update {\\"id\\": 1}"}'),
|
|
70
|
+
).toEqual({ command: 'call dashboard-update {"id": 1}' });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort parser for incomplete JSON streamed via Anthropic's
|
|
3
|
+
* `input_json_delta` events. Used to surface tool inputs while they are still
|
|
4
|
+
* being generated so the UI can show the args during execution instead of
|
|
5
|
+
* waiting for the finalized assistant message.
|
|
6
|
+
*
|
|
7
|
+
* Strategy: walk the input tracking open `{`/`[` and quote/escape state, then
|
|
8
|
+
* try a few completions in order of likelihood (close any open string, drop
|
|
9
|
+
* trailing commas/colons or partial keys, then close any open brackets).
|
|
10
|
+
* Returns `null` when no completion parses — callers should silently skip
|
|
11
|
+
* that delta and wait for more input.
|
|
12
|
+
*/
|
|
13
|
+
export function tryParsePartialJson(s: string): unknown {
|
|
14
|
+
const trimmed = s.trim();
|
|
15
|
+
if (!trimmed) return null;
|
|
16
|
+
|
|
17
|
+
// Fast path: complete JSON.
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(trimmed);
|
|
20
|
+
} catch {}
|
|
21
|
+
|
|
22
|
+
const closers: string[] = [];
|
|
23
|
+
let inString = false;
|
|
24
|
+
let escaped = false;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
27
|
+
const ch = trimmed[i];
|
|
28
|
+
if (inString) {
|
|
29
|
+
if (escaped) {
|
|
30
|
+
escaped = false;
|
|
31
|
+
} else if (ch === "\\") {
|
|
32
|
+
escaped = true;
|
|
33
|
+
} else if (ch === '"') {
|
|
34
|
+
inString = false;
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ch === '"') inString = true;
|
|
39
|
+
else if (ch === "{") closers.push("}");
|
|
40
|
+
else if (ch === "[") closers.push("]");
|
|
41
|
+
else if (ch === "}" || ch === "]") closers.pop();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const closeBrackets = (str: string): string => {
|
|
45
|
+
let out = str;
|
|
46
|
+
for (let i = closers.length - 1; i >= 0; i--) out += closers[i];
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const candidates: string[] = [];
|
|
51
|
+
|
|
52
|
+
// 1. Close any open string + brackets.
|
|
53
|
+
const closedString = inString ? `${trimmed}"` : trimmed;
|
|
54
|
+
candidates.push(closeBrackets(closedString));
|
|
55
|
+
|
|
56
|
+
// 2. Drop trailing partial token (comma, colon, or `"key":`/`"key"`)
|
|
57
|
+
// and close brackets.
|
|
58
|
+
let stripped = closedString.replace(/[,:]\s*$/, "");
|
|
59
|
+
stripped = stripped.replace(/,?\s*"[^"]*"\s*:?\s*$/, "");
|
|
60
|
+
candidates.push(closeBrackets(stripped));
|
|
61
|
+
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(candidate);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|