summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat/logic')
-rw-r--r--src/features/chat/logic/tool-summary.test.ts281
-rw-r--r--src/features/chat/logic/tool-summary.ts263
2 files changed, 544 insertions, 0 deletions
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
new file mode 100644
index 0000000..5caa1f1
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, it } from "vitest";
+import type { ToolEntry } from "../../../core/chunks";
+import { viewToolEntry } from "./tool-summary";
+
+function entry(
+ name: string,
+ input: unknown,
+ content: string | null = "ok",
+ isError = false,
+): ToolEntry {
+ return {
+ toolCallId: "a",
+ toolName: name,
+ call: { type: "tool-call", toolCallId: "a", toolName: name, input },
+ result:
+ content === null
+ ? null
+ : { type: "tool-result", toolCallId: "a", toolName: name, content, isError },
+ };
+}
+
+const view = (name: string, input: unknown, content: string | null = "ok", isError = false) =>
+ viewToolEntry(entry(name, input, content, isError));
+const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
+
+describe("tool summaries", () => {
+ it.each([
+ ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"],
+ ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"],
+ ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"],
+ ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"],
+ ["read_file", { path: "src/a.ts" }, "src/a.ts"],
+ ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"],
+ ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"],
+ ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"],
+ ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"],
+ [
+ "lsp",
+ { operation: "references", path: "src/a.ts", line: 12, character: 3 },
+ "references src/a.ts:12:3",
+ ],
+ ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"],
+ [
+ "todo_write",
+ { todos: [{ status: "pending" }, { status: "completed" }, null] },
+ "3 tasks · 1 completed",
+ ],
+ ["load_skill", { name: "research_methodology" }, "research_methodology"],
+ [
+ "consult_vision",
+ { question: "What is in this picture?", imageIds: [1] },
+ "What is in this picture?",
+ ],
+ ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"],
+ ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"],
+ ["summon_agent", { task: "Review the patch" }, "Review the patch"],
+ ["summon_agent", { agent: "Opus" }, "Opus"],
+ ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"],
+ ["read_agent", { conversationId: "abcd" }, "abcd"],
+ ["open_agent", { conversationId: "abcd" }, "abcd"],
+ ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"],
+ ["list_agents", { relationship: "descendants" }, "descendants"],
+ ["list_agents", { query: "review" }, "review"],
+ ["list_agent_models", { query: "Opus" }, "Opus"],
+ ])("%s derives a meaningful call summary from %j", (name, input, expected) => {
+ expect(view(name, input).callSummary).toBe(expected);
+ });
+
+ it("formats edit call badges and old/new details without hiding multiline text", () => {
+ const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" };
+ const card = view("edit_file", input);
+ expect(card.callBadges).toEqual(["+3 −2"]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: "a.ts",
+ oldText: input.oldString,
+ newText: input.newString,
+ });
+ expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([
+ "+1 −0",
+ ]);
+ expect(view("edit_file", { path: "a" }).callBadges).toEqual([]);
+ });
+ it("preserves exact shell commands in the expanded detail", () => {
+ expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({
+ kind: "command",
+ text: "ls\n pwd",
+ });
+ });
+ it("shows scalar fields and pretty nested JSON", () => {
+ expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "path", value: "a.ts", multiline: false },
+ { key: "count", value: "2", multiline: false },
+ { key: "nested", value: '{\n "a": true\n}', multiline: true },
+ ],
+ });
+ });
+ it.each([
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ ])("generic fallback recognizes %s", (key) => {
+ expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target");
+ });
+ it("generic fallback prefers the first meaningful argument, else compact JSON", () => {
+ expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a");
+ expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe('{"a":1,"b":"x"}');
+ });
+ it.each([
+ "not JSON",
+ "{bad",
+ null,
+ undefined,
+ 42,
+ true,
+ [1, "a"],
+ {},
+ "null",
+ '"hi"',
+ ])("tolerates malformed/non-object input %j", (input) => {
+ expect(() => view("read_file", input)).not.toThrow();
+ expect(view("read_file", input).callSummary).not.toBe("");
+ expect(view("read_file", input).callDetail.kind).toBe("fields");
+ });
+ it.each([
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "youtube_transcript",
+ "summon_agent",
+ "read_agent",
+ ])("%s falls back safely for missing arguments", (name) => {
+ for (const input of [null, undefined, {}, "{bad"]) {
+ expect(() => view(name, input)).not.toThrow();
+ expect(view(name, input).callSummary.trim()).not.toBe("");
+ }
+ });
+ it("parses JSON-string inputs", () => {
+ const card = view("run_shell", '{"command":"bun run test"}');
+ expect(card.callSummary).toBe("bun run test");
+ expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" });
+ });
+ it("handles unexpected built-in argument types and prototype-looking tool names", () => {
+ expect(view("todo_write", { todos: "wrong" }).callSummary).toBe('{"todos":"wrong"}');
+ expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields");
+ expect(view("constructor", { path: "a" }).callSummary).toBe("a");
+ expect(view("__proto__", {}).callSummary).toBe("{}");
+ expect(
+ view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary,
+ ).toBe("good");
+ });
+ it("does not throw on unserializable input", () => {
+ const cyclic: Record<string, unknown> = {};
+ cyclic.self = cyclic;
+ expect(view("mcp", cyclic).callSummary).toBe("[unserializable input]");
+ expect(() => view("mcp", { integer: 1n })).not.toThrow();
+ });
+ it("caps and whitespace-collapses header strings but retains full detail", () => {
+ const command = ` echo\n${"x".repeat(300)}`;
+ const card = view("run_shell", { command });
+ expect(card.callSummary.length).toBe(120);
+ expect(card.callSummary.endsWith("…")).toBe(true);
+ expect(card.callSummary).not.toMatch(/\n/);
+ expect(card.callDetail).toEqual({ kind: "command", text: command });
+ });
+ it("represents pending entries with no response summary/output", () => {
+ expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({
+ status: "pending",
+ resultSummary: null,
+ output: null,
+ });
+ });
+ it("represents orphan results with unknown call details, not pending", () => {
+ const e = { ...entry("read_file", {}, "data"), call: null };
+ expect(viewToolEntry(e)).toMatchObject({
+ name: "read_file",
+ status: "ok",
+ callSummary: "call unavailable",
+ resultSummary: "1 line",
+ });
+ expect(viewToolEntry(e).callDetail).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "call", value: "Call unavailable (earlier history unloaded)", multiline: false },
+ ],
+ });
+ });
+});
+
+describe("tool response facts", () => {
+ it.each([
+ ["run_shell", "one\ntwo\n", false, "2 lines"],
+ ["run_shell", "done", false, "1 line"],
+ ["read_file", "1\tfile text\n", false, "1 line"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ["mcp", "", false, "no output"],
+ ["mcp", " \n\t", false, "no output"],
+ ["mcp", "a short response", false, "a short response"],
+ ["mcp", "x".repeat(200), false, "1 line"],
+ ["web_search", "a\nb\nc", false, "3 lines"],
+ ["run_shell", "", true, "failed"],
+ ["mcp", "Permission denied", true, "failed · Permission denied"],
+ ["run_shell", "error\ndetail", true, "failed · 2 lines"],
+ ["run_shell", "partial\n\n[Command timed out]", true, "timed out"],
+ ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"],
+ ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · 2 lines · truncated"],
+ ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"],
+ [
+ "edit_file",
+ 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2',
+ false,
+ "3 replacements · diagnostics",
+ ],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"],
+ ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"],
+ ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => {
+ const card = view(name, {}, content, isError);
+ expect(card.resultSummary).toBe(expected);
+ expect(card.status).toBe(isError ? "error" : "ok");
+ expect(card.output?.isError).toBe(isError);
+ });
+ it("does not infer success on an error that resembles a write result", () => {
+ expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch(
+ /^failed/,
+ );
+ });
+});
+
+describe("tool output elision", () => {
+ it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({
+ kind: "full",
+ lines: lines(n),
+ isError: false,
+ });
+ });
+ it.each([
+ 13, 14, 100,
+ ])("shows first/last six of %i lines, retaining everything for expansion", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({
+ kind: "elided",
+ head: lines(n).slice(0, 6),
+ hiddenCount: n - 12,
+ tail: lines(n).slice(-6),
+ all: lines(n),
+ isError: true,
+ });
+ });
+ it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => {
+ expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full");
+ expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({
+ kind: "elided",
+ hiddenCount: 1,
+ });
+ });
+ it("preserves meaningful blank lines including an extra trailing blank line", () => {
+ expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({
+ kind: "full",
+ lines: ["a", "", "b", ""],
+ isError: false,
+ });
+ });
+});
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
new file mode 100644
index 0000000..3b75a6b
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.ts
@@ -0,0 +1,263 @@
+import type { ToolEntry } from "../../../core/chunks";
+
+export interface ToolCardView {
+ readonly name: string;
+ readonly status: "pending" | "ok" | "error";
+ readonly callSummary: string;
+ readonly callBadges: readonly string[];
+ readonly resultSummary: string | null;
+ readonly callDetail:
+ | { readonly kind: "command"; readonly text: string }
+ | {
+ readonly kind: "edit";
+ readonly path: string;
+ readonly oldText: string;
+ readonly newText: string;
+ }
+ | {
+ readonly kind: "fields";
+ readonly fields: readonly {
+ readonly key: string;
+ readonly value: string;
+ readonly multiline: boolean;
+ }[];
+ };
+ readonly output: ToolOutputView | null;
+}
+
+export type ToolOutputView =
+ | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean }
+ | {
+ readonly kind: "elided";
+ readonly head: readonly string[];
+ readonly hiddenCount: number;
+ readonly tail: readonly string[];
+ readonly all: readonly string[];
+ readonly isError: boolean;
+ };
+
+type Args = Readonly<Record<string, unknown>>;
+type CallSummary = (args: Args) => string;
+
+function oneLine(text: string, cap = 120): string {
+ const compact = text.replace(/\s+/g, " ").trim();
+ return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact;
+}
+
+function normalize(input: unknown): unknown {
+ if (typeof input !== "string") return input;
+ try {
+ return JSON.parse(input);
+ } catch {
+ return input;
+ }
+}
+
+function asArgs(input: unknown): Args {
+ return typeof input === "object" && input !== null && !Array.isArray(input)
+ ? (input as Args)
+ : {};
+}
+
+function serialize(input: unknown, pretty = false): string {
+ if (typeof input === "string") return input;
+ try {
+ return JSON.stringify(input, null, pretty ? 2 : undefined) ?? String(input);
+ } catch {
+ return "[unserializable input]";
+ }
+}
+
+function arg(args: Args, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = Object.hasOwn(args, key) ? args[key] : undefined;
+ if (typeof value === "string" && value.trim()) return value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ }
+ return "";
+}
+
+function lineCount(text: string): number {
+ return text === "" ? 0 : outputLines(text).length;
+}
+
+/** Drop one final newline terminator, not meaningful blank lines within the output. */
+function outputLines(content: string): string[] {
+ const normalized = content.replace(/\r\n/g, "\n");
+ return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n");
+}
+
+function readSummary(args: Args): string {
+ const path = arg(args, "path");
+ const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1;
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null;
+ return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`;
+}
+
+function lspSummary(args: Args): string {
+ const line = arg(args, "line");
+ const character = arg(args, "character");
+ return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`;
+}
+
+function todoSummary(args: Args): string {
+ if (!Array.isArray(args.todos)) return "";
+ const done = args.todos.filter((t) => asArgs(t).status === "completed").length;
+ return `${args.todos.length} tasks · ${done} completed`;
+}
+
+const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model");
+const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message");
+
+/** Optional built-in polish; all unknown/MCP names use the same generic path. */
+const summaries: Readonly<Record<string, CallSummary>> = {
+ run_shell: (args) => arg(args, "command"),
+ read_file: readSummary,
+ edit_file: (args) => arg(args, "path"),
+ write_file: (args) => arg(args, "path"),
+ web_search: (args) => arg(args, "query", "url"),
+ lsp: lspSummary,
+ todo_write: todoSummary,
+ load_skill: (args) => arg(args, "name"),
+ consult_vision: (args) => arg(args, "question", "path"),
+ youtube_transcript: (args) => arg(args, "url"),
+ summon_agent: summarizeAgent,
+ send_agent_message: summarizeConversation,
+ read_agent: summarizeConversation,
+ open_agent: summarizeConversation,
+ cancel_agent_message: summarizeConversation,
+ list_agents: (args) => arg(args, "query", "relationship", "workspaceId"),
+ list_agent_models: (args) => arg(args, "query"),
+};
+
+function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] {
+ if (name === "run_shell" && typeof args.command === "string") {
+ return { kind: "command", text: args.command };
+ }
+ if (name === "edit_file" && typeof args.path === "string") {
+ return {
+ kind: "edit",
+ path: args.path,
+ oldText: typeof args.oldString === "string" ? args.oldString : "",
+ newText: typeof args.newString === "string" ? args.newString : "",
+ };
+ }
+ const entries = Object.entries(args);
+ return {
+ kind: "fields",
+ fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => {
+ const text = serialize(value, true);
+ return { key: String(key), value: text, multiline: text.includes("\n") };
+ }),
+ };
+}
+
+function bytesLabel(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function responseSummary(name: string, content: string, isError: boolean): string {
+ const lines = outputLines(content);
+ const first = oneLine(lines.find((line) => line.trim()) ?? "", 72);
+ const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`;
+ const timeout = /\[Command timed out\]/i.test(content);
+ const truncated = /\[Output truncated[^\]]*\]/i.test(content);
+ let summary: string;
+ if (timeout) summary = "timed out";
+ else if (isError)
+ summary = `failed${lines.length > 1 ? ` · ${count}` : first ? ` · ${first}` : ""}`;
+ else if (!content.trim()) summary = "no output";
+ else if (/^\(empty file\b/i.test(content)) summary = "empty file";
+ else {
+ const replaced =
+ name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null;
+ const written =
+ name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null;
+ if (replaced) {
+ summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`;
+ if (content.trim().includes("\n")) summary += " · diagnostics";
+ } else if (written) {
+ summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`;
+ } else if (
+ name === "run_shell" ||
+ name === "read_file" ||
+ lines.length > 1 ||
+ first.length > 60
+ ) {
+ summary = count;
+ } else summary = first;
+ }
+ return oneLine(`${summary}${truncated ? " · truncated" : ""}`, 96);
+}
+
+export function viewToolEntry(entry: ToolEntry): ToolCardView {
+ const input = normalize(entry.call?.input);
+ const args = asArgs(input);
+ const summarize = Object.hasOwn(summaries, entry.toolName)
+ ? summaries[entry.toolName]
+ : undefined;
+ const generic = arg(
+ args,
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ );
+ const builtInSummary = summarize?.(args).trim();
+ const callSummary =
+ entry.call === null
+ ? "call unavailable"
+ : oneLine(builtInSummary || generic || serialize(input));
+ const callBadges: string[] = [];
+ if (entry.toolName === "edit_file" && entry.call !== null) {
+ if (typeof args.oldString === "string" && typeof args.newString === "string") {
+ callBadges.push(`+${lineCount(args.newString)} −${lineCount(args.oldString)}`);
+ }
+ }
+ const result = entry.result;
+ const lines = result === null ? [] : outputLines(result.content);
+ return {
+ name: entry.toolName,
+ status: result === null ? "pending" : result.isError ? "error" : "ok",
+ callSummary,
+ callBadges,
+ callDetail:
+ entry.call === null
+ ? {
+ kind: "fields",
+ fields: [
+ {
+ key: "call",
+ value: "Call unavailable (earlier history unloaded)",
+ multiline: false,
+ },
+ ],
+ }
+ : detail(entry.toolName, input, args),
+ resultSummary:
+ result === null ? null : responseSummary(entry.toolName, result.content, result.isError),
+ output:
+ result === null
+ ? null
+ : lines.length <= 12
+ ? { kind: "full", lines, isError: result.isError }
+ : {
+ kind: "elided",
+ head: lines.slice(0, 6),
+ hiddenCount: lines.length - 12,
+ tail: lines.slice(-6),
+ all: lines,
+ isError: result.isError,
+ },
+ };
+}