fix: relativize web console tool paths

This commit is contained in:
Keisuke Hirata 2026-07-22 16:23:39 +09:00
parent c7c838b4e5
commit 1ff02151b8
No known key found for this signature in database
4 changed files with 350 additions and 18 deletions

View File

@ -2,7 +2,7 @@
title: 'Backend runtime経由の操作をprotocol transportへ統一しTUI同等にする' title: 'Backend runtime経由の操作をprotocol transportへ統一しTUI同等にする'
state: 'planning' state: 'planning'
created_at: '2026-07-21T09:20:07Z' created_at: '2026-07-21T09:20:07Z'
updated_at: '2026-07-22T07:08:39Z' updated_at: '2026-07-22T07:23:32Z'
assignee: null assignee: null
readiness: 'draft' readiness: 'draft'
--- ---

View File

@ -414,3 +414,52 @@ Verification:
Note: `deno fmt --check` on the touched files still reports existing formatting changes outside this patch scope, so it was not used as the gating check for this small change. Note: `deno fmt --check` on the touched files still reports existing formatting changes outside this patch scope, so it was not used as the gating check for this small change.
--- ---
<!-- event: comment author: assistant at: 2026-07-22T07:12:46Z -->
## Comment
Requested Web Console tool-path display change before implementation:
- Use the Worker's protocol `snapshot.greeting.cwd` as the display base.
- For known display portions of edit-related tools plus `Read`, `Grep`, and `Glob`, show absolute paths relative to cwd.
- Do not globally replace arbitrary tool output text; only rewrite known path-bearing parts of known tool display formats.
Implementation plan:
- Carry `greeting.cwd` through the Web Console projection state.
- Add a focused display normalizer for known tool output formats:
- `Read ... from <path>` / read error path forms if present.
- grep result path prefixes such as `<path>:line:...` and summary lines like matching lines in files.
- glob found-path listing entries.
- edit/write-style success/error lines that explicitly include a file path.
- Add focused model tests for the supported formats and keep transcript/protocol payloads unchanged.
---
<!-- event: implementation_report author: assistant at: 2026-07-22T07:23:32Z -->
## Implementation report
Implementation report for Web Console known tool-path display relativization:
- Web Console projection now carries `snapshot.greeting.cwd` in `ConsoleProjection`.
- Each `ToolCallView` captures the current cwd so later rendering can use the correct display base without changing protocol payloads or stored tool output.
- Added path-display normalization scoped to known tool display formats only:
- `Read`: `file_path` display and `Read ... from <path>` summary/detail path.
- `Write`: `file_path` display and known `Created` / `Overwrote` / `Wrote` / related result-line path prefixes.
- `Edit`: `file_path` display and known `Edited <path> (...)` result-line path prefix.
- `Glob`: line-start cwd paths in result preview.
- `Grep`: line-start cwd paths in result preview, including `path:line:...` output.
- Paths outside cwd are left absolute.
- Arbitrary default tool output, Bash output, and argument JSON/details are not globally rewritten.
Tests:
- Added a focused Web model regression test with `snapshot.greeting.cwd = /repo` covering Read/Write/Edit/Glob/Grep display output.
- The test asserts cwd-contained paths become relative while `/outside/...` paths remain absolute.
Verification:
- `cd web/workspace && deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/console/model.test.ts`
- `cd web/workspace && deno task check` passed with 0 errors; it still reports the existing 2 a11y warnings in `+page.svelte`.
- `git diff --check`
---

View File

