workspace: group console tool calls

This commit is contained in:
Keisuke Hirata 2026-07-11 07:17:03 +09:00
parent a5417320eb
commit 2a6930a946
No known key found for this signature in database
4 changed files with 485 additions and 64 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: 'Improve workspace console tool call rendering' title: 'Improve workspace console tool call rendering'
state: 'inprogress' state: 'done'
created_at: '2026-07-10T22:08:58Z' created_at: '2026-07-10T22:08:58Z'
updated_at: '2026-07-10T22:09:21Z' updated_at: '2026-07-10T22:16:58Z'
assignee: null assignee: null
queued_by: 'yoi ticket' queued_by: 'yoi ticket'
queued_at: '2026-07-10T22:09:21Z' queued_at: '2026-07-10T22:09:21Z'

View File

@ -39,4 +39,34 @@ Ticket を `yoi ticket` が queued にしました。
State changed to `inprogress`. State changed to `inprogress`.
---
<!-- event: implementation_report author: hare at: 2026-07-10T22:16:58Z -->
## Implementation report
Implemented Workspace Console tool-call grouping.
- Tool lifecycle events now update one `Call · <tool>` console line keyed by call id.
- Args streaming, call completion, and tool result output are folded into that line instead of producing separate `tool call` / `tool call done` / `tool result` rows.
- Added generic tool rendering plus TUI-aligned special summaries for Read, Write, Edit, Glob, and Grep; Bash renders the command and output in the same Call block.
- In-flight tool snapshots now render as Call blocks as well.
- Added model tests for completed Bash call aggregation and streaming Read call updates.
Validation:
- cd web/workspace && deno task check
- cd web/workspace && deno task test
- git diff --check
- nix build .#yoi --no-link
---
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:16:58Z from: inprogress to: done reason: cli_state field: state -->
## State changed
State changed to `done`.
--- ---

View File

