* fix: show loading skeleton for pending tool call details * fix: surface claude tool previews before full json * fix: route claude partial tool input through canonical mapping * fix: remove pending tool-call summary placeholder
This commit is contained in:
@@ -447,7 +447,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("applies input_json_delta updates only when buffered JSON is complete", async () => {
|
||||
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
@@ -485,7 +485,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
partial_json: "{\"command\":\"echo ",
|
||||
},
|
||||
},
|
||||
expectedCommand: "echo seed",
|
||||
expectedCommand: "echo ",
|
||||
},
|
||||
{
|
||||
event: {
|
||||
@@ -528,6 +528,45 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
toolUseCache: Map<string, { input?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
const toolUseId = "tool-input-preview";
|
||||
const index = 8;
|
||||
try {
|
||||
internal.mapPartialEvent({
|
||||
type: "content_block_start",
|
||||
index,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: toolUseId,
|
||||
name: "Edit",
|
||||
},
|
||||
});
|
||||
|
||||
internal.mapPartialEvent({
|
||||
type: "content_block_delta",
|
||||
index,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json:
|
||||
"{\"file_path\":\"src/message.tsx\",\"old_string\":\"before",
|
||||
},
|
||||
});
|
||||
|
||||
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("maps tool_result content shapes into deterministic string output", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
listClaudeCatalogModels,
|
||||
type ClaudeModelFamily,
|
||||
} from "./claude/model-catalog.js";
|
||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
|
||||
import type {
|
||||
@@ -4398,15 +4399,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
const buffer = (this.toolUseInputBuffers.get(toolId) ?? "") + partialJson;
|
||||
this.toolUseInputBuffers.set(toolId, buffer);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(buffer);
|
||||
} catch {
|
||||
const entry = this.toolUseCache.get(toolId);
|
||||
const parsed = parsePartialJsonObject(buffer);
|
||||
if (!entry || !parsed) {
|
||||
return;
|
||||
}
|
||||
const entry = this.toolUseCache.get(toolId);
|
||||
const normalized = this.normalizeToolInput(parsed);
|
||||
if (!entry || !normalized) {
|
||||
const normalized = this.normalizeToolInput(parsed.value);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (!parsed.complete && Object.keys(normalized).length === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.areToolInputsEqual(entry.input ?? undefined, normalized)) {
|
||||
return;
|
||||
}
|
||||
this.applyToolInput(entry, normalized);
|
||||
@@ -4428,6 +4433,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return input;
|
||||
}
|
||||
|
||||
private areToolInputsEqual(
|
||||
left: AgentMetadata | undefined,
|
||||
right: AgentMetadata
|
||||
): boolean {
|
||||
if (!left) {
|
||||
return false;
|
||||
}
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
return rightKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
private applyToolInput(entry: ToolUseCacheEntry, input: AgentMetadata): void {
|
||||
entry.input = input;
|
||||
if (this.isCommandTool(entry.name, input)) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
|
||||
describe("parsePartialJsonObject", () => {
|
||||
it("parses complete objects", () => {
|
||||
expect(
|
||||
parsePartialJsonObject("{\"command\":\"pwd\",\"cwd\":\"/tmp/repo\"}")
|
||||
).toEqual({
|
||||
value: {
|
||||
command: "pwd",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
complete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps partial string values without field-specific logic", () => {
|
||||
expect(parsePartialJsonObject("{\"command\":\"echo ")).toEqual({
|
||||
value: {
|
||||
command: "echo ",
|
||||
},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns parsed prefix fields from incomplete objects", () => {
|
||||
expect(
|
||||
parsePartialJsonObject(
|
||||
"{\"file_path\":\"src/message.tsx\",\"old_string\":\"before"
|
||||
)
|
||||
).toEqual({
|
||||
value: {
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses nested partial values generically", () => {
|
||||
expect(
|
||||
parsePartialJsonObject(
|
||||
"{\"payload\":{\"path\":\"src/index.ts\",\"content\":\"hello"
|
||||
)
|
||||
).toEqual({
|
||||
value: {
|
||||
payload: {
|
||||
path: "src/index.ts",
|
||||
content: "hello",
|
||||
},
|
||||
},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-object payloads", () => {
|
||||
expect(parsePartialJsonObject("\"text\"")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
type PartialParseResult<T> = {
|
||||
value: T;
|
||||
nextIndex: number;
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
function skipWhitespace(input: string, index: number): number {
|
||||
let currentIndex = index;
|
||||
while (currentIndex < input.length && /\s/u.test(input[currentIndex] ?? "")) {
|
||||
currentIndex += 1;
|
||||
}
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
function parsePartialString(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<string> | null {
|
||||
if (input[index] !== "\"") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentIndex = index + 1;
|
||||
let value = "";
|
||||
|
||||
while (currentIndex < input.length) {
|
||||
const character = input[currentIndex];
|
||||
if (character === "\"") {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex + 1,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
const escapeCharacter = input[currentIndex + 1];
|
||||
if (escapeCharacter === undefined) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
switch (escapeCharacter) {
|
||||
case "\"":
|
||||
case "\\":
|
||||
case "/":
|
||||
value += escapeCharacter;
|
||||
currentIndex += 2;
|
||||
continue;
|
||||
case "b":
|
||||
value += "\b";
|
||||
currentIndex += 2;
|
||||
continue;
|
||||
case "f":
|
||||
value += "\f";
|
||||
currentIndex += 2;
|
||||
continue;
|
||||
case "n":
|
||||
value += "\n";
|
||||
currentIndex += 2;
|
||||
continue;
|
||||
case "r":
|
||||
value += "\r";
|
||||
currentIndex += 2;
|
||||
continue;
|
||||
case "t":
|
||||
value += "\t";
|
||||
currentIndex += 2;
|
||||
continue;
|
||||
case "u": {
|
||||
const hex = input.slice(currentIndex + 2, currentIndex + 6);
|
||||
if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/u.test(hex)) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
value += String.fromCodePoint(Number.parseInt(hex, 16));
|
||||
currentIndex += 6;
|
||||
continue;
|
||||
}
|
||||
default:
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
value += character;
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePartialLiteral(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<boolean | null> | null {
|
||||
const remaining = input.slice(index);
|
||||
if (remaining.startsWith("true")) {
|
||||
return { value: true, nextIndex: index + 4, complete: true };
|
||||
}
|
||||
if (remaining.startsWith("false")) {
|
||||
return { value: false, nextIndex: index + 5, complete: true };
|
||||
}
|
||||
if (remaining.startsWith("null")) {
|
||||
return { value: null, nextIndex: index + 4, complete: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePartialNumber(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<number> | null {
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(
|
||||
input.slice(index)
|
||||
);
|
||||
if (!match?.[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: Number(match[0]),
|
||||
nextIndex: index + match[0].length,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePartialArray(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<unknown[]> | null {
|
||||
if (input[index] !== "[") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values: unknown[] = [];
|
||||
let currentIndex = index + 1;
|
||||
|
||||
while (currentIndex <= input.length) {
|
||||
currentIndex = skipWhitespace(input, currentIndex);
|
||||
const nextCharacter = input[currentIndex];
|
||||
|
||||
if (nextCharacter === "]") {
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: currentIndex + 1,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextCharacter === undefined) {
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedValue = parsePartialJsonValue(input, currentIndex);
|
||||
if (!parsedValue) {
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
values.push(parsedValue.value);
|
||||
currentIndex = skipWhitespace(input, parsedValue.nextIndex);
|
||||
|
||||
if (!parsedValue.complete) {
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const delimiter = input[currentIndex];
|
||||
if (delimiter === ",") {
|
||||
currentIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (delimiter === "]") {
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: currentIndex + 1,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
if (delimiter === undefined) {
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: values,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePartialObject(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<Record<string, unknown>> | null {
|
||||
if (input[index] !== "{") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value: Record<string, unknown> = {};
|
||||
let currentIndex = index + 1;
|
||||
|
||||
while (currentIndex <= input.length) {
|
||||
currentIndex = skipWhitespace(input, currentIndex);
|
||||
const nextCharacter = input[currentIndex];
|
||||
|
||||
if (nextCharacter === "}") {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex + 1,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (nextCharacter === undefined) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedKey = parsePartialString(input, currentIndex);
|
||||
if (!parsedKey?.complete) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: parsedKey?.nextIndex ?? currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
currentIndex = skipWhitespace(input, parsedKey.nextIndex);
|
||||
if (input[currentIndex] !== ":") {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
currentIndex = skipWhitespace(input, currentIndex + 1);
|
||||
const parsedMemberValue = parsePartialJsonValue(input, currentIndex);
|
||||
if (!parsedMemberValue) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
value[parsedKey.value] = parsedMemberValue.value;
|
||||
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
|
||||
|
||||
if (!parsedMemberValue.complete) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const delimiter = input[currentIndex];
|
||||
if (delimiter === ",") {
|
||||
currentIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (delimiter === "}") {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex + 1,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
if (delimiter === undefined) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
nextIndex: input.length,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePartialJsonValue(
|
||||
input: string,
|
||||
index: number
|
||||
): PartialParseResult<unknown> | null {
|
||||
const currentIndex = skipWhitespace(input, index);
|
||||
const character = input[currentIndex];
|
||||
|
||||
if (character === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (character === "\"") {
|
||||
return parsePartialString(input, currentIndex);
|
||||
}
|
||||
if (character === "{") {
|
||||
return parsePartialObject(input, currentIndex);
|
||||
}
|
||||
if (character === "[") {
|
||||
return parsePartialArray(input, currentIndex);
|
||||
}
|
||||
if (character === "t" || character === "f" || character === "n") {
|
||||
return parsePartialLiteral(input, currentIndex);
|
||||
}
|
||||
if (character === "-" || /\d/u.test(character)) {
|
||||
return parsePartialNumber(input, currentIndex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePartialJsonObject(
|
||||
input: string
|
||||
): { value: Record<string, unknown>; complete: boolean } | null {
|
||||
const currentIndex = skipWhitespace(input, 0);
|
||||
const parsed = parsePartialObject(input, currentIndex);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: parsed.value,
|
||||
complete:
|
||||
parsed.complete && skipWhitespace(input, parsed.nextIndex) === input.length,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,22 @@ describe("claude tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps partial running input through the same canonical detail path", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
callId: "claude-call-partial-1",
|
||||
name: "Bash",
|
||||
input: { command: "echo " },
|
||||
output: null,
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "echo ",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
|
||||
Reference in New Issue
Block a user