From 1ff02151b885f06b7ae04720f7cf5703a107511e Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 22 Jul 2026 16:23:39 +0900 Subject: [PATCH] fix: relativize web console tool paths --- .yoi/tickets/00001KY1ZKCPC/item.md | 2 +- .yoi/tickets/00001KY1ZKCPC/thread.md | 49 +++++ .../src/lib/workspace/console/model.test.ts | 174 ++++++++++++++++++ .../src/lib/workspace/console/model.ts | 143 ++++++++++++-- 4 files changed, 350 insertions(+), 18 deletions(-) diff --git a/.yoi/tickets/00001KY1ZKCPC/item.md b/.yoi/tickets/00001KY1ZKCPC/item.md index 8d0bf59a..0c8f24ab 100644 --- a/.yoi/tickets/00001KY1ZKCPC/item.md +++ b/.yoi/tickets/00001KY1ZKCPC/item.md @@ -2,7 +2,7 @@ title: 'Backend runtime経由の操作をprotocol transportへ統一しTUI同等にする' state: 'planning' created_at: '2026-07-21T09:20:07Z' -updated_at: '2026-07-22T07:08:39Z' +updated_at: '2026-07-22T07:23:32Z' assignee: null readiness: 'draft' --- diff --git a/.yoi/tickets/00001KY1ZKCPC/thread.md b/.yoi/tickets/00001KY1ZKCPC/thread.md index 91223852..e6ad3558 100644 --- a/.yoi/tickets/00001KY1ZKCPC/thread.md +++ b/.yoi/tickets/00001KY1ZKCPC/thread.md @@ -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. --- + + + +## 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 ` / read error path forms if present. + - grep result path prefixes such as `: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. + +--- + + + +## 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 ` summary/detail path. + - `Write`: `file_path` display and known `Created` / `Overwrote` / `Wrote` / related result-line path prefixes. + - `Edit`: `file_path` display and known `Edited (...)` 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` + +--- diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 1e584d14..bb15afd3 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -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", + ); +}); diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index b8623901..4be61683 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -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, }); } }