@ -105,11 +105,15 @@ Deno.test("projectConsole projects visible protocol rows", () => {
]); ]);
assert( assert(
projection.lines.some((line) => line.source === "event" && line.kind === "user"), projection.lines.some((line) =>
line.source === "event" && line.kind === "user"
),
"user protocol row expected", "user protocol row expected",
); );
assert( assert(
projection.lines.some((line) => line.source === "event" && line.kind === "assistant"), projection.lines.some((line) =>
line.source === "event" && line.kind === "assistant"
),
"assistant protocol row expected", "assistant protocol row expected",
); );
assert( assert(
@ -134,6 +138,100 @@ Deno.test("projectConsole projects visible protocol rows", () => {
); );
}); });
Deno.test("projectConsole groups tool call lifecycle into one Call block", () => {
const projection = projectConsole([
{
cursor: "40",
event: {
event: "tool_call_start",
data: { id: "call-1", name: "Bash" },
} satisfies Event,
},
{
cursor: "41",
event: {
event: "tool_call_args_delta",
data: { id: "call-1", json: '{"command":"pw' },
} satisfies Event,
},
{
cursor: "42",
event: {
event: "tool_call_args_delta",
data: { id: "call-1", json: 'd"}' },
} satisfies Event,
},
{
cursor: "43",
event: {
event: "tool_call_done",
data: { id: "call-1", name: "Bash", arguments: '{"command":"pwd"}' },
} satisfies Event,
},
{
cursor: "44",
event: {
event: "tool_result",
data: {
id: "call-1",
summary: "command completed",
output: "/repo\n",
is_error: false,
},
} satisfies Event,
},
]);
const toolLines = projection.lines.filter((line) => line.kind === "tool");
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Bash");
assert(
!toolLines[0].streaming,
"completed tool call should not remain streaming",
);
assert(
toolLines[0].body.includes("$ pwd"),
"Bash command should be summarized",
);
assert(
toolLines[0].body.includes("/repo"),
"tool result should be folded into the Call block",
);
assert(
toolLines[0].detail?.includes("id: call-1"),
"call id should remain in detail",
);
});
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
const projection = projectConsole([
{
cursor: "45",
event: {
event: "tool_call_start",
data: { id: "call-2", name: "Read" },
} satisfies Event,
},
{
cursor: "46",
event: {
event: "tool_call_args_delta",
data: { id: "call-2", json: '{"file_path":"/tmp/a.md"}' },
} satisfies Event,
},
]);
const toolLines = projection.lines.filter((line) => line.kind === "tool");
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
assert(
toolLines[0].body.includes("/tmp/a.md") &&
toolLines[0].body.includes("streaming args"),
"Read call should render the current argument stream and state",
);
});
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => { Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
const projection = projectConsole([ const projection = projectConsole([
{ {
@ -163,7 +261,10 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur
}, },
{ {
cursor: "32", cursor: "32",
event: { event: "turn_end", data: { turn: 0, result: "finished" } } satisfies Event, event: {
event: "turn_end",
data: { turn: 0, result: "finished" },
} satisfies Event,
}, },
{ {
cursor: "33", cursor: "33",
@ -213,7 +314,9 @@ Deno.test("projectConsole uses snapshot for state without rendering it as consol
assertEquals(projection.status, "running"); assertEquals(projection.status, "running");
assertEquals( assertEquals(
projection.lines.map((line) => `${line.kind}:${line.body}:${line.streaming}`), projection.lines.map((line) =>
`${line.kind}:${line.body}:${line.streaming}`
),
["in_flight:partial:true"], ["in_flight:partial:true"],
); );
}); });

View File

@ -1,6 +1,7 @@
import type { import type {
Event as ProtocolEvent, Event as ProtocolEvent,
InFlightBlock, InFlightBlock,
InFlightToolCallState,
Segment, Segment,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import { workspaceRoute } from "$lib/workspace-api/http"; import { workspaceRoute } from "$lib/workspace-api/http";
@ -16,6 +17,24 @@ export type ConsoleLineKind =
| "in_flight" | "in_flight"
| "system"; | "system";
type ToolCallState =
| "pending"
| "streaming_args"
| "running"
| "done"
| "error";
type ToolCallView = {
id: string;
name: string;
argsStream: string;
arguments?: string;
state: ToolCallState;
summary?: string;
output?: string | null;
isError?: boolean;
};
export type ConsoleLine = { export type ConsoleLine = {
id: string; id: string;
kind: ConsoleLineKind; kind: ConsoleLineKind;
@ -26,6 +45,7 @@ export type ConsoleLine = {
source: "event"; source: "event";
streaming?: boolean; streaming?: boolean;
error?: boolean; error?: boolean;
toolCall?: ToolCallView;
}; };
export type ConsoleProjection = { export type ConsoleProjection = {
@ -40,7 +60,10 @@ export type WorkerTarget = {
worker_id: string; worker_id: string;
}; };
export function workerConsoleHref(target: WorkerTarget, workspaceId: string): string { export function workerConsoleHref(
target: WorkerTarget,
workspaceId: string,
): string {
return workspaceRoute( return workspaceRoute(
workspaceId, workspaceId,
`/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${ `/runtimes/${encodeURIComponent(target.runtime_id)}/workers/${
@ -56,12 +79,17 @@ export function workerConsolePath(
runtimeId: string, runtimeId: string,
workerId: string, workerId: string,
): string { ): string {
return workerConsoleHref({ runtime_id: runtimeId, worker_id: workerId }, workspaceId); return workerConsoleHref(
{ runtime_id: runtimeId, worker_id: workerId },
workspaceId,
);
} }
export type ConsoleEventInput = { cursor: string; event: ProtocolEvent }; export type ConsoleEventInput = { cursor: string; event: ProtocolEvent };
export function projectConsole(events: ConsoleEventInput[] = []): ConsoleProjection { export function projectConsole(
events: ConsoleEventInput[] = [],
): ConsoleProjection {
return events.reduce(applyProtocolEvent, { return events.reduce(applyProtocolEvent, {
lines: [], lines: [],
status: null, status: null,
@ -138,43 +166,28 @@ export function applyProtocolEvent(
); );
break; break;
case "tool_call_start": case "tool_call_start":
next.lines.push( upsertToolCall(next, envelope.cursor, event.data.id, {
line( name: event.data.name,
envelope.cursor, state: "pending",
"tool", });
`tool call · ${event.data.name}`,
`id: ${event.data.id}`,
undefined,
true,
),
);
break; break;
case "tool_call_args_delta": case "tool_call_args_delta":
appendToolArgs(next, envelope.cursor, event.data.id, event.data.json); appendToolArgs(next, envelope.cursor, event.data.id, event.data.json);
break; break;
case "tool_call_done": case "tool_call_done":
next.lines.push( upsertToolCall(next, envelope.cursor, event.data.id, {
line( name: event.data.name,
envelope.cursor, arguments: event.data.arguments,
"tool", argsStream: event.data.arguments,
`tool call done · ${event.data.name}`, state: "running",
event.data.arguments, });
`id: ${event.data.id}`,
),
);
break; break;
case "tool_result": case "tool_result":
next.lines.push( attachToolResult(next, envelope.cursor, event.data.id, {
line( summary: event.data.summary,
envelope.cursor, output: event.data.output,
"tool", isError: event.data.is_error,
event.data.is_error ? "tool result error" : "tool result", });
event.data.output ?? event.data.summary,
`id: ${event.data.id} · ${event.data.summary}`,
false,
event.data.is_error,
),
);
break; break;
case "usage": case "usage":
next.usage = usageText(event.data); next.usage = usageText(event.data);
@ -326,34 +339,265 @@ function finalizeStreaming(
projection.lines.push(line(cursor, kind, title, body)); projection.lines.push(line(cursor, kind, title, body));
} }
function upsertToolCall(
projection: ConsoleProjection,
cursor: string,
id: string,
update: Partial<Omit<ToolCallView, "id">>,
): ConsoleLine {
let existing = findToolCallLine(projection, id);
if (!existing) {
existing = toolLine(cursor, {
id,
name: update.name ?? "Tool",
argsStream: update.argsStream ?? "",
arguments: update.arguments,
state: update.state ?? "pending",
summary: update.summary,
output: update.output,
isError: update.isError,
});
projection.lines.push(existing);
} else {
existing.cursor = cursor;
existing.toolCall = {
...existing.toolCall!,
...update,
id,
name: update.name ?? existing.toolCall!.name,
};
}
refreshToolLine(existing);
return existing;
}
function appendToolArgs( function appendToolArgs(
projection: ConsoleProjection, projection: ConsoleProjection,
cursor: string, cursor: string,
id: string, id: string,
delta: string, delta: string,
): void { ): void {
const existing = [...projection.lines] const existing = upsertToolCall(projection, cursor, id, {
.reverse() state: "streaming_args",
.find((item) => });
item.kind === "tool" && item.streaming && item.body.includes(`id: ${id}`) existing.toolCall!.argsStream += delta;
); refreshToolLine(existing);
if (existing) { }
existing.body += delta;
existing.cursor = cursor; function attachToolResult(
projection: ConsoleProjection,
cursor: string,
id: string,
result: Pick<ToolCallView, "summary" | "output" | "isError">,
): void {
const existing = findToolCallLine(projection, id);
if (!existing) {
const fallback = toolLine(cursor, {
id,
name: "Tool",
argsStream: "",
state: result.isError ? "error" : "done",
...result,
});
fallback.title = result.isError
? "Call · Tool result error"
: "Call · Tool result";
refreshToolLine(fallback);
projection.lines.push(fallback);
return; return;
} }
projection.lines.push( existing.cursor = cursor;
line( existing.toolCall = {
cursor, ...existing.toolCall!,
"tool", ...result,
"tool call args", state: result.isError ? "error" : "done",
`id: ${id}\n${delta}`, };
undefined, refreshToolLine(existing);
true, }
),
function findToolCallLine(
projection: ConsoleProjection,
id: string,
): ConsoleLine | undefined {
return [...projection.lines].reverse().find((item) =>
item.toolCall?.id === id
); );
} }
function toolLine(cursor: string, toolCall: ToolCallView): ConsoleLine {
const item: ConsoleLine = {
id: `tool-call-${toolCall.id}`,
kind: "tool",
title: `Call · ${toolCall.name}`,
body: "",
detail: undefined,
cursor,
source: "event",
streaming: true,
error: false,
toolCall,
};
refreshToolLine(item);
return item;
}
function refreshToolLine(item: ConsoleLine): void {
const toolCall = item.toolCall;
if (!toolCall) {
return;
}
item.title = item.title.startsWith("Call · Tool result")
? item.title
: `Call · ${toolCall.name}`;
item.body = renderToolCall(toolCall);
item.detail = toolCallDetail(toolCall);
item.streaming = !["done", "error"].includes(toolCall.state);
item.error = toolCall.state === "error";
}
function renderToolCall(toolCall: ToolCallView): string {
switch (toolCall.name) {
case "Read":
return renderReadTool(toolCall);
case "Write":
return renderWriteTool(toolCall);
case "Edit":
return renderEditTool(toolCall);
case "Glob":
case "Grep":
return renderSearchTool(toolCall);
case "Bash":
return renderBashTool(toolCall);
default:
return renderDefaultTool(toolCall);
}
}
function renderReadTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?";
return compactLines([
`Read — ${path} (${stateSuffix(toolCall.state)})`,
resultText(toolCall),
]);
}
function renderWriteTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?";
const content = stringField(args, "content");
return compactLines([
`Write — ${path} (${stateSuffix(toolCall.state)})`,
cappedSection(content, 5),
resultText(toolCall),
]);
}
function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?";
const oldString = stringField(args, "old_string");
const newString = stringField(args, "new_string");
const change = oldString || newString
? compactLines([
oldString ? `- ${firstLine(oldString)}` : undefined,
newString ? `+ ${firstLine(newString)}` : undefined,
])
: undefined;
return compactLines([
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
change,
resultText(toolCall),
]);
}
function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`${toolCall.name}${
summary ? firstLine(summary) : stateSuffix(toolCall.state)
}`,
resultText(toolCall),
]);
}
function renderBashTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const command = stringField(args, "command");
return compactLines([
`Bash — ${stateSuffix(toolCall.state)}`,
command ? `$ ${command}` : argsText(toolCall),
resultText(toolCall),
]);
}
function renderDefaultTool(toolCall: ToolCallView): string {
return compactLines([
`${toolCall.name}${stateSuffix(toolCall.state)}`,
argsText(toolCall),
resultText(toolCall),
]);
}
function toolCallDetail(toolCall: ToolCallView): string {
return compactLines([
`id: ${toolCall.id}`,
`state: ${stateSuffix(toolCall.state)}`,
toolCall.summary ? `summary: ${toolCall.summary}` : undefined,
argsText(toolCall) ? `arguments:\n${argsText(toolCall)}` : undefined,
]);
}
function resultText(toolCall: ToolCallView): string | undefined {
if (toolCall.output) {
return toolCall.output;
}
return toolCall.summary;
}
function argsText(toolCall: ToolCallView): string {
const raw = toolCall.arguments ?? toolCall.argsStream;
if (!raw.trim()) {
return "";
}
const parsed = parseJson(raw);
return parsed === undefined ? raw : jsonPreview(parsed);
}
function parsedArgs(
toolCall: ToolCallView,
): Record<string, unknown> | undefined {
const parsed = parseJson(toolCall.arguments ?? toolCall.argsStream);
return isRecord(parsed) ? parsed : undefined;
}
function stringField(
value: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const field = value?.[key];
return typeof field === "string" ? field : undefined;
}
function compactLines(lines: Array<string | undefined | null | false>): string {
return lines.filter((line): line is string => Boolean(line)).join("\n");
}
function cappedSection(
value: string | undefined,
cap: number,
): string | undefined {
if (!value) {
return undefined;
}
const lines = value.split(/\r?\n/);
const shown = lines.slice(0, cap);
if (lines.length > cap) {
shown.push(`… +${lines.length - cap} more lines`);
}
return shown.join("\n");
}
function usageText( function usageText(
data: { data: {
input_tokens: number | null; input_tokens: number | null;
@ -387,17 +631,49 @@ function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine {
!block.finished, !block.finished,
); );
case "tool_call": case "tool_call":
return line( return toolLine(cursor, {
cursor, id: block.id,
"in_flight", name: block.name,
`in-flight tool · ${block.name}`, argsStream: block.args,
block.args, arguments: block.state === "done" ? block.args : undefined,
`${block.id} · ${block.state ?? "pending"}`, state: inFlightToolState(block.state),
block.state !== "done", });
);
} }
} }
function inFlightToolState(
state: InFlightToolCallState | undefined,
): ToolCallState {
switch (state) {
case "streaming_args":
return "streaming_args";
case "done":
return "running";
case "pending":
default:
return "pending";
}
}
function stateSuffix(state: ToolCallState): string {
switch (state) {
case "pending":
return "pending";
case "streaming_args":
return "streaming args";
case "running":
return "running";
case "done":
return "done";
case "error":
return "error";
}
}
function firstLine(value: string): string {
return value.split(/\r?\n/, 1)[0] ?? "";
}
function slugify(value: string): string { function slugify(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace( return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(
/^-|-$/g, /^-|-$/g,
@ -405,6 +681,14 @@ function slugify(value: string): string {
) || "event"; ) || "event";
} }
function parseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
function jsonPreview(value: unknown): string { function jsonPreview(value: unknown): string {
try { try {
return JSON.stringify(value, null, 2) ?? "null"; return JSON.stringify(value, null, 2) ?? "null";
@ -412,3 +696,7 @@ function jsonPreview(value: unknown): string {
return String(value); return String(value);
} }
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}