feat(core,web,cli): add file tools and per-tool call descriptions (#62)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,9 @@
|
||||
*/
|
||||
import { createInterface, type Interface } from "node:readline";
|
||||
import type { Command } from "commander";
|
||||
import { createAgent, userText } from "@prismshadow/penguin-core";
|
||||
import { createAgent, userText, VERSION } from "@prismshadow/penguin-core";
|
||||
import type { ApprovalDecision, OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core";
|
||||
import { StreamRenderer, dim, renderHistory } from "../render.js";
|
||||
import { StreamRenderer, dim, renderHistory, sessionMetaTools } from "../render.js";
|
||||
import { runTask } from "../task-loop.js";
|
||||
import { parseApprovalAnswer, resolveApprovalMode } from "../approval.js";
|
||||
import { LineComposer, PasteFilter } from "../input.js";
|
||||
@@ -113,9 +113,11 @@ export function registerChatCommand(program: Command, t: Messages): void {
|
||||
}
|
||||
|
||||
const renderer = new StreamRenderer(out, t);
|
||||
// The assembled tool schemas decide each tool's call-line preview path (see render.ts).
|
||||
renderer.useToolSchemas(sessionMetaTools(session));
|
||||
|
||||
out.write(
|
||||
`${t.header("chat", agent.state.agentId, session.workspaceDir, session.modelId)}\n` +
|
||||
`${t.header("chat", VERSION, agent.state.agentId, session.workspaceDir, session.modelId)}\n` +
|
||||
`${t.chatHints()}\n`,
|
||||
);
|
||||
// On resume, first render the history messages of the current context per Trace
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
* Docs: /docs/cli § "penguin run".
|
||||
*/
|
||||
import type { Command } from "commander";
|
||||
import { createAgent, userText } from "@prismshadow/penguin-core";
|
||||
import { StreamRenderer } from "../render.js";
|
||||
import { createAgent, userText, VERSION } from "@prismshadow/penguin-core";
|
||||
import { StreamRenderer, sessionMetaTools } from "../render.js";
|
||||
import { runTask } from "../task-loop.js";
|
||||
import { denyActivePrompt, resolveApprovalMode } from "../approval.js";
|
||||
import type { Messages } from "../i18n.js";
|
||||
@@ -53,7 +53,9 @@ export function registerRunCommand(program: Command, t: Messages): void {
|
||||
});
|
||||
|
||||
const out = process.stdout;
|
||||
out.write(`${t.header("run", agent.state.agentId, session.workspaceDir, session.modelId)}\n`);
|
||||
out.write(
|
||||
`${t.header("run", VERSION, agent.state.agentId, session.workspaceDir, session.modelId)}\n`,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const onSigint = () => {
|
||||
@@ -65,6 +67,8 @@ export function registerRunCommand(program: Command, t: Messages): void {
|
||||
process.on("SIGINT", onSigint);
|
||||
|
||||
const renderer = new StreamRenderer(out, t);
|
||||
// The assembled tool schemas decide each tool's call-line preview path (see render.ts).
|
||||
renderer.useToolSchemas(sessionMetaTools(session));
|
||||
try {
|
||||
const result = await runTask(session, [userText(opts.message)], {
|
||||
mode,
|
||||
|
||||
@@ -114,7 +114,14 @@ export interface Messages {
|
||||
};
|
||||
|
||||
// —— Runtime output ——
|
||||
header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string;
|
||||
/** Startup banner: product + subcommand + CLI version on the first line, then Agent / Workspace / Model each on its own line. */
|
||||
header(
|
||||
kind: "chat" | "run",
|
||||
version: string,
|
||||
agentId: string,
|
||||
workspace: string,
|
||||
model: string,
|
||||
): string;
|
||||
chatHints(): string;
|
||||
confirmExit(): string;
|
||||
taskInterrupted(): string;
|
||||
@@ -187,8 +194,34 @@ export interface Messages {
|
||||
webTimeout(url: string): string;
|
||||
}
|
||||
|
||||
function header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string {
|
||||
return `PenguinHarness ${kind} — agent=${agentId} workspace=${workspace} model=${model}`;
|
||||
function headerEn(
|
||||
kind: "chat" | "run",
|
||||
version: string,
|
||||
agentId: string,
|
||||
workspace: string,
|
||||
model: string,
|
||||
): string {
|
||||
return [
|
||||
`PenguinHarness ${kind} v${version}`,
|
||||
`Agent: ${agentId}`,
|
||||
`Workspace: ${workspace}`,
|
||||
`Model: ${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function headerZh(
|
||||
kind: "chat" | "run",
|
||||
version: string,
|
||||
agentId: string,
|
||||
workspace: string,
|
||||
model: string,
|
||||
): string {
|
||||
return [
|
||||
`PenguinHarness ${kind} v${version}`,
|
||||
`Agent:${agentId}`,
|
||||
`Workspace:${workspace}`,
|
||||
`模型:${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const en: Messages = {
|
||||
@@ -308,7 +341,7 @@ const en: Messages = {
|
||||
installerFetchFailed: (url) => `Could not download the installer from ${url}.`,
|
||||
},
|
||||
|
||||
header,
|
||||
header: headerEn,
|
||||
chatHints: () =>
|
||||
"Type a message to start a conversation; end a line with \\; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.",
|
||||
confirmExit: () => "Exit penguin? [y/N] ",
|
||||
@@ -473,7 +506,7 @@ const zh: Messages = {
|
||||
installerFetchFailed: (url) => `无法从 ${url} 下载安装脚本。`,
|
||||
},
|
||||
|
||||
header,
|
||||
header: headerZh,
|
||||
chatHints: () =>
|
||||
"输入消息发起对话;行尾 \\ 续行;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。",
|
||||
confirmExit: () => "确认退出 penguin?[y/N] ",
|
||||
|
||||
+178
-29
@@ -21,13 +21,15 @@
|
||||
* - when the head of the queue is held, the holder's own subsequent messages are let
|
||||
* through first (preserving in-segment order), avoiding deadlock.
|
||||
*
|
||||
* **Pairing tags**: a tool call and its output may be separated by several segments, so
|
||||
* both are tagged with a shared word for pairing: the call line reads
|
||||
* `[tool-653] $ cmd`, the output line `[tool-653] >> ...` (653 being the last 3
|
||||
* characters of tool_call_id); nested (subagent) tools use
|
||||
* `[agent-f2a-tool-653] $ cmd` (f2a being the last 3 characters of the direct child
|
||||
* Session id). Approval lines carry no tag (they immediately follow the matching call
|
||||
* line, so context makes the pairing clear): `[approved]`.
|
||||
* **Call/output pairing**: both sides carry the same `[tool-653] <toolName>` prefix
|
||||
* (653 being the last 3 characters of tool_call_id) — the call line reads
|
||||
* `[tool-653] exec_command <- $ cmd`, each output line `[tool-653] exec_command -> ...`;
|
||||
* nested (subagent) tools use `[agent-f2a-tool-653] …` (f2a being the last 3 characters
|
||||
* of the direct child Session id). The output-side tool name is resolved from the
|
||||
* preceding call via a tool_call_id → name map (the call always precedes its output; if
|
||||
* no call was seen, the bare `[tool-653]` tag remains). Approval lines carry no tag
|
||||
* (they immediately follow the matching call line, so context makes the pairing clear):
|
||||
* `[approved]`.
|
||||
*
|
||||
* **Nested sub-session messages** (those carrying an origin) are handled separately:
|
||||
* child tool calls (so the user can see what the subagent is calling before approval)
|
||||
@@ -52,14 +54,18 @@ import type {
|
||||
PartialToolCallPayload,
|
||||
PartialToolCallOutputPayload,
|
||||
RequestEndPayload,
|
||||
SessionMetaPayload,
|
||||
TokenUsagePayload,
|
||||
ToolCallPayload,
|
||||
ToolDefinition,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import { renderPartialToolCall } from "./tool-render.js";
|
||||
import { renderFileToolApprovalPayload, renderPartialToolCall } from "./tool-render.js";
|
||||
import { defaultMessages } from "./i18n.js";
|
||||
import type { Messages } from "./i18n.js";
|
||||
|
||||
const DIM = "\x1b[2m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
@@ -67,6 +73,17 @@ export function dim(text: string): string {
|
||||
return `${DIM}${text}${RESET}`;
|
||||
}
|
||||
|
||||
/** The two file tools whose outputs carry git-style diffs; their `+`/`-`/`@@` lines get colored. */
|
||||
const DIFF_OUTPUT_TOOLS = new Set(["edit_file", "write_file"]);
|
||||
|
||||
/** Color for one diff-output line, picked from its first character (null = plain). */
|
||||
function diffLineColor(firstChar: string | undefined): string | null {
|
||||
if (firstChar === "+") return GREEN;
|
||||
if (firstChar === "-") return RED;
|
||||
if (firstChar === "@") return DIM;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Colors a tool call line cyan, distinguishing it from body text/thinking (review comment #5). */
|
||||
function cyan(text: string): string {
|
||||
return `${CYAN}${text}${RESET}`;
|
||||
@@ -123,6 +140,15 @@ export function formatAbort(p: AbortPayload, t: Messages): string {
|
||||
return dim(t.abortLabel(p.reason ?? undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Session's assembled tool schemas, read off its `session_meta` — the definitions
|
||||
* actually exposed to the model, so the per-tool `call_description` switch is already
|
||||
* applied. Feeds `StreamRenderer.useToolSchemas`.
|
||||
*/
|
||||
export function sessionMetaTools(session: { metaMessage: OmniMessage }): readonly ToolDefinition[] {
|
||||
return (session.metaMessage.payload as SessionMetaPayload).tools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically renders resumed history messages (`--resume`: full-message semantics, no
|
||||
* partial_*, including interrupted messages and their markers). Uses the
|
||||
@@ -135,6 +161,10 @@ export function renderHistory(
|
||||
out: NodeJS.WritableStream,
|
||||
t: Messages = defaultMessages(),
|
||||
): void {
|
||||
// tool_call_id -> tool name (keyed with the origin chain: parent/child ids may collide),
|
||||
// so output lines can be labeled with the tool name of their preceding call.
|
||||
const toolNames = new Map<string, string>();
|
||||
const nameKey = (msg: OmniMessage, id: string): string => `${msg.origin?.join("/") ?? ""}:${id}`;
|
||||
for (const msg of messages) {
|
||||
if (isEventMessage(msg)) {
|
||||
const p = msg.payload as { type?: string } & AbortPayload;
|
||||
@@ -167,19 +197,31 @@ export function renderHistory(
|
||||
out.write(`${dim(p.thinking ?? "")}${marker}\n`);
|
||||
break;
|
||||
case "tool_call": {
|
||||
if (p.name) toolNames.set(nameKey(msg, p.tool_call_id ?? ""), p.name);
|
||||
const preview =
|
||||
renderPartialToolCall(p.name ?? "", p.arguments ?? "") ?? `${p.name} ${p.arguments}`;
|
||||
renderPartialToolCall(p.name ?? "", p.arguments ?? "", { final: true }) ??
|
||||
`${p.name} ${p.arguments}`;
|
||||
out.write(`${cyan(`[${callTag(p.tool_call_id ?? "")}] ${preview}`)}${marker}\n`);
|
||||
break;
|
||||
}
|
||||
case "tool_call_output": {
|
||||
const tag = callTag(p.tool_call_id ?? "");
|
||||
// Output lines carry the pairing tag plus the tool name (the bare tag when the
|
||||
// transcript has no matching call); file-tool diff lines are colored like git's.
|
||||
const tag = `[${callTag(p.tool_call_id ?? "")}]`;
|
||||
const name = toolNames.get(nameKey(msg, p.tool_call_id ?? ""));
|
||||
const label = name ? `${tag} ${name}` : tag;
|
||||
const colorDiff = name !== undefined && DIFF_OUTPUT_TOOLS.has(name);
|
||||
for (const line of (p.output ?? "").split("\n")) {
|
||||
out.write(`${DIM}[${tag}] >> ${RESET}${line}\n`);
|
||||
const color = colorDiff ? diffLineColor(line[0]) : null;
|
||||
out.write(
|
||||
color
|
||||
? `${DIM}${label} -> ${RESET}${color}${line}${RESET}\n`
|
||||
: `${DIM}${label} -> ${RESET}${line}\n`,
|
||||
);
|
||||
}
|
||||
// Attached images aren't rendered by the terminal; print one placeholder line per image.
|
||||
for (const _ of p.images ?? []) {
|
||||
out.write(`${DIM}[${tag}] >> [image]${RESET}\n`);
|
||||
out.write(`${DIM}${label} -> [image]${RESET}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -238,6 +280,22 @@ export class StreamRenderer {
|
||||
private inDim = false;
|
||||
/** Whether tool-call output is at the start of a line (decides whether the gutter needs to be written). */
|
||||
private toolOutLineStart = true;
|
||||
|
||||
/**
|
||||
* tool_call_id -> tool name for the current task's parent-session calls (nested tool
|
||||
* outputs are not gutter-rendered), so output lines can be prefixed with the tool name
|
||||
* of the call that produced them. Populated from partial/complete call messages (the
|
||||
* call always precedes its output); cleared with the other per-task registrations.
|
||||
*/
|
||||
private toolNames = new Map<string, string>();
|
||||
/**
|
||||
* Names of the tools whose assembled schema carries the `description` argument (from
|
||||
* `session_meta.tools`, i.e. after the per-tool `call_description` switch has been
|
||||
* applied). Decides the preview path before a call's arguments stream: awaiting the
|
||||
* description, or streaming the plain form right away (see tool-render.ts). An unknown
|
||||
* tool falls back to "no description".
|
||||
*/
|
||||
private describedTools = new Set<string>();
|
||||
/** Buffer for partial_tool_call; each delta streams out the newly appended suffix of the preview. */
|
||||
private partialToolCalls = new Map<
|
||||
string,
|
||||
@@ -318,6 +376,21 @@ export class StreamRenderer {
|
||||
beginUserPrompt(toolCall?: OmniMessage<ToolCallPayload>): void {
|
||||
if (toolCall) this.ensureAdjacentCallLine(toolCall);
|
||||
this.finishLine();
|
||||
// File-tool approvals: the one-line preview shows only the (shortened) path, but the
|
||||
// user is approving a concrete rewrite — print the decoded payload
|
||||
// (old_string/new_string/content), bounded with an explicit elision note, right before
|
||||
// the prompt.
|
||||
if (toolCall) {
|
||||
const payload = renderFileToolApprovalPayload(
|
||||
toolCall.payload.name,
|
||||
toolCall.payload.arguments,
|
||||
);
|
||||
if (payload !== null) {
|
||||
for (const line of payload.split("\n")) this.out.write(`${dim(line)}\n`);
|
||||
// lastLineKey stays on the call's key: the payload lines belong to this call, so
|
||||
// the later noteApprovalDecision must not re-render the call line as "not adjacent".
|
||||
}
|
||||
}
|
||||
this.promptActive = true;
|
||||
this.promptKey = toolCall
|
||||
? this.callLineKey(toolCall.payload.tool_call_id, toolCall.origin)
|
||||
@@ -384,7 +457,11 @@ export class StreamRenderer {
|
||||
key: string,
|
||||
): void {
|
||||
this.ensuredCallLines.add(key);
|
||||
const preview = renderPartialToolCall(p.name, p.arguments) ?? `${p.name} ${p.arguments}`;
|
||||
// Parent-session calls feed the output-gutter name map (nested outputs are not
|
||||
// gutter-rendered, and a child id could collide with a parent id).
|
||||
if (!origin || origin.length === 0) this.toolNames.set(p.tool_call_id, p.name);
|
||||
const preview =
|
||||
renderPartialToolCall(p.name, p.arguments, { final: true }) ?? `${p.name} ${p.arguments}`;
|
||||
this.finishLine();
|
||||
this.out.write(`${cyan(`[${callTag(p.tool_call_id, origin)}] ${preview}`)}\n`);
|
||||
this.lastLineKey = key;
|
||||
@@ -500,7 +577,14 @@ export class StreamRenderer {
|
||||
case "partial_tool_call_output":
|
||||
this.handlePartialToolOutput(payload as PartialToolCallOutputPayload);
|
||||
return;
|
||||
// Complete (non-streaming) model_msg is never rendered (including image_url/inline_*); the content has already been shown by partial_*.
|
||||
// Complete (non-streaming) model_msg is never rendered (including image_url/inline_*);
|
||||
// the content has already been shown by partial_*. A complete tool_call still feeds
|
||||
// the name map so its output gutter can carry the tool name.
|
||||
case "tool_call": {
|
||||
const p = payload as ToolCallPayload;
|
||||
this.toolNames.set(p.tool_call_id, p.name);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -603,7 +687,26 @@ export class StreamRenderer {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// session_meta: not rendered.
|
||||
// session_meta: not rendered, but its tool list settles each tool's preview path.
|
||||
if (msg.type === "session_meta") {
|
||||
this.useToolSchemas((msg.payload as SessionMetaPayload).tools);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Session's assembled tool schemas (`session_meta.tools`), which decide each
|
||||
* tool's preview path before its arguments stream (see `describedTools`). The host calls
|
||||
* this as soon as the Session exists; a `session_meta` flowing through the stream (resume,
|
||||
* sub-sessions) registers the same way.
|
||||
*/
|
||||
useToolSchemas(tools: readonly ToolDefinition[]): void {
|
||||
for (const tool of tools) {
|
||||
const properties = (tool.parameters as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties;
|
||||
if (properties && Object.hasOwn(properties, "description"))
|
||||
this.describedTools.add(tool.name);
|
||||
else this.describedTools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,6 +780,25 @@ export class StreamRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a call line whose preview was withheld for the whole stream (the arguments never
|
||||
* settled — e.g. the turn was interrupted mid-arguments — so a description could still have
|
||||
* arrived, see tool-render.ts). Called once at `stop` with the fragment marked final, so an
|
||||
* in-flight call is never left invisible.
|
||||
*/
|
||||
private renderWithheldCallLine(
|
||||
toolCallId: string,
|
||||
partial: { name: string; arguments: string },
|
||||
): void {
|
||||
const key = this.callLineKey(toolCallId);
|
||||
if (this.ensuredCallLines.has(key)) return;
|
||||
const preview = renderPartialToolCall(partial.name, partial.arguments, { final: true });
|
||||
if (preview === null) return;
|
||||
this.finishLine();
|
||||
this.out.write(`${cyan(`[${callTag(toolCallId)}] ${preview}`)}\n`);
|
||||
this.lastLineKey = key;
|
||||
}
|
||||
|
||||
private handlePartialToolCall(p: PartialToolCallPayload): void {
|
||||
// The call line was already rendered in place from the complete message at approval time: skip the whole late-arriving streaming copy (clean up the buffer on stop).
|
||||
if (this.ensuredCallLines.has(this.callLineKey(p.tool_call_id))) {
|
||||
@@ -689,13 +811,18 @@ export class StreamRenderer {
|
||||
partial = { name: p.name, arguments: "", lastPreview: "" };
|
||||
this.partialToolCalls.set(p.tool_call_id, partial);
|
||||
}
|
||||
if (p.name) partial.name = p.name;
|
||||
if (p.name) {
|
||||
partial.name = p.name;
|
||||
// Remember the name for this call's output gutter (`<name> -> …`).
|
||||
this.toolNames.set(p.tool_call_id, p.name);
|
||||
}
|
||||
if (p.arguments) {
|
||||
partial.arguments += p.arguments;
|
||||
}
|
||||
|
||||
if (p.event_type === "stop") {
|
||||
if (partial.lastPreview) this.finishLine();
|
||||
else this.renderWithheldCallLine(p.tool_call_id, partial);
|
||||
this.partialToolCalls.delete(p.tool_call_id);
|
||||
return;
|
||||
}
|
||||
@@ -703,7 +830,9 @@ export class StreamRenderer {
|
||||
if (!p.arguments) return;
|
||||
|
||||
if (this.inDim) this.finishLine();
|
||||
const preview = renderPartialToolCall(partial.name, partial.arguments);
|
||||
const preview = renderPartialToolCall(partial.name, partial.arguments, {
|
||||
expectDescription: this.describedTools.has(partial.name),
|
||||
});
|
||||
if (preview === null) return;
|
||||
|
||||
// The line starts with a pairing tag [tool-<last 3 chars of id>], matching the output line that follows.
|
||||
@@ -731,40 +860,59 @@ export class StreamRenderer {
|
||||
return;
|
||||
}
|
||||
if (this.inDim) this.finishLine();
|
||||
if (p.output) this.writeToolOutput(p.output, callTag(p.tool_call_id));
|
||||
const label = this.outputLabel(p.tool_call_id);
|
||||
const name = this.toolNames.get(p.tool_call_id);
|
||||
const colorDiff = name !== undefined && DIFF_OUTPUT_TOOLS.has(name);
|
||||
if (p.output) this.writeToolOutput(p.output, label, colorDiff);
|
||||
// Image delta (carried whole in a single delta): the terminal doesn't render the
|
||||
// image itself, so print one placeholder line per image, using the same pairing tag
|
||||
// as the output gutter.
|
||||
// image itself, so print one placeholder line per image, using the same gutter label
|
||||
// as the text output.
|
||||
if (p.images && p.images.length > 0) {
|
||||
this.finishLine();
|
||||
const tag = callTag(p.tool_call_id);
|
||||
for (const _ of p.images) {
|
||||
this.out.write(`${DIM}[${tag}] >> [image]${RESET}\n`);
|
||||
this.out.write(`${DIM}${label} -> [image]${RESET}\n`);
|
||||
}
|
||||
this.lastLineKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Output-gutter label: the `[tool-xxx]` pairing tag plus the tool name of the preceding call (the bare tag when no call was seen). */
|
||||
private outputLabel(toolCallId: string): string {
|
||||
const tag = `[${callTag(toolCallId)}]`;
|
||||
const name = this.toolNames.get(toolCallId);
|
||||
return name ? `${tag} ${name}` : tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes tool-call **output** line by line, each line starting with the dim gutter
|
||||
* `[tool-<last 3 chars of id>] >> `, paired with the call line (cyan `[tool-xxx] $
|
||||
* cmd`). Streaming chunks arrive incrementally; whether to write the gutter is
|
||||
* decided by the current line-start state.
|
||||
* `[tool-xxx] <toolName> -> ` (the same prefix as the call line, cyan
|
||||
* `[tool-xxx] <name> <- …`; the bare tag when no call was seen). Streaming chunks
|
||||
* arrive incrementally; whether to write the gutter is decided by the current
|
||||
* line-start state.
|
||||
*/
|
||||
private writeToolOutput(chunk: string, tag: string): void {
|
||||
private writeToolOutput(chunk: string, label: string, colorDiff: boolean): void {
|
||||
let i = 0;
|
||||
while (i < chunk.length) {
|
||||
let lineColor: string | null = null;
|
||||
if (this.toolOutLineStart) {
|
||||
this.out.write(`${DIM}[${tag}] >> ${RESET}`);
|
||||
this.out.write(`${DIM}${label} -> ${RESET}`);
|
||||
this.toolOutLineStart = false;
|
||||
this.inLine = true;
|
||||
// Diff coloring keys off the line's first character. File-tool outputs arrive as
|
||||
// one delta of whole lines, so the first character is always in this chunk; a
|
||||
// line continued from a previous chunk stays plain.
|
||||
if (colorDiff) lineColor = diffLineColor(chunk[i]);
|
||||
}
|
||||
const nl = chunk.indexOf("\n", i);
|
||||
const end = nl === -1 ? chunk.length : nl;
|
||||
const segment = chunk.slice(i, end);
|
||||
if (segment) {
|
||||
this.out.write(lineColor ? `${lineColor}${segment}${RESET}` : segment);
|
||||
}
|
||||
if (nl === -1) {
|
||||
this.out.write(chunk.slice(i));
|
||||
i = chunk.length;
|
||||
} else {
|
||||
this.out.write(chunk.slice(i, nl + 1));
|
||||
this.out.write("\n");
|
||||
this.toolOutLineStart = true;
|
||||
this.inLine = false;
|
||||
i = nl + 1;
|
||||
@@ -833,6 +981,7 @@ export class StreamRenderer {
|
||||
this.ensuredCallLines.clear();
|
||||
this.renderedDecisions.clear();
|
||||
this.partialToolCalls.clear();
|
||||
this.toolNames.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+246
-34
@@ -1,22 +1,52 @@
|
||||
/**
|
||||
* Streaming tool-call rendering (CLI side).
|
||||
*
|
||||
* The CLI only consumes `partial_tool_call` for visible rendering. exec_command is shown as
|
||||
* `$ <cmd>` as early as possible; input_command / input_subagent show the target session id,
|
||||
* with a non-empty payload (chars / prompt) appended as `<< <content>` — the payload is
|
||||
* critical for approval and later audit (writing to stdin is equivalent to running a command),
|
||||
* so the session id alone is not enough; run_subagent shows the prompt; other tools fall back
|
||||
* to `name(args-prefix)`.
|
||||
* The CLI only consumes `partial_tool_call` for visible rendering. Formats (the `<-` marker
|
||||
* reads "input to the tool", paired with the `->` output gutter in render.ts):
|
||||
* - exec_command: `exec_command <- $ {cmd}`, or with a model-written description
|
||||
* `exec_command <- {description} ($ {cmd})`;
|
||||
* - input_command: `input_command <- {process_id} << {chars}` (`<< …` only when writing;
|
||||
* an empty payload just polls), or `input_command <- {description} ({process_id} << {chars})`;
|
||||
* - run_subagent: `run_subagent <- {prompt}` or `run_subagent <- {description} ({prompt})`;
|
||||
* - input_subagent: `input_subagent <- {subagent_id} << {prompt}` or
|
||||
* `input_subagent <- {description} ({subagent_id} << {prompt})`;
|
||||
* - file tools (read_file / edit_file / write_file): `{name} {shortened path}` — the path is
|
||||
* shortened to at most one parent directory (`…/parent/file.ts`); the full path stays in
|
||||
* the arguments.
|
||||
* The payload (chars / prompt) is critical for approval and later audit (writing to stdin
|
||||
* is equivalent to running a command), so the session id alone is never enough; other tools
|
||||
* fall back to `name(args-prefix)`.
|
||||
*
|
||||
* The render layer streams by appending to the preview (see render.ts), so the preview format
|
||||
* must stay append-only: rendering only starts once the target id has fully appeared, the
|
||||
* payload is only appended at the end, and the preview stops growing once it hits the
|
||||
* truncation limit.
|
||||
* The render layer streams by appending to the preview (see render.ts), so the preview must
|
||||
* stay append-only — a preview that is not an extension of the previous one costs a fresh
|
||||
* line, leaving the superseded one on screen. Which of the two forms a call will take is
|
||||
* therefore decided **before** its arguments stream, from the tool's assembled schema
|
||||
* (`expectDescription`, taken from `session_meta.tools` — the per-tool `call_description`
|
||||
* switch decides whether the argument exists at all; see the docs on tool configuration):
|
||||
* - schema without the argument (and the unknown case) → the plain form streams immediately,
|
||||
* character by character, and any stray `description` is ignored;
|
||||
* - schema with it → the description is awaited: it streams live as it grows
|
||||
* (`name <- desc…`) and the payload is appended once its value completes
|
||||
* (`name <- desc ({payload…` → `)`), so the plain form never reaches the screen whichever
|
||||
* order the model emits its arguments in. The wait is bounded: the argument is declared
|
||||
* **required**, so a schema-abiding model always sends one, and it is asked to send it
|
||||
* first.
|
||||
* A `final` fragment (stream ended, or arguments from a complete message) is settled by
|
||||
* definition and renders whichever form the arguments actually carry — which is also what
|
||||
* catches a model that violates the schema and omits the required argument.
|
||||
* File-tool paths render only once complete — shortening a still-growing path would rewrite
|
||||
* the line.
|
||||
*/
|
||||
|
||||
/** Max length of the single-line preview for a payload (chars / prompt); truncated with an ellipsis beyond this, after which the preview stops growing. */
|
||||
/** Max length of the single-line preview for a payload (chars / prompt / description); truncated with an ellipsis beyond this, after which the preview stops growing. */
|
||||
const MAX_PAYLOAD_PREVIEW = 120;
|
||||
|
||||
/** Max lines of a file-tool payload printed before the approval prompt; the rest is elided with a count. */
|
||||
const MAX_APPROVAL_PAYLOAD_LINES = 24;
|
||||
|
||||
/** Max characters per printed approval-payload line (the full text stays in the trace). */
|
||||
const MAX_APPROVAL_PAYLOAD_LINE = 200;
|
||||
|
||||
/** Collapse to a single line: newlines/runs of whitespace become a single space, and leading/trailing whitespace is trimmed. */
|
||||
function toSingleLine(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
@@ -44,8 +74,14 @@ function capPreview(text: string): string {
|
||||
return text.length > MAX_PAYLOAD_PREVIEW ? `${text.slice(0, MAX_PAYLOAD_PREVIEW)}…` : text;
|
||||
}
|
||||
|
||||
/** Extract the current value of a string field from a possibly-incomplete JSON object string. */
|
||||
function extractPartialStringField(argsJson: string, field: string): string | null {
|
||||
/** A string field extracted from possibly-incomplete JSON: its value so far, and whether the closing quote was seen. */
|
||||
interface PartialField {
|
||||
value: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Extract a string field from a possibly-incomplete JSON object string, reporting completeness. */
|
||||
function extractField(argsJson: string, field: string): PartialField | null {
|
||||
const key = `"${field}"`;
|
||||
const keyIndex = argsJson.indexOf(key);
|
||||
if (keyIndex === -1) return null;
|
||||
@@ -89,7 +125,7 @@ function extractPartialStringField(argsJson: string, field: string): string | nu
|
||||
// emitting the incomplete hex as a literal would cause a rollback once the next
|
||||
// increment completes it (breaking append-only preview); the render layer falls
|
||||
// back to a new line in that case.
|
||||
if (i + 5 > argsJson.length) return out;
|
||||
if (i + 5 > argsJson.length) return { value: out, complete: false };
|
||||
const hex = argsJson.slice(i + 1, i + 5);
|
||||
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||
out += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
@@ -108,44 +144,220 @@ function extractPartialStringField(argsJson: string, field: string): string | nu
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') return out;
|
||||
if (ch === '"') return { value: out, complete: true };
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
return { value: out, complete: false };
|
||||
}
|
||||
|
||||
/** Extract the current value of a string field from a possibly-incomplete JSON object string. */
|
||||
function extractPartialStringField(argsJson: string, field: string): string | null {
|
||||
return extractField(argsJson, field)?.value ?? null;
|
||||
}
|
||||
|
||||
/** The three file tools: previewed as `<name> <shortened file_path>`. */
|
||||
const FILE_TOOL_NAMES = new Set(["read_file", "edit_file", "write_file"]);
|
||||
|
||||
/**
|
||||
* Shortens a path for one-line display: at most one parent directory plus the filename
|
||||
* (`…/parent/file.ts`); paths already within that shape are shown as-is. The full path
|
||||
* stays in the argument JSON (and the expanded web card).
|
||||
*/
|
||||
export function shortenPath(p: string): string {
|
||||
const segments = p.split("/").filter((s) => s.length > 0);
|
||||
if (segments.length <= 2) return p;
|
||||
return `…/${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
|
||||
}
|
||||
|
||||
/** How a tool call is previewed while its arguments stream (see the module header). */
|
||||
export interface ToolCallPreviewOptions {
|
||||
/**
|
||||
* Whether this tool's assembled schema carries the `description` argument (from
|
||||
* `session_meta.tools`). Unknown ⇒ `false`: fall back to the plain form, matching a
|
||||
* configuration with the argument switched off.
|
||||
*/
|
||||
expectDescription?: boolean;
|
||||
/** The call's last fragment (its stream ended) or arguments from a complete message: settled, so render whichever form they carry. */
|
||||
final?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming argument preview: exec_command shows `$ <cmd>` once cmd can be read; input_command /
|
||||
* input_subagent show `⌨ <name> → <id>` once the target id is available, with a non-empty
|
||||
* chars / prompt appended as `<< <content>` (an empty payload just means polling, left as-is);
|
||||
* run_subagent shows `run_subagent << <prompt>` once prompt can be read; other tools fall back
|
||||
* to name(args-prefix).
|
||||
* State of the model-written `description` argument within a possibly-incomplete arguments
|
||||
* fragment:
|
||||
* - `pending`: it is expected but hasn't produced anything showable yet — nothing renders;
|
||||
* - `none`: no usable description (not expected, or the settled arguments carry none), so
|
||||
* the plain form is correct;
|
||||
* - `{ text, complete }`: the description's value so far, single-lined and capped. Payload
|
||||
* is appended only once `complete`, keeping the preview append-only whichever order the
|
||||
* model emits its arguments in.
|
||||
*/
|
||||
export function renderPartialToolCall(name: string, argsJson: string): string | null {
|
||||
type DescriptionState = "pending" | "none" | { text: string; complete: boolean };
|
||||
|
||||
function describedState(argsJson: string, opts: ToolCallPreviewOptions): DescriptionState {
|
||||
const settled = opts.final === true || argsComplete(argsJson);
|
||||
// Not expected and not settled: the schema has no such argument, so stream the plain form
|
||||
// right away. Settled fragments are read for real — a complete call renders what it carries.
|
||||
if (!settled && opts.expectDescription !== true) return "none";
|
||||
const field = extractField(argsJson, "description");
|
||||
if (field === null) return settled ? "none" : "pending";
|
||||
const text = toSingleLine(field.value);
|
||||
// An empty description (still opening, or explicitly "") carries nothing to show: once
|
||||
// settled it means "no description", otherwise keep waiting for its first characters.
|
||||
if (!text) return settled ? "none" : "pending";
|
||||
return { text: capPreview(text), complete: field.complete };
|
||||
}
|
||||
|
||||
/** Whether the whole argument JSON parses (i.e. argument streaming is finished). */
|
||||
function argsComplete(argsJson: string): boolean {
|
||||
try {
|
||||
JSON.parse(argsJson);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a payload preview in the description form: `{name} <- {description} ({payload…}`,
|
||||
* closing the parenthesis once `closed`. The open parenthesis mid-stream keeps the preview
|
||||
* append-only while the payload grows.
|
||||
*/
|
||||
function describedForm(name: string, desc: string, payload: string, closed: boolean): string {
|
||||
return `${name} <- ${desc} (${payload}${closed ? ")" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming argument preview (formats documented in the module header). Returns null while
|
||||
* nothing presentable has streamed in yet — including a call whose schema carries the
|
||||
* `description` argument (`opts.expectDescription`) whose value hasn't started streaming.
|
||||
*/
|
||||
export function renderPartialToolCall(
|
||||
name: string,
|
||||
argsJson: string,
|
||||
opts: ToolCallPreviewOptions = {},
|
||||
): string | null {
|
||||
if (!argsJson) return null;
|
||||
if (name === "exec_command") {
|
||||
const cmd = extractPartialStringField(argsJson, "cmd");
|
||||
if (cmd !== null) return `$ ${toSingleLine(cmd)}`;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const cmd = extractField(argsJson, "cmd");
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || cmd === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(name, desc.text, `$ ${toSingleLine(cmd.value)}`, cmd.complete);
|
||||
}
|
||||
if (cmd !== null) return `${name} <- $ ${toSingleLine(cmd.value)}`;
|
||||
return null;
|
||||
}
|
||||
if (name === "run_subagent") {
|
||||
const prompt = extractPartialStringField(argsJson, "prompt");
|
||||
if (prompt !== null) return `run_subagent << ${capPreview(toSingleLine(prompt))}`;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const prompt = extractField(argsJson, "prompt");
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || prompt === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(
|
||||
name,
|
||||
desc.text,
|
||||
capPreview(toSingleLine(prompt.value)),
|
||||
prompt.complete,
|
||||
);
|
||||
}
|
||||
if (prompt !== null) return `${name} <- ${capPreview(toSingleLine(prompt.value))}`;
|
||||
return null;
|
||||
}
|
||||
if (name === "input_command") {
|
||||
const pid = extractPartialStringField(argsJson, "process_id");
|
||||
if (pid === null) return null;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const pid = extractField(argsJson, "process_id");
|
||||
if (desc === "none" && pid === null) return null;
|
||||
const chars = extractPartialStringField(argsJson, "chars");
|
||||
const payload = chars ? ` << ${capPreview(visualizeControlChars(chars))}` : "";
|
||||
return `⌨ input_command → ${toSingleLine(pid)}${payload}`;
|
||||
const payloadSuffix = chars ? ` << ${capPreview(visualizeControlChars(chars))}` : "";
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || pid === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(
|
||||
name,
|
||||
desc.text,
|
||||
`${toSingleLine(pid.value)}${payloadSuffix}`,
|
||||
argsComplete(argsJson),
|
||||
);
|
||||
}
|
||||
return `${name} <- ${toSingleLine(pid!.value)}${payloadSuffix}`;
|
||||
}
|
||||
if (name === "input_subagent") {
|
||||
const sid = extractPartialStringField(argsJson, "subagent_id");
|
||||
if (sid === null) return null;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const sid = extractField(argsJson, "subagent_id");
|
||||
if (desc === "none" && sid === null) return null;
|
||||
const prompt = extractPartialStringField(argsJson, "prompt");
|
||||
const payload = prompt ? ` << ${capPreview(toSingleLine(prompt))}` : "";
|
||||
return `⌨ input_subagent → ${toSingleLine(sid)}${payload}`;
|
||||
const payloadSuffix = prompt ? ` << ${capPreview(toSingleLine(prompt))}` : "";
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || sid === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(
|
||||
name,
|
||||
desc.text,
|
||||
`${toSingleLine(sid.value)}${payloadSuffix}`,
|
||||
argsComplete(argsJson),
|
||||
);
|
||||
}
|
||||
return `${name} <- ${toSingleLine(sid!.value)}${payloadSuffix}`;
|
||||
}
|
||||
if (FILE_TOOL_NAMES.has(name)) {
|
||||
// Path rendered only once complete: shortening a still-growing path would rewrite the line.
|
||||
const filePath = extractField(argsJson, "file_path");
|
||||
if (filePath !== null && filePath.complete) {
|
||||
return `${name} ${shortenPath(toSingleLine(filePath.value))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return `${name || "tool_call"}(${toSingleLine(argsJson)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-tool payload for the interactive approval prompt: the full decoded arguments
|
||||
* (old_string / new_string / content …), bounded to MAX_APPROVAL_PAYLOAD_LINES lines with
|
||||
* an explicit elision note — under always-ask/read-only approval the user must see what
|
||||
* they are approving, not just the file path. Returns null for other tools or unparseable
|
||||
* arguments (arguments are complete by approval time).
|
||||
*/
|
||||
export function renderFileToolApprovalPayload(name: string, argsJson: string): string | null {
|
||||
if (!FILE_TOOL_NAMES.has(name)) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(argsJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") return null;
|
||||
const args = parsed as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
const pushField = (label: string, value: unknown): void => {
|
||||
if (value === undefined) return;
|
||||
if (typeof value === "string" && value.includes("\n")) {
|
||||
lines.push(`${label}:`);
|
||||
for (const line of value.split("\n")) lines.push(` | ${line}`);
|
||||
} else {
|
||||
lines.push(`${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
};
|
||||
pushField("file_path", args["file_path"]);
|
||||
if (name === "read_file") {
|
||||
pushField("offset", args["offset"]);
|
||||
pushField("limit", args["limit"]);
|
||||
} else if (name === "edit_file") {
|
||||
pushField("old_string", args["old_string"]);
|
||||
pushField("new_string", args["new_string"]);
|
||||
if (args["replace_all"] === true) pushField("replace_all", true);
|
||||
} else if (name === "write_file") {
|
||||
pushField("content", args["content"]);
|
||||
}
|
||||
let shown = lines;
|
||||
let elided = 0;
|
||||
if (shown.length > MAX_APPROVAL_PAYLOAD_LINES) {
|
||||
elided = shown.length - MAX_APPROVAL_PAYLOAD_LINES;
|
||||
shown = shown.slice(0, MAX_APPROVAL_PAYLOAD_LINES);
|
||||
}
|
||||
const capped = shown.map((l) =>
|
||||
l.length > MAX_APPROVAL_PAYLOAD_LINE ? `${l.slice(0, MAX_APPROVAL_PAYLOAD_LINE)}…` : l,
|
||||
);
|
||||
if (elided > 0) capped.push(`[… ${elided} more line${elided === 1 ? "" : "s"} not shown]`);
|
||||
return capped.join("\n");
|
||||
}
|
||||
|
||||
@@ -48,10 +48,16 @@ describe("getMessages", () => {
|
||||
expect(getMessages("zh").langInvalid("fr")).toContain("fr");
|
||||
});
|
||||
|
||||
it("header order is agent → workspace → model", () => {
|
||||
const h = getMessages("en").header("run", "ag", "/ws", "mod");
|
||||
expect(h.indexOf("agent=ag")).toBeLessThan(h.indexOf("workspace=/ws"));
|
||||
expect(h.indexOf("workspace=/ws")).toBeLessThan(h.indexOf("model=mod"));
|
||||
it("header shows the version and Agent / Workspace / Model on their own lines", () => {
|
||||
for (const lang of ["en", "zh"] as const) {
|
||||
const lines = getMessages(lang).header("run", "1.2.3", "ag", "/ws", "mod").split("\n");
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[0]).toContain("run");
|
||||
expect(lines[0]).toContain("v1.2.3");
|
||||
expect(lines[1]).toContain("ag");
|
||||
expect(lines[2]).toContain("/ws");
|
||||
expect(lines[3]).toContain("mod");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -117,19 +117,165 @@ describe("StreamRenderer", () => {
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c4" }));
|
||||
r.handle(toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c4" }));
|
||||
// The call line carries a [tool-<last-3-chars-of-id>] pairing tag matching the output line.
|
||||
expect(stripAnsi(text())).toBe("[tool-c4] $ ls\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-c4] exec_command <- $ ls\n");
|
||||
});
|
||||
|
||||
it("streams partial_tool_call_output with a tagged gutter and skips the complete tool_call_output", () => {
|
||||
it("renders one call line when the description arrives after the command", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// The assembled schema carries the description argument, so the preview waits for it:
|
||||
// with payload-first emission (models don't always honour schema order) the plain form
|
||||
// must never reach the screen, or it would be stranded above the described one.
|
||||
r.useToolSchemas([
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "run a command",
|
||||
parameters: { type: "object", properties: { description: {}, cmd: {} } },
|
||||
},
|
||||
]);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c9" }));
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '{"cmd":"ls -la",',
|
||||
toolCallId: "c9",
|
||||
}),
|
||||
);
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '"description":"列出当前目录的文件"}',
|
||||
toolCallId: "c9",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c9" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c9] exec_command <- 列出当前目录的文件 ($ ls -la)\n");
|
||||
});
|
||||
|
||||
it("streams the command live when the schema has no description argument", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// call_description switched off for this tool: nothing can supersede the plain form, so
|
||||
// it streams as the arguments arrive rather than waiting for them to settle.
|
||||
r.useToolSchemas([
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "run a command",
|
||||
parameters: { type: "object", properties: { cmd: {} } },
|
||||
},
|
||||
]);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c7" }));
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"ls', toolCallId: "c7" }),
|
||||
);
|
||||
expect(stripAnsi(text())).toBe("[tool-c7] exec_command <- $ ls");
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: ' -la"}', toolCallId: "c7" }),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c7" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c7] exec_command <- $ ls -la\n");
|
||||
});
|
||||
|
||||
it("still renders a call line whose arguments never settled", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// Interrupted mid-arguments while awaiting a description: the call must not vanish.
|
||||
r.useToolSchemas([
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "run a command",
|
||||
parameters: { type: "object", properties: { description: {}, cmd: {} } },
|
||||
},
|
||||
]);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c8" }));
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"sle', toolCallId: "c8" }),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c8" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c8] exec_command <- $ sle\n");
|
||||
});
|
||||
|
||||
it("prefixes streamed tool output with the tool name and skips the complete tool_call_output", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// The call precedes its output and supplies the gutter's tool name.
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c3" }));
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '{"cmd":"ls"}',
|
||||
toolCallId: "c3",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "line1\n", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "line2", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
|
||||
r.handle(toolCallOutput({ output: "line1\nline2", toolCallId: "c3" })); // must not be re-rendered
|
||||
// Each line starts with a tagged gutter (no indent) matching the call line.
|
||||
expect(stripAnsi(text())).toBe("[tool-c3] >> line1\n[tool-c3] >> line2\n");
|
||||
// Call line first, then each output line repeats the `[tool-xxx] <toolName>` prefix.
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-c3] exec_command <- $ ls\n[tool-c3] exec_command -> line1\n[tool-c3] exec_command -> line2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("colors edit_file diff output lines green/red and dims hunk headers", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "edit_file", toolCallId: "d1" }));
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '{"file_path":"x.ts","old_string":"old","new_string":"new"}',
|
||||
toolCallId: "d1",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "d1" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "d1" }));
|
||||
r.handle(
|
||||
partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: 'Replaced 1 occurrence in "x.ts".\n@@ -1,1 +1,1 @@\n-old\n+new\n',
|
||||
toolCallId: "d1",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "d1" }));
|
||||
const raw = text();
|
||||
// Diff lines are wrapped in green/red; the hunk header is dimmed; the summary line stays plain.
|
||||
expect(raw).toContain("\x1b[32m+new\x1b[0m");
|
||||
expect(raw).toContain("\x1b[31m-old\x1b[0m");
|
||||
expect(raw).toContain("\x1b[2m@@ -1,1 +1,1 @@\x1b[0m");
|
||||
// The stripped view still reads as labeled gutter lines.
|
||||
const plain = stripAnsi(raw);
|
||||
expect(plain).toContain("[tool-d1] edit_file -> -old");
|
||||
expect(plain).toContain("[tool-d1] edit_file -> +new");
|
||||
});
|
||||
|
||||
it("does not diff-color non-file-tool output", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "d2" }));
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"x"}', toolCallId: "d2" }),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "d2" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "d2" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "+plus\n", toolCallId: "d2" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "d2" }));
|
||||
expect(text()).not.toContain("\x1b[32m");
|
||||
});
|
||||
|
||||
it("falls back to the pairing tag on output whose call was never seen", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "line1", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c3] -> line1\n");
|
||||
});
|
||||
|
||||
it("prints the retry line only when the retry request actually begins", () => {
|
||||
@@ -165,10 +311,11 @@ describe("StreamRenderer", () => {
|
||||
r.handle(partialText("start", ""));
|
||||
r.handle(partialText("delta", "hello"));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "a2\n", toolCallId: "tA" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] >> a1\n[tool-tA] >> a2\n"); // hello is still queued
|
||||
// No call preceded tA in this stream: the gutter falls back to the pairing tag.
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] -> a1\n[tool-tA] -> a2\n"); // hello is still queued
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "tA" }));
|
||||
r.handle(partialText("stop", "", "completed"));
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] >> a1\n[tool-tA] >> a2\nhello\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] -> a1\n[tool-tA] -> a2\nhello\n");
|
||||
});
|
||||
|
||||
it("queues everything while a user prompt is active and flushes after it ends", () => {
|
||||
@@ -411,10 +558,30 @@ describe("StreamRenderer", () => {
|
||||
r.beginUserPrompt(tc);
|
||||
r.noteApprovalDecision(tc, "allow");
|
||||
r.endUserPrompt();
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] $ pwd\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] exec_command <- $ pwd\n✓ [approved]\n");
|
||||
// A late approval_decision event is deduped by key and not re-rendered.
|
||||
r.handle(approvalDecision("allow", "p8"));
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] $ pwd\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] exec_command <- $ pwd\n✓ [approved]\n");
|
||||
});
|
||||
|
||||
it("prints the decoded file-tool payload before the approval prompt, without duplicating the call line", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
const tc = toolCall({
|
||||
name: "edit_file",
|
||||
arguments: JSON.stringify({ file_path: "src/x.ts", old_string: "a", new_string: "b" }),
|
||||
toolCallId: "fp1",
|
||||
});
|
||||
r.beginUserPrompt(tc);
|
||||
r.noteApprovalDecision(tc, "allow");
|
||||
r.endUserPrompt();
|
||||
// Call line, payload lines (what the user is approving), then the result — with no
|
||||
// duplicated call line after the payload.
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-fp1] edit_file src/x.ts\n" +
|
||||
"file_path: src/x.ts\nold_string: a\nnew_string: b\n" +
|
||||
"✓ [approved]\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-renders a half-streamed call line at approval and suppresses its late tail deltas", () => {
|
||||
@@ -447,7 +614,7 @@ describe("StreamRenderer", () => {
|
||||
// At approval time, the full call line is re-rendered in place from the complete message, right next to
|
||||
// the result; after unlocking, the late tail is deduped and must not start a duplicate call line after
|
||||
// the result line.
|
||||
expect(s).toContain("[tool-h7] $ git status\n✓ [approved]\n");
|
||||
expect(s).toContain("[tool-h7] exec_command <- $ git status\n✓ [approved]\n");
|
||||
expect(s.slice(s.indexOf("[approved]"))).not.toContain("[tool-h7]");
|
||||
});
|
||||
|
||||
@@ -531,7 +698,9 @@ describe("StreamRenderer", () => {
|
||||
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c5" }),
|
||||
"allow",
|
||||
);
|
||||
expect(stripAnsi(text())).toBe("[tool-c5] $ ls\nhi\n[tool-c5] $ ls\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-c5] exec_command <- $ ls\nhi\n[tool-c5] exec_command <- $ ls\n✓ [approved]\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not re-render the call line when it is already adjacent to the decision", () => {
|
||||
@@ -551,7 +720,7 @@ describe("StreamRenderer", () => {
|
||||
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c6" }),
|
||||
"deny",
|
||||
);
|
||||
expect(stripAnsi(text())).toBe("[tool-c6] $ ls\n× [denied]\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-c6] exec_command <- $ ls\n× [denied]\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -574,7 +743,7 @@ describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
|
||||
),
|
||||
);
|
||||
r.handle(withOrigin(approvalDecision("allow", "cc1"), hop));
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc1] $ ls\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc1] exec_command <- $ ls\n✓ [approved]\n");
|
||||
});
|
||||
|
||||
it("renders the pending nested tool call at approval time when its stream copy has not arrived; dedupes the late copy", () => {
|
||||
@@ -586,11 +755,11 @@ describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
|
||||
);
|
||||
// The approval callback arrives before the forwarded message: beginUserPrompt renders the call line directly from the complete message.
|
||||
r.beginUserPrompt(tc);
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] $ ls\n");
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] exec_command <- $ ls\n");
|
||||
r.endUserPrompt();
|
||||
// The late forwarded copy is deduped by key and not re-rendered.
|
||||
r.handle(tc);
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] $ ls\n");
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] exec_command <- $ ls\n");
|
||||
});
|
||||
|
||||
it("renders the pending parent tool call at approval time and suppresses its late partial stream", () => {
|
||||
@@ -611,7 +780,7 @@ describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "p7" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-p7] $ pwd\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-p7] exec_command <- $ pwd\n");
|
||||
});
|
||||
|
||||
it("adds nested token_usage request totals to the task delta and the session total", () => {
|
||||
@@ -691,9 +860,9 @@ describe("renderHistory (resume)", () => {
|
||||
expect(s).toContain("> hello");
|
||||
expect(s).toContain("pondering");
|
||||
expect(s).toContain("hi there");
|
||||
expect(s).toContain("[tool-653] $ ls");
|
||||
expect(s).toContain("[tool-653] >> a.txt");
|
||||
expect(s).toContain("[tool-653] >> b.txt");
|
||||
expect(s).toContain("[tool-653] exec_command <- $ ls");
|
||||
expect(s).toContain("[tool-653] exec_command -> a.txt");
|
||||
expect(s).toContain("[tool-653] exec_command -> b.txt");
|
||||
// An interrupted message carries a marker (rendering includes the interrupted turn).
|
||||
expect(s).toContain("half answer [aborted]");
|
||||
});
|
||||
|
||||
@@ -1,72 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderPartialToolCall } from "../src/tool-render.js";
|
||||
import {
|
||||
renderFileToolApprovalPayload,
|
||||
renderPartialToolCall,
|
||||
shortenPath,
|
||||
} from "../src/tool-render.js";
|
||||
|
||||
describe("renderPartialToolCall", () => {
|
||||
it("renders partial exec_command args as $ <cmd-so-far>", () => {
|
||||
describe("renderPartialToolCall — exec_command", () => {
|
||||
it("streams `exec_command <- $ {cmd}` when the schema has no description argument", () => {
|
||||
// The default path: the switch is off (or the tool is unknown), so nothing can supersede
|
||||
// the plain form and it streams character by character.
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":')).toBeNull();
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"l')).toBe("$ l");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls"}')).toBe("$ ls");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"echo \\"hi\\"')).toBe('$ echo "hi"');
|
||||
});
|
||||
|
||||
it("renders run_subagent as run_subagent << <prompt>, folded to one line", () => {
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":')).toBeNull();
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"analy')).toBe("run_subagent << analy");
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"line1\\nline2"}')).toBe(
|
||||
"run_subagent << line1 line2",
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"l')).toBe("exec_command <- $ l");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls"}')).toBe("exec_command <- $ ls");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"echo \\"hi\\"')).toBe(
|
||||
'exec_command <- $ echo "hi"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders input_command polls (empty chars) without a payload", () => {
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":')).toBeNull();
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d"}')).toBe(
|
||||
"⌨ input_command → proc-1a2b3c4d",
|
||||
it("waits for the description when the schema carries the argument", () => {
|
||||
const described = { expectDescription: true };
|
||||
// Nothing renders while the description could still be the first thing shown...
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls -la', described)).toBeNull();
|
||||
// ...until the arguments settle without one, or the fragment is final.
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls -la"}', described)).toBe(
|
||||
"exec_command <- $ ls -la",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":""}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d");
|
||||
renderPartialToolCall("exec_command", '{"cmd":"ls -la', { ...described, final: true }),
|
||||
).toBe("exec_command <- $ ls -la");
|
||||
});
|
||||
|
||||
it("renders non-empty input_command chars with visible control characters", () => {
|
||||
it("renders `exec_command <- {description} ($ {cmd})` when a description is present", () => {
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"y\\n"}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d << y\\n");
|
||||
// U+0003 (Ctrl-C) is rendered in caret notation.
|
||||
renderPartialToolCall("exec_command", '{"description":"List files","cmd":"ls -la"}'),
|
||||
).toBe("exec_command <- List files ($ ls -la)");
|
||||
// Same final form regardless of the model's property order.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"\\u0003"}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d << ^C");
|
||||
// Disambiguates literal backslash escapes: chars "a", "\", "n" render as a\\n, distinct from a real newline \n.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"a\\\\n"}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d << a\\\\n");
|
||||
renderPartialToolCall("exec_command", '{"cmd":"ls -la","description":"List files"}'),
|
||||
).toBe("exec_command <- List files ($ ls -la)");
|
||||
// Multi-line descriptions fold to one line; an empty description falls back to the plain form.
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls","description":"a\\nb"}')).toBe(
|
||||
"exec_command <- a b ($ ls)",
|
||||
);
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls","description":""}')).toBe(
|
||||
"exec_command <- $ ls",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps input_command previews append-only across \\uXXXX delta boundaries", () => {
|
||||
it("streams the description form append-only when the description arrives first", () => {
|
||||
const stages = [
|
||||
'{"process_id":"proc-1a2b3c4d","chars":"y',
|
||||
'{"process_id":"proc-1a2b3c4d","chars":"y\\u0',
|
||||
'{"process_id":"proc-1a2b3c4d","chars":"y\\u0003',
|
||||
'{"description":"List fi', // description streams live
|
||||
'{"description":"List files"', // description complete
|
||||
'{"description":"List files","cmd":"ls', // cmd streaming inside the open parenthesis
|
||||
'{"description":"List files","cmd":"ls -la"}', // cmd complete: parenthesis closes
|
||||
];
|
||||
const previews = stages.map((s) => renderPartialToolCall("input_command", s)!);
|
||||
expect(previews[0]).toBe("⌨ input_command → proc-1a2b3c4d << y");
|
||||
// An incomplete \u escape is treated as "stop here" rather than emitting the raw hex as literal text.
|
||||
expect(previews[1]).toBe("⌨ input_command → proc-1a2b3c4d << y");
|
||||
expect(previews[2]).toBe("⌨ input_command → proc-1a2b3c4d << y^C");
|
||||
const previews = stages.map((s) =>
|
||||
renderPartialToolCall("exec_command", s, { expectDescription: true }),
|
||||
);
|
||||
expect(previews[0]).toBe("exec_command <- List fi");
|
||||
expect(previews[1]).toBe("exec_command <- List files");
|
||||
expect(previews[2]).toBe("exec_command <- List files ($ ls");
|
||||
expect(previews[3]).toBe("exec_command <- List files ($ ls -la)");
|
||||
for (let i = 1; i < previews.length; i++) {
|
||||
expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders input_subagent polls without a payload and follow-up prompts with one", () => {
|
||||
it("never shows the plain form first when the model emits the payload before the description", () => {
|
||||
// The regression this guards: a plain line followed by a described one for the same call.
|
||||
const stages = [
|
||||
'{"cmd":"ls -la', // withheld: the schema says a description is coming
|
||||
'{"cmd":"ls -la","description":"List fi', // description streams; payload waits for it
|
||||
'{"cmd":"ls -la","description":"List files"}', // settled: payload appended
|
||||
];
|
||||
const previews = stages.map((s) =>
|
||||
renderPartialToolCall("exec_command", s, { expectDescription: true }),
|
||||
);
|
||||
expect(previews[0]).toBeNull();
|
||||
expect(previews[1]).toBe("exec_command <- List fi");
|
||||
expect(previews[2]).toBe("exec_command <- List files ($ ls -la)");
|
||||
expect(previews[2]!.startsWith(previews[1]!)).toBe(true);
|
||||
expect(previews.some((p) => p === "exec_command <- $ ls -la")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — run_subagent", () => {
|
||||
it("renders `run_subagent <- {prompt}` and the description form", () => {
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":')).toBeNull();
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"analy')).toBe("run_subagent <- analy");
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"line1\\nline2"}')).toBe(
|
||||
"run_subagent <- line1 line2",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"run_subagent",
|
||||
'{"description":"Delegating research","prompt":"do the thing"}',
|
||||
),
|
||||
).toBe("run_subagent <- Delegating research (do the thing)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — input_command / input_subagent", () => {
|
||||
it("renders polls (empty chars) without a payload", () => {
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":')).toBeNull();
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d"}')).toBe(
|
||||
"input_command <- proc-1a2b3c4d",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":""}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d");
|
||||
});
|
||||
|
||||
it("renders non-empty chars with visible control characters", () => {
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"y\\n"}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d << y\\n");
|
||||
// U+0003 (Ctrl-C) is rendered in caret notation.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"\\u0003"}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d << ^C");
|
||||
// Disambiguates literal backslash escapes: chars "a", "\", "n" render as a\\n, distinct from a real newline \n.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"a\\\\n"}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d << a\\\\n");
|
||||
});
|
||||
|
||||
it("wraps the payload in parentheses after the description", () => {
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"input_command",
|
||||
'{"description":"Confirm the prompt","process_id":"proc-1a2b3c4d","chars":"y\\n"}',
|
||||
),
|
||||
).toBe("input_command <- Confirm the prompt (proc-1a2b3c4d << y\\n)");
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"input_subagent",
|
||||
'{"description":"Poll for progress","subagent_id":"subagent-9f8e7d6c"}',
|
||||
),
|
||||
).toBe("input_subagent <- Poll for progress (subagent-9f8e7d6c)");
|
||||
});
|
||||
|
||||
it("keeps input_command previews append-only across \\uXXXX delta boundaries", () => {
|
||||
// Schema order (description first) keeps the whole call streaming live.
|
||||
const stages = [
|
||||
'{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y',
|
||||
'{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y\\u0',
|
||||
'{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y\\u0003',
|
||||
];
|
||||
const previews = stages.map((s) =>
|
||||
renderPartialToolCall("input_command", s, { expectDescription: true })!,
|
||||
);
|
||||
expect(previews[0]).toBe("input_command <- Confirm (proc-1a2b3c4d << y");
|
||||
// An incomplete \u escape is treated as "stop here" rather than emitting the raw hex as literal text.
|
||||
expect(previews[1]).toBe("input_command <- Confirm (proc-1a2b3c4d << y");
|
||||
expect(previews[2]).toBe("input_command <- Confirm (proc-1a2b3c4d << y^C");
|
||||
for (let i = 1; i < previews.length; i++) {
|
||||
expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders input_subagent polls and follow-up prompts", () => {
|
||||
expect(
|
||||
renderPartialToolCall("input_subagent", '{"subagent_id":"subagent-9f8e7d6c","prompt":""}'),
|
||||
).toBe("⌨ input_subagent → subagent-9f8e7d6c");
|
||||
).toBe("input_subagent <- subagent-9f8e7d6c");
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"input_subagent",
|
||||
'{"subagent_id":"subagent-9f8e7d6c","prompt":"continue with the tests"}',
|
||||
),
|
||||
).toBe("⌨ input_subagent → subagent-9f8e7d6c << continue with the tests");
|
||||
).toBe("input_subagent <- subagent-9f8e7d6c << continue with the tests");
|
||||
});
|
||||
|
||||
it("truncates long payload previews and stops growing afterwards", () => {
|
||||
@@ -75,15 +177,95 @@ describe("renderPartialToolCall", () => {
|
||||
"input_subagent",
|
||||
`{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}"}`,
|
||||
);
|
||||
expect(capped).toBe(`⌨ input_subagent → subagent-9f8e7d6c << ${"x".repeat(120)}…`);
|
||||
expect(capped).toBe(`input_subagent <- subagent-9f8e7d6c << ${"x".repeat(120)}…`);
|
||||
const longer = renderPartialToolCall(
|
||||
"input_subagent",
|
||||
`{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}yyy"}`,
|
||||
);
|
||||
expect(longer).toBe(capped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — file tools", () => {
|
||||
it("renders `<name> <shortened path>` only once the path is complete", () => {
|
||||
expect(renderPartialToolCall("read_file", '{"file_path":')).toBeNull();
|
||||
// A still-streaming path is withheld: shortening a growing path would rewrite the line.
|
||||
expect(renderPartialToolCall("read_file", '{"file_path":"src/ap')).toBeNull();
|
||||
expect(renderPartialToolCall("read_file", '{"file_path":"src/app.py","offset":10}')).toBe(
|
||||
"read_file src/app.py",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall("edit_file", '{"file_path":"a.txt","old_string":"x","new_string":"y"}'),
|
||||
).toBe("edit_file a.txt");
|
||||
expect(
|
||||
renderPartialToolCall("write_file", '{"file_path":"packages/core/src/state/out.ts"}'),
|
||||
).toBe("write_file …/state/out.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — fallback", () => {
|
||||
it("falls back to name(args-prefix) for unknown tools", () => {
|
||||
expect(renderPartialToolCall("search", '{"q":"hi')).toBe('search({"q":"hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenPath", () => {
|
||||
it("keeps at most one parent directory plus the filename", () => {
|
||||
expect(shortenPath("file.ts")).toBe("file.ts");
|
||||
expect(shortenPath("src/file.ts")).toBe("src/file.ts");
|
||||
expect(shortenPath("/etc/hosts")).toBe("/etc/hosts");
|
||||
expect(shortenPath("packages/core/src/state/default-config.ts")).toBe(
|
||||
"…/state/default-config.ts",
|
||||
);
|
||||
expect(shortenPath("/home/user/project/src/app.py")).toBe("…/src/app.py");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFileToolApprovalPayload", () => {
|
||||
it("prints the decoded edit_file payload with gutters for multi-line fields", () => {
|
||||
const payload = renderFileToolApprovalPayload(
|
||||
"edit_file",
|
||||
JSON.stringify({
|
||||
file_path: "src/app.py",
|
||||
old_string: "a\nb",
|
||||
new_string: "a\nc",
|
||||
}),
|
||||
);
|
||||
expect(payload).toBe(
|
||||
[
|
||||
"file_path: src/app.py",
|
||||
"old_string:",
|
||||
" | a",
|
||||
" | b",
|
||||
"new_string:",
|
||||
" | a",
|
||||
" | c",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints write_file content and read_file window arguments", () => {
|
||||
expect(
|
||||
renderFileToolApprovalPayload("write_file", '{"file_path":"out.md","content":"hello"}'),
|
||||
).toBe(["file_path: out.md", "content: hello"].join("\n"));
|
||||
expect(
|
||||
renderFileToolApprovalPayload("read_file", '{"file_path":"a.txt","offset":3,"limit":5}'),
|
||||
).toBe(["file_path: a.txt", "offset: 3", "limit: 5"].join("\n"));
|
||||
});
|
||||
|
||||
it("bounds the payload to a line count with an explicit elision note", () => {
|
||||
const content = Array.from({ length: 60 }, (_, i) => `line-${i + 1}`).join("\n");
|
||||
const payload = renderFileToolApprovalPayload(
|
||||
"write_file",
|
||||
JSON.stringify({ file_path: "big.txt", content }),
|
||||
)!;
|
||||
const lines = payload.split("\n");
|
||||
// 24 shown lines + the elision note.
|
||||
expect(lines).toHaveLength(25);
|
||||
expect(lines[lines.length - 1]).toMatch(/\[… \d+ more lines not shown\]/);
|
||||
});
|
||||
|
||||
it("returns null for non-file tools", () => {
|
||||
expect(renderFileToolApprovalPayload("exec_command", '{"cmd":"ls"}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user