Merge pull request #45 from multica-ai/forrestchang/cli-tool-details
test(cli): add output formatting tests and refactoring
This commit is contained in:
commit
a2fc30080c
2 changed files with 351 additions and 7 deletions
244
src/agent/cli/output.test.ts
Normal file
244
src/agent/cli/output.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolDisplayName,
|
||||
formatToolArgs,
|
||||
extractResultDetails,
|
||||
formatResultSummary,
|
||||
} from "./output.js";
|
||||
|
||||
describe("output", () => {
|
||||
describe("toolDisplayName", () => {
|
||||
it("should map known tool names to display names", () => {
|
||||
expect(toolDisplayName("read")).toBe("ReadFile");
|
||||
expect(toolDisplayName("write")).toBe("WriteFile");
|
||||
expect(toolDisplayName("edit")).toBe("EditFile");
|
||||
expect(toolDisplayName("glob")).toBe("Glob");
|
||||
expect(toolDisplayName("web_search")).toBe("WebSearch");
|
||||
expect(toolDisplayName("web_fetch")).toBe("WebFetch");
|
||||
});
|
||||
|
||||
it("should return original name for unknown tools", () => {
|
||||
expect(toolDisplayName("custom_tool")).toBe("custom_tool");
|
||||
expect(toolDisplayName("unknown")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolArgs", () => {
|
||||
it("should return empty string for null/undefined args", () => {
|
||||
expect(formatToolArgs("read", null)).toBe("");
|
||||
expect(formatToolArgs("read", undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for non-object args", () => {
|
||||
expect(formatToolArgs("read", "string")).toBe("");
|
||||
expect(formatToolArgs("read", 123)).toBe("");
|
||||
});
|
||||
|
||||
it("should format read tool args", () => {
|
||||
expect(formatToolArgs("read", { path: "/foo/bar.ts" })).toBe("/foo/bar.ts");
|
||||
expect(formatToolArgs("read", { file: "/foo/bar.ts" })).toBe("/foo/bar.ts");
|
||||
});
|
||||
|
||||
it("should format glob tool args", () => {
|
||||
expect(formatToolArgs("glob", { pattern: "**/*.ts" })).toBe("**/*.ts");
|
||||
expect(formatToolArgs("glob", { pattern: "**/*.ts", cwd: "/src" })).toBe("**/*.ts in /src");
|
||||
});
|
||||
|
||||
it("should format web_search tool args with truncation", () => {
|
||||
expect(formatToolArgs("web_search", { query: "short query" })).toBe("short query");
|
||||
const longQuery = "a".repeat(60);
|
||||
expect(formatToolArgs("web_search", { query: longQuery })).toBe("a".repeat(50) + "…");
|
||||
});
|
||||
|
||||
it("should format web_fetch tool args with URL parsing", () => {
|
||||
expect(formatToolArgs("web_fetch", { url: "https://example.com" })).toBe("example.com");
|
||||
expect(formatToolArgs("web_fetch", { url: "https://example.com/" })).toBe("example.com");
|
||||
expect(formatToolArgs("web_fetch", { url: "https://example.com/path/to/page" })).toBe(
|
||||
"example.com/path/to/page"
|
||||
);
|
||||
});
|
||||
|
||||
it("should truncate long URL paths", () => {
|
||||
const longPath = "/very/long/path/that/exceeds/thirty/characters/limit";
|
||||
expect(formatToolArgs("web_fetch", { url: `https://example.com${longPath}` })).toBe(
|
||||
"example.com" + longPath.slice(0, 30) + "…"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle invalid URLs gracefully", () => {
|
||||
expect(formatToolArgs("web_fetch", { url: "not-a-valid-url" })).toBe("not-a-valid-url");
|
||||
const longInvalid = "x".repeat(60);
|
||||
expect(formatToolArgs("web_fetch", { url: longInvalid })).toBe("x".repeat(50) + "…");
|
||||
});
|
||||
|
||||
it("should return empty string for unknown tools", () => {
|
||||
expect(formatToolArgs("unknown_tool", { foo: "bar" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractResultDetails", () => {
|
||||
it("should return null for null/undefined", () => {
|
||||
expect(extractResultDetails(null)).toBeNull();
|
||||
expect(extractResultDetails(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-objects", () => {
|
||||
expect(extractResultDetails("string")).toBeNull();
|
||||
expect(extractResultDetails(123)).toBeNull();
|
||||
});
|
||||
|
||||
it("should extract JSON from AgentMessage content array", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"count": 5, "files": ["a.ts", "b.ts"]}' }],
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ count: 5, files: ["a.ts", "b.ts"] });
|
||||
});
|
||||
|
||||
it("should skip non-text content items", () => {
|
||||
const result = {
|
||||
content: [
|
||||
{ type: "image", data: "..." },
|
||||
{ type: "text", text: '{"value": 42}' },
|
||||
],
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it("should handle invalid JSON gracefully", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "not json" }],
|
||||
};
|
||||
// Falls back to returning the object itself
|
||||
expect(extractResultDetails(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it("should return direct object if no content array", () => {
|
||||
const result = { count: 10, truncated: true };
|
||||
expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResultSummary", () => {
|
||||
describe("glob", () => {
|
||||
it("should format file count from count field", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 5}' }] };
|
||||
expect(formatResultSummary("glob", result)).toBe("5 files");
|
||||
});
|
||||
|
||||
it("should format file count from files array length", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"files": ["a.ts", "b.ts", "c.ts"]}' }],
|
||||
};
|
||||
expect(formatResultSummary("glob", result)).toBe("3 files");
|
||||
});
|
||||
|
||||
it("should show + for truncated results", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 100, "truncated": true}' }] };
|
||||
expect(formatResultSummary("glob", result)).toBe("100+ files");
|
||||
});
|
||||
|
||||
it("should handle zero files", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 0, "files": []}' }] };
|
||||
expect(formatResultSummary("glob", result)).toBe("0 files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_search", () => {
|
||||
it("should format error results", () => {
|
||||
const result = { content: [{ type: "text", text: '{"error": true, "message": "API error"}' }] };
|
||||
expect(formatResultSummary("web_search", result)).toBe("error: API error");
|
||||
});
|
||||
|
||||
it("should format Perplexity results with citations", () => {
|
||||
const result = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '{"content": "answer text", "citations": ["url1", "url2", "url3"]}',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(formatResultSummary("web_search", result)).toBe("3 citations");
|
||||
});
|
||||
|
||||
it("should format Brave results with count", () => {
|
||||
const result = { content: [{ type: "text", text: '{"count": 10}' }] };
|
||||
expect(formatResultSummary("web_search", result)).toBe("10 results");
|
||||
});
|
||||
|
||||
it("should count results array if no count field", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"results": [{}, {}, {}]}' }],
|
||||
};
|
||||
expect(formatResultSummary("web_search", result)).toBe("3 results");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web_fetch", () => {
|
||||
it("should format error results", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"error": true, "message": "404 Not Found"}' }],
|
||||
};
|
||||
expect(formatResultSummary("web_fetch", result)).toBe("error: 404 Not Found");
|
||||
});
|
||||
|
||||
it("should format title", () => {
|
||||
const result = { content: [{ type: "text", text: '{"title": "Example Page"}' }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe('"Example Page"');
|
||||
});
|
||||
|
||||
it("should truncate long titles", () => {
|
||||
const longTitle = "A".repeat(50);
|
||||
const result = { content: [{ type: "text", text: `{"title": "${longTitle}"}` }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe(`"${"A".repeat(30)}…"`);
|
||||
});
|
||||
|
||||
it("should format content length in KB", () => {
|
||||
const result = { content: [{ type: "text", text: '{"length": 2048}' }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe("2.0KB");
|
||||
});
|
||||
|
||||
it("should show cached indicator", () => {
|
||||
const result = { content: [{ type: "text", text: '{"cached": true}' }] };
|
||||
expect(formatResultSummary("web_fetch", result)).toBe("cached");
|
||||
});
|
||||
|
||||
it("should combine multiple fields", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: '{"title": "Page", "length": 1024, "cached": true}' }],
|
||||
};
|
||||
expect(formatResultSummary("web_fetch", result)).toBe('"Page", 1.0KB, cached');
|
||||
});
|
||||
});
|
||||
|
||||
describe("grep", () => {
|
||||
it("should return 'no matches' for empty results", () => {
|
||||
const result = { content: [{ type: "text", text: "No matches found" }] };
|
||||
expect(formatResultSummary("grep", result)).toBe("no matches");
|
||||
});
|
||||
|
||||
it("should count non-empty lines as matches", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "file.ts:1:match1\nfile.ts:2:match2\nfile.ts:3:match3" }],
|
||||
};
|
||||
expect(formatResultSummary("grep", result)).toBe("3 matches");
|
||||
});
|
||||
|
||||
it("should ignore empty lines when counting", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "file.ts:1:match1\n\nfile.ts:2:match2\n" }],
|
||||
};
|
||||
expect(formatResultSummary("grep", result)).toBe("2 matches");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty string for unknown tools", () => {
|
||||
const result = { content: [{ type: "text", text: '{"data": "value"}' }] };
|
||||
expect(formatResultSummary("unknown_tool", result)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for null result", () => {
|
||||
expect(formatResultSummary("glob", null)).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,12 @@ function extractText(message: AgentMessage | undefined): string {
|
|||
.join("");
|
||||
}
|
||||
|
||||
function toolDisplayName(name: string): string {
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function toolDisplayName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
read: "ReadFile",
|
||||
write: "WriteFile",
|
||||
|
|
@ -32,11 +37,15 @@ function toolDisplayName(name: string): string {
|
|||
grep: "Grep",
|
||||
find: "FindFiles",
|
||||
ls: "ListDir",
|
||||
glob: "Glob",
|
||||
web_search: "WebSearch",
|
||||
web_fetch: "WebFetch",
|
||||
};
|
||||
return map[name] || name;
|
||||
}
|
||||
|
||||
function formatToolArgs(name: string, args: unknown): string {
|
||||
// Exported for testing
|
||||
export function formatToolArgs(name: string, args: unknown): string {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
const record = args as Record<string, unknown>;
|
||||
const get = (key: string) => (record[key] !== undefined ? String(record[key]) : "");
|
||||
|
|
@ -57,19 +66,110 @@ function formatToolArgs(name: string, args: unknown): string {
|
|||
return get("command");
|
||||
case "process":
|
||||
return [get("action"), get("id")].filter(Boolean).join(" ");
|
||||
case "glob":
|
||||
return [get("pattern"), get("cwd")].filter(Boolean).join(" in ");
|
||||
case "web_search":
|
||||
return truncate(get("query"), 50);
|
||||
case "web_fetch": {
|
||||
const url = get("url");
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname + (parsed.pathname !== "/" ? truncate(parsed.pathname, 30) : "");
|
||||
} catch {
|
||||
return truncate(url, 50);
|
||||
}
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolLine(name: string, args: unknown): string {
|
||||
function formatToolLine(name: string, args: unknown, result?: unknown): string {
|
||||
const title = colors.toolName(toolDisplayName(name));
|
||||
const argText = formatToolArgs(name, args);
|
||||
const resultSummary = formatResultSummary(name, result);
|
||||
const bullet = colors.toolBullet("•");
|
||||
|
||||
let line = `${bullet} ${title}`;
|
||||
if (argText) {
|
||||
return `${bullet} ${title} ${colors.toolArgs(`(${argText})`)}`;
|
||||
line += ` ${colors.toolArgs(`(${argText})`)}`;
|
||||
}
|
||||
if (resultSummary) {
|
||||
line += ` ${colors.toolArrow("→")} ${colors.toolArgs(resultSummary)}`;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function extractResultDetails(result: unknown): Record<string, unknown> | null {
|
||||
if (!result || typeof result !== "object") return null;
|
||||
|
||||
// Try to extract from AgentMessage content array (JSON result)
|
||||
const msg = result as { content?: Array<{ type: string; text?: string }> };
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const c of msg.content) {
|
||||
if (c.type === "text" && c.text) {
|
||||
try {
|
||||
return JSON.parse(c.text) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try direct object access
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export function formatResultSummary(name: string, result: unknown): string {
|
||||
const details = extractResultDetails(result);
|
||||
if (!details) return "";
|
||||
|
||||
switch (name) {
|
||||
case "glob": {
|
||||
const count = details.count ?? (Array.isArray(details.files) ? details.files.length : 0);
|
||||
const truncated = details.truncated ? "+" : "";
|
||||
return `${count}${truncated} files`;
|
||||
}
|
||||
case "web_search": {
|
||||
if (details.error) return `error: ${details.message || details.error}`;
|
||||
if (details.content) {
|
||||
// Perplexity result
|
||||
const citations = Array.isArray(details.citations) ? details.citations.length : 0;
|
||||
return `${citations} citations`;
|
||||
}
|
||||
// Brave result
|
||||
const count = details.count ?? (Array.isArray(details.results) ? details.results.length : 0);
|
||||
return `${count} results`;
|
||||
}
|
||||
case "web_fetch": {
|
||||
if (details.error) return `error: ${details.message || details.error}`;
|
||||
const parts: string[] = [];
|
||||
if (details.title) {
|
||||
parts.push(`"${truncate(String(details.title), 30)}"`);
|
||||
}
|
||||
if (typeof details.length === "number") {
|
||||
const kb = (details.length / 1024).toFixed(1);
|
||||
parts.push(`${kb}KB`);
|
||||
}
|
||||
if (details.cached) {
|
||||
parts.push("cached");
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
case "grep": {
|
||||
// Try to count matches from result text
|
||||
const text = extractText(result as AgentMessage | undefined);
|
||||
if (text.includes("No matches found")) return "no matches";
|
||||
const lines = text.split("\n").filter((l) => l.trim()).length;
|
||||
if (lines > 0) return `${lines} matches`;
|
||||
return "";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
return `${bullet} ${title}`;
|
||||
}
|
||||
|
||||
export function createAgentOutput(params: {
|
||||
|
|
@ -151,14 +251,14 @@ export function createAgentOutput(params: {
|
|||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
// Stop spinner and show final result
|
||||
// Stop spinner and show final result with summary
|
||||
if (event.isError) {
|
||||
const errorText = extractText(event.result) || "Tool failed";
|
||||
const bullet = colors.toolError("✗");
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
|
||||
} else {
|
||||
spinner.stop(formatToolLine(event.toolName, pendingToolArgs));
|
||||
spinner.stop(formatToolLine(event.toolName, pendingToolArgs, event.result));
|
||||
}
|
||||
pendingToolName = "";
|
||||
pendingToolArgs = null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue