fix: relativize web console tool paths
This commit is contained in:
@@ -36,6 +36,27 @@ function consoleLine(id: string, kind: ConsoleLine["kind"]): ConsoleLine {
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotEvent(cwd: string): Event {
|
||||
return {
|
||||
event: "snapshot",
|
||||
data: {
|
||||
entries: [],
|
||||
greeting: {
|
||||
worker_name: "Worker",
|
||||
cwd,
|
||||
provider: "provider",
|
||||
model: "model",
|
||||
scope_summary: "bounded",
|
||||
tools: [],
|
||||
context_window: 100,
|
||||
context_tokens: 20,
|
||||
},
|
||||
status: "idle",
|
||||
in_flight: { blocks: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||
assert(
|
||||
workerConsoleHref({
|
||||
@@ -886,3 +907,156 @@ Deno.test("selectConsoleTimelineLines keeps all users and only last assistant pe
|
||||
["0:u1", "3:a2", "4:u2", "6:a4"],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole relativizes known tool path displays from snapshot cwd", () => {
|
||||
const projection = projectConsole([
|
||||
{ eventId: "cwd-0", event: snapshotEvent("/repo") },
|
||||
{
|
||||
eventId: "cwd-1",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "read-rel",
|
||||
name: "Read",
|
||||
arguments: JSON.stringify({ file_path: "/repo/src/main.rs" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-2",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "read-rel",
|
||||
summary: "Read 4 line(s) [1..4] of 20 from /repo/src/main.rs",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-3",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "write-rel",
|
||||
name: "Write",
|
||||
arguments: JSON.stringify({ file_path: "/repo/out.txt", content: "ok" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-4",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "write-rel",
|
||||
summary: "Wrote /repo/out.txt",
|
||||
output: "Wrote /repo/out.txt",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-5",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "edit-rel",
|
||||
name: "Edit",
|
||||
arguments: JSON.stringify({
|
||||
file_path: "/repo/src/main.rs",
|
||||
old_string: "a",
|
||||
new_string: "b",
|
||||
}),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-6",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "edit-rel",
|
||||
summary: "Edited /repo/src/main.rs (1 replacement)",
|
||||
output: "Edited /repo/src/main.rs (1 replacement)",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-7",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "glob-rel",
|
||||
name: "Glob",
|
||||
arguments: JSON.stringify({ path: "/repo", pattern: "**/*.rs" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-8",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "glob-rel",
|
||||
summary: "Found 2 file(s) matching **/*.rs",
|
||||
output: "Found 2 file(s) matching **/*.rs\n/repo/src/main.rs\n/outside/lib.rs",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-9",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "grep-rel",
|
||||
name: "Grep",
|
||||
arguments: JSON.stringify({ pattern: "needle", path: "/repo" }),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "cwd-10",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "grep-rel",
|
||||
summary: "2 matching line(s) in 2 file(s)",
|
||||
output: "/repo/src/main.rs:12:needle\n/outside/lib.rs:1:needle",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const bodies = projection.lines.filter((line) => line.kind === "tool").map((line) => line.body);
|
||||
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
|
||||
assert(
|
||||
projection.lines[0].detail?.includes("from src/main.rs"),
|
||||
"Read summary detail path should be relative",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) => body.includes("Write — out.txt") && body.includes("Wrote out.txt")),
|
||||
"Write header and known result path should be relative",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) =>
|
||||
body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs")
|
||||
),
|
||||
"Edit header and known result path should be relative",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) =>
|
||||
body.includes("src/main.rs") && body.includes("/outside/lib.rs")
|
||||
),
|
||||
"Glob should relativize cwd paths and keep outside paths absolute",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) =>
|
||||
body.includes("src/main.rs:12:needle") && body.includes("/outside/lib.rs:1:needle")
|
||||
),
|
||||
"Grep should relativize line-start cwd paths and keep outside paths absolute",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ type ToolCallView = {
|
||||
summary?: string;
|
||||
output?: string | null;
|
||||
isError?: boolean;
|
||||
cwd?: string | null;
|
||||
};
|
||||
|
||||
export type ConsoleDiffLine = {
|
||||
@@ -61,6 +62,7 @@ export type ConsoleProjection = {
|
||||
lines: ConsoleLine[];
|
||||
status: string | null;
|
||||
usage: string | null;
|
||||
cwd: string | null;
|
||||
lastEventId: string | null;
|
||||
};
|
||||
|
||||
@@ -138,6 +140,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
|
||||
lines: [],
|
||||
status: null,
|
||||
usage: null,
|
||||
cwd: null,
|
||||
lastEventId: null,
|
||||
};
|
||||
}
|
||||
@@ -183,6 +186,7 @@ export function applyProtocolEvent(
|
||||
lines: [...projection.lines],
|
||||
status: projection.status,
|
||||
usage: projection.usage,
|
||||
cwd: projection.cwd,
|
||||
lastEventId: envelope.eventId,
|
||||
};
|
||||
const event = envelope.event;
|
||||
@@ -284,16 +288,21 @@ export function applyProtocolEvent(
|
||||
break;
|
||||
case "snapshot":
|
||||
next.status = event.data.status;
|
||||
next.lines = snapshotLinesFromEntries(envelope.eventId, event.data.entries);
|
||||
next.cwd = event.data.greeting.cwd;
|
||||
next.lines = snapshotLinesFromEntries(
|
||||
envelope.eventId,
|
||||
event.data.entries,
|
||||
next.cwd,
|
||||
);
|
||||
for (const block of event.data.in_flight?.blocks ?? []) {
|
||||
next.lines.push(inFlightLine(envelope.eventId, block));
|
||||
next.lines.push(inFlightLine(envelope.eventId, block, next.cwd));
|
||||
}
|
||||
break;
|
||||
case "status":
|
||||
next.status = event.data.status;
|
||||
break;
|
||||
case "segment_rotated":
|
||||
next.lines = snapshotLinesFromEntries(envelope.eventId, [event.data.entry]);
|
||||
next.lines = snapshotLinesFromEntries(envelope.eventId, [event.data.entry], next.cwd);
|
||||
break;
|
||||
case "invoke_start":
|
||||
case "turn_start":
|
||||
@@ -504,6 +513,7 @@ function upsertToolCall(
|
||||
summary: update.summary,
|
||||
output: update.output,
|
||||
isError: update.isError,
|
||||
cwd: update.cwd ?? projection.cwd,
|
||||
});
|
||||
projection.lines.push(created);
|
||||
return created;
|
||||
@@ -518,6 +528,7 @@ function upsertToolCall(
|
||||
...update,
|
||||
id,
|
||||
name: update.name ?? existing.toolCall!.name,
|
||||
cwd: update.cwd ?? existing.toolCall!.cwd ?? projection.cwd,
|
||||
},
|
||||
});
|
||||
projection.lines[index] = updated;
|
||||
@@ -537,6 +548,7 @@ function appendToolArgs(
|
||||
name: "Tool",
|
||||
argsStream: delta,
|
||||
state: "streaming_args",
|
||||
cwd: projection.cwd,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -567,6 +579,7 @@ function attachToolResult(
|
||||
name: "Tool",
|
||||
argsStream: "",
|
||||
state: result.isError ? "error" : "done",
|
||||
cwd: projection.cwd,
|
||||
...result,
|
||||
}),
|
||||
title: result.isError ? "Call · Tool result error" : "Call · Tool result",
|
||||
@@ -703,6 +716,10 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
||||
}
|
||||
|
||||
function readPath(toolCall: ToolCallView): string {
|
||||
return displayPath(readRawPath(toolCall), toolCall.cwd);
|
||||
}
|
||||
|
||||
function readRawPath(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
return stringField(args, "file_path") ?? "?";
|
||||
}
|
||||
@@ -712,37 +729,37 @@ function readDetail(toolCall: ToolCallView): string {
|
||||
`id: ${toolCall.id}`,
|
||||
`state: ${stateSuffix(toolCall.state)}`,
|
||||
`path: ${readPath(toolCall)}`,
|
||||
toolCall.summary ? `summary: ${toolCall.summary}` : undefined,
|
||||
toolCall.summary
|
||||
? `summary: ${normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)}`
|
||||
: undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
function renderReadTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const path = stringField(args, "file_path") ?? "?";
|
||||
return `Read — ${path} (${stateSuffix(toolCall.state)})`;
|
||||
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
|
||||
}
|
||||
|
||||
function renderWriteTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const path = stringField(args, "file_path") ?? "?";
|
||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||
const content = stringField(args, "content");
|
||||
return compactLines([
|
||||
`Write — ${path} (${stateSuffix(toolCall.state)})`,
|
||||
cappedSection(content, 5),
|
||||
resultText(toolCall),
|
||||
knownToolResultText(toolCall),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderEditTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const path = stringField(args, "file_path") ?? "?";
|
||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||
const diff = editDiff(toolCall) ?? [];
|
||||
const removes = diff.filter((line) => line.kind === "remove").length;
|
||||
const adds = diff.filter((line) => line.kind === "add").length;
|
||||
return compactLines([
|
||||
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
|
||||
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
|
||||
resultText(toolCall),
|
||||
knownToolResultText(toolCall),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -829,7 +846,7 @@ function renderSearchTool(toolCall: ToolCallView): string {
|
||||
const summary = toolCall.summary?.trim();
|
||||
return compactLines([
|
||||
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||
resultText(toolCall),
|
||||
knownToolResultText(toolCall),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -838,7 +855,7 @@ function renderGrepTool(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||
grepQueryText(toolCall),
|
||||
cappedResultSection(resultText(toolCall), 5),
|
||||
cappedResultSection(knownToolResultText(toolCall), 5),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -884,7 +901,9 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`id: ${toolCall.id}`,
|
||||
`state: ${stateSuffix(toolCall.state)}`,
|
||||
toolCall.summary ? `summary: ${toolCall.summary}` : undefined,
|
||||
toolCall.summary
|
||||
? `summary: ${normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)}`
|
||||
: undefined,
|
||||
argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined,
|
||||
]);
|
||||
}
|
||||
@@ -896,6 +915,84 @@ function resultText(toolCall: ToolCallView): string | undefined {
|
||||
return toolCall.summary;
|
||||
}
|
||||
|
||||
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
||||
const text = resultText(toolCall);
|
||||
if (!text) return undefined;
|
||||
return normalizeKnownToolResult(toolCall.name, text, toolCall.cwd);
|
||||
}
|
||||
|
||||
function normalizeKnownToolResult(
|
||||
toolName: string,
|
||||
text: string,
|
||||
cwd: string | null | undefined,
|
||||
): string {
|
||||
if (!cwd) return text;
|
||||
switch (toolName) {
|
||||
case "Read":
|
||||
return normalizeReadResultPaths(text, cwd);
|
||||
case "Grep":
|
||||
return normalizeLineStartPaths(text, cwd);
|
||||
case "Glob":
|
||||
return normalizeLineStartPaths(text, cwd);
|
||||
case "Edit":
|
||||
case "Write":
|
||||
return normalizeEditResultPaths(text, cwd);
|
||||
default:
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function displayPath(path: string, cwd: string | null | undefined): string {
|
||||
if (!cwd || !path.startsWith("/")) return path;
|
||||
const normalizedCwd = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
|
||||
if (path === normalizedCwd) return ".";
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
||||
}
|
||||
|
||||
function normalizeReadResultPaths(text: string, cwd: string): string {
|
||||
return text.split("\n").map((line) => {
|
||||
const readMatch = line.match(
|
||||
/^(Read \d+ line\(s\)(?: \[[^\]]+\])?(?: of \d+)? from )(.+)$/,
|
||||
);
|
||||
if (readMatch) {
|
||||
return `${readMatch[1]}${displayPath(readMatch[2], cwd)}`;
|
||||
}
|
||||
return normalizeKnownPathSuffix(line, cwd);
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function normalizeEditResultPaths(text: string, cwd: string): string {
|
||||
return text.split("\n").map((line) => {
|
||||
const simpleMatch = line.match(
|
||||
/^(Edited |Wrote |Created |Overwrote |Deleted )(.+?)( \(.+\))?$/,
|
||||
);
|
||||
if (simpleMatch) {
|
||||
return `${simpleMatch[1]}${displayPath(simpleMatch[2], cwd)}${
|
||||
simpleMatch[3] ?? ""
|
||||
}`;
|
||||
}
|
||||
return normalizeKnownPathSuffix(line, cwd);
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function normalizeKnownPathSuffix(line: string, cwd: string): string {
|
||||
const match = line.match(
|
||||
/^(.*\b(?:from|path|file not found|not a directory): )(.+)$/,
|
||||
);
|
||||
if (!match) return line;
|
||||
return `${match[1]}${displayPath(match[2], cwd)}`;
|
||||
}
|
||||
|
||||
function normalizeLineStartPaths(text: string, cwd: string): string {
|
||||
const normalizedCwd = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
|
||||
const prefix = `${normalizedCwd}/`;
|
||||
return text.split("\n").map((line) => {
|
||||
if (line === normalizedCwd) return ".";
|
||||
return line.startsWith(prefix) ? line.slice(prefix.length) : line;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function argsText(toolCall: ToolCallView): string {
|
||||
const raw = toolCall.arguments ?? toolCall.argsStream;
|
||||
if (!raw.trim()) {
|
||||
@@ -992,14 +1089,21 @@ function usageText(
|
||||
} · cache ${data.cache_read_input_tokens ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function snapshotLinesFromEntries(eventId: string, entries: unknown[]): ConsoleLine[] {
|
||||
function snapshotLinesFromEntries(
|
||||
eventId: string,
|
||||
entries: unknown[],
|
||||
cwd: string | null,
|
||||
): ConsoleLine[] {
|
||||
const projection: ConsoleProjection = {
|
||||
lines: [],
|
||||
status: null,
|
||||
usage: null,
|
||||
cwd,
|
||||
lastEventId: eventId,
|
||||
};
|
||||
entries.forEach((entry, index) => applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry));
|
||||
entries.forEach((entry, index) =>
|
||||
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
||||
);
|
||||
return projection.lines;
|
||||
}
|
||||
|
||||
@@ -1143,7 +1247,11 @@ function loggedContentText(parts: unknown[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine {
|
||||
function inFlightLine(
|
||||
eventId: string,
|
||||
block: InFlightBlock,
|
||||
cwd: string | null,
|
||||
): ConsoleLine {
|
||||
switch (block.kind) {
|
||||
case "text":
|
||||
return line(
|
||||
@@ -1170,6 +1278,7 @@ function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine {
|
||||
argsStream: block.args,
|
||||
arguments: block.state === "done" ? block.args : undefined,
|
||||
state: inFlightToolState(block.state),
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user