@ -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", () => { Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
assert( assert(
workerConsoleHref({ workerConsoleHref({
@ -886,3 +907,156 @@ Deno.test("selectConsoleTimelineLines keeps all users and only last assistant pe
["0:u1", "3:a2", "4:u2", "6:a4"], ["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",
);
});

View File

@ -34,6 +34,7 @@ type ToolCallView = {
summary?: string; summary?: string;
output?: string | null; output?: string | null;
isError?: boolean; isError?: boolean;
cwd?: string | null;
}; };
export type ConsoleDiffLine = { export type ConsoleDiffLine = {
@ -61,6 +62,7 @@ export type ConsoleProjection = {
lines: ConsoleLine[]; lines: ConsoleLine[];
status: string | null; status: string | null;
usage: string | null; usage: string | null;
cwd: string | null;
lastEventId: string | null; lastEventId: string | null;
}; };
@ -138,6 +140,7 @@ export function emptyConsoleProjection(): ConsoleProjection {
lines: [], lines: [],
status: null, status: null,
usage: null, usage: null,
cwd: null,
lastEventId: null, lastEventId: null,
}; };
} }
@ -183,6 +186,7 @@ export function applyProtocolEvent(
lines: [...projection.lines], lines: [...projection.lines],
status: projection.status, status: projection.status,
usage: projection.usage, usage: projection.usage,
cwd: projection.cwd,
lastEventId: envelope.eventId, lastEventId: envelope.eventId,
}; };
const event = envelope.event; const event = envelope.event;
@ -284,16 +288,21 @@ export function applyProtocolEvent(
break; break;
case "snapshot": case "snapshot":
next.status = event.data.status; 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 ?? []) { 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; break;
case "status": case "status":
next.status = event.data.status; next.status = event.data.status;
break; break;
case "segment_rotated": case "segment_rotated":
next.lines = snapshotLinesFromEntries(envelope.eventId, [event.data.entry]); next.lines = snapshotLinesFromEntries(envelope.eventId, [event.data.entry], next.cwd);
break; break;
case "invoke_start": case "invoke_start":
case "turn_start": case "turn_start":
@ -504,6 +513,7 @@ function upsertToolCall(
summary: update.summary, summary: update.summary,
output: update.output, output: update.output,
isError: update.isError, isError: update.isError,
cwd: update.cwd ?? projection.cwd,
}); });
projection.lines.push(created); projection.lines.push(created);
return created; return created;
@ -518,6 +528,7 @@ function upsertToolCall(
...update, ...update,
id, id,
name: update.name ?? existing.toolCall!.name, name: update.name ?? existing.toolCall!.name,
cwd: update.cwd ?? existing.toolCall!.cwd ?? projection.cwd,
}, },
}); });
projection.lines[index] = updated; projection.lines[index] = updated;
@ -537,6 +548,7 @@ function appendToolArgs(
name: "Tool", name: "Tool",
argsStream: delta, argsStream: delta,
state: "streaming_args", state: "streaming_args",
cwd: projection.cwd,
})); }));
return; return;
} }
@ -567,6 +579,7 @@ function attachToolResult(
name: "Tool", name: "Tool",
argsStream: "", argsStream: "",
state: result.isError ? "error" : "done", state: result.isError ? "error" : "done",
cwd: projection.cwd,
...result, ...result,
}), }),
title: result.isError ? "Call · Tool result error" : "Call · Tool 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 { function readPath(toolCall: ToolCallView): string {
return displayPath(readRawPath(toolCall), toolCall.cwd);
}
function readRawPath(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); const args = parsedArgs(toolCall);
return stringField(args, "file_path") ?? "?"; return stringField(args, "file_path") ?? "?";
} }
@ -712,37 +729,37 @@ function readDetail(toolCall: ToolCallView): string {
`id: ${toolCall.id}`, `id: ${toolCall.id}`,
`state: ${stateSuffix(toolCall.state)}`, `state: ${stateSuffix(toolCall.state)}`,
`path: ${readPath(toolCall)}`, `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 { function renderReadTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
const path = stringField(args, "file_path") ?? "?";
return `Read — ${path} (${stateSuffix(toolCall.state)})`;
} }
function renderWriteTool(toolCall: ToolCallView): string { function renderWriteTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?"; const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
const content = stringField(args, "content"); const content = stringField(args, "content");
return compactLines([ return compactLines([
`Write — ${path} (${stateSuffix(toolCall.state)})`, `Write — ${path} (${stateSuffix(toolCall.state)})`,
cappedSection(content, 5), cappedSection(content, 5),
resultText(toolCall), knownToolResultText(toolCall),
]); ]);
} }
function renderEditTool(toolCall: ToolCallView): string { function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?"; const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
const diff = editDiff(toolCall) ?? []; const diff = editDiff(toolCall) ?? [];
const removes = diff.filter((line) => line.kind === "remove").length; const removes = diff.filter((line) => line.kind === "remove").length;
const adds = diff.filter((line) => line.kind === "add").length; const adds = diff.filter((line) => line.kind === "add").length;
return compactLines([ return compactLines([
`Edit — ${path} (${stateSuffix(toolCall.state)})`, `Edit — ${path} (${stateSuffix(toolCall.state)})`,
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined, 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(); const summary = toolCall.summary?.trim();
return compactLines([ return compactLines([
`${toolCall.name}${toolHeaderSuffix(toolCall, summary)}`, `${toolCall.name}${toolHeaderSuffix(toolCall, summary)}`,
resultText(toolCall), knownToolResultText(toolCall),
]); ]);
} }
@ -838,7 +855,7 @@ function renderGrepTool(toolCall: ToolCallView): string {
return compactLines([ return compactLines([
`Grep — ${toolHeaderSuffix(toolCall, summary)}`, `Grep — ${toolHeaderSuffix(toolCall, summary)}`,
grepQueryText(toolCall), grepQueryText(toolCall),
cappedResultSection(resultText(toolCall), 5), cappedResultSection(knownToolResultText(toolCall), 5),
]); ]);
} }
@ -884,7 +901,9 @@ function toolCallDetail(toolCall: ToolCallView): string {
return compactLines([ return compactLines([
`id: ${toolCall.id}`, `id: ${toolCall.id}`,
`state: ${stateSuffix(toolCall.state)}`, `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, argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined,
]); ]);
} }
@ -896,6 +915,84 @@ function resultText(toolCall: ToolCallView): string | undefined {
return toolCall.summary; 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 { function argsText(toolCall: ToolCallView): string {
const raw = toolCall.arguments ?? toolCall.argsStream; const raw = toolCall.arguments ?? toolCall.argsStream;
if (!raw.trim()) { if (!raw.trim()) {
@ -992,14 +1089,21 @@ function usageText(
} · cache ${data.cache_read_input_tokens ?? "unknown"}`; } · 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 = { const projection: ConsoleProjection = {
lines: [], lines: [],
status: null, status: null,
usage: null, usage: null,
cwd,
lastEventId: eventId, 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; return projection.lines;
} }
@ -1143,7 +1247,11 @@ function loggedContentText(parts: unknown[]): string {
.join("\n"); .join("\n");
} }
function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine { function inFlightLine(
eventId: string,
block: InFlightBlock,
cwd: string | null,
): ConsoleLine {
switch (block.kind) { switch (block.kind) {
case "text": case "text":
return line( return line(
@ -1170,6 +1278,7 @@ function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine {
argsStream: block.args, argsStream: block.args,
arguments: block.state === "done" ? block.args : undefined, arguments: block.state === "done" ? block.args : undefined,
state: inFlightToolState(block.state), state: inFlightToolState(block.state),
cwd,
}); });
} }
} }