import { Text, type TUI } from "@jaeswift/jae-tui"; import { Type } from "@sinclair/typebox"; import stripAnsi from "strip-ansi"; import { beforeAll, describe, expect, test } from "vitest"; import type { ToolDefinition } from "../src/core/extensions/types.js"; import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.js"; import { createReadToolDefinition } from "../src/core/tools/read.js"; import { createWriteToolDefinition } from "../src/core/tools/write.js"; import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; import { initTheme } from "../src/modes/interactive/theme/theme.js"; function createBaseToolDefinition(name = "custom_tool"): ToolDefinition { return { name, label: name, description: "custom tool", parameters: Type.Any(), execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {}, }), }; } function createFakeTui(): TUI { return { requestRender: () => {}, } as unknown as TUI; } describe("ToolExecutionComponent parity", () => { beforeAll(() => { initTheme("dark"); }); test("stacks custom call and result renderers like the old implementation", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), renderCall: () => new Text("custom call", 0, 0), renderResult: () => new Text("custom result", 0, 0), }; const component = new ToolExecutionComponent("custom_tool", "tool-1", {}, {}, toolDefinition, createFakeTui()); expect(stripAnsi(component.render(120).join("\n"))).toContain("custom call"); component.updateResult( { content: [{ type: "text", text: "done" }], details: {}, isError: false, }, false, ); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("custom call"); expect(rendered).toContain("custom result"); }); test("uses built-in rendering for built-in overrides without custom renderers", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("edit"), }; const component = new ToolExecutionComponent( "edit", "tool-2", { path: "README.md", oldText: "before", newText: "after" }, {}, overrideDefinition, createFakeTui(), ); component.updateResult({ content: [], details: { diff: "+1 after", firstChangedLine: 1 }, isError: false }); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("edit"); expect(rendered).toContain("README.md"); expect(rendered).not.toContain(":1"); }); test("preserves legacy file_path rendering compatibility for built-in tools", () => { const component = new ToolExecutionComponent( "read", "tool-3", { file_path: "README.md" }, {}, undefined, createFakeTui(), ); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("read"); expect(rendered).toContain("README.md"); }); test("bash execute emits an initial empty partial update before output arrives", async () => { const updates: Array<{ content: Array<{ type: string; text?: string }>; details?: unknown }> = []; const operations: BashOperations = { exec: async () => { await new Promise((resolve) => setTimeout(resolve, 10)); return { exitCode: 0 }; }, }; const tool = createBashToolDefinition(process.cwd(), { operations }); const promise = tool.execute( "tool-bash-1", { command: "sleep 10" }, undefined, (update) => updates.push(update as { content: Array<{ type: string; text?: string }>; details?: unknown }), {} as never, ); expect(updates).toEqual([{ content: [], details: undefined }]); await promise; }); test("does not duplicate built-in headers when passed the active built-in definition", () => { const component = new ToolExecutionComponent( "read", "tool-4", { path: "README.md" }, {}, createReadToolDefinition(process.cwd()), createFakeTui(), ); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered.match(/\bread\b/g)?.length ?? 0).toBe(1); }); test("inherits missing built-in result renderer slot from the built-in tool", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("read"), renderCall: () => new Text("override call", 0, 0), }; const component = new ToolExecutionComponent( "read", "tool-4b", { path: "README.md" }, {}, overrideDefinition, createFakeTui(), ); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("override call"); expect(rendered).toContain("hello"); }); test("inherits missing built-in call renderer slot from the built-in tool", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("read"), renderResult: () => new Text("override result", 0, 0), }; const component = new ToolExecutionComponent( "read", "tool-4c", { path: "README.md" }, {}, overrideDefinition, createFakeTui(), ); component.updateResult({ content: [{ type: "text", text: "hello" }], details: undefined, isError: false }, false); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("read"); expect(rendered).toContain("README.md"); expect(rendered).toContain("override result"); }); test("shares renderer state across custom call and result slots", () => { type RenderState = { token?: string }; const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), renderCall: (_args, _theme, context) => { context.state.token ??= "shared-token"; return new Text(`custom call ${context.state.token}`, 0, 0); }, renderResult: (_result, _options, _theme, context) => { return new Text(`custom result ${context.state.token}`, 0, 0); }, }; const component = new ToolExecutionComponent("custom_tool", "tool-5", {}, {}, toolDefinition, createFakeTui()); component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("custom call shared-token"); expect(rendered).toContain("custom result shared-token"); }); test("exposes args in render result context", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), renderCall: () => new Text("call", 0, 0), renderResult: (_result, _options, _theme, context) => new Text(`arg:${String((context.args as { foo: string }).foo)}`, 0, 0), }; const component = new ToolExecutionComponent( "custom_tool", "tool-5b", { foo: "bar" }, {}, toolDefinition, createFakeTui(), ); component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("arg:bar"); }); test("falls back when custom renderers are absent", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), }; const component = new ToolExecutionComponent( "custom_tool", "tool-6", { foo: "bar" }, {}, toolDefinition, createFakeTui(), ); component.updateResult({ content: [{ type: "text", text: "done" }], details: {}, isError: false }, false); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("custom_tool"); expect(rendered).toContain("done"); }); test("trims trailing blank display lines from write previews", () => { const component = new ToolExecutionComponent( "write", "tool-7", { path: "README.md", content: "one\ntwo\n" }, {}, createWriteToolDefinition(process.cwd()), createFakeTui(), ); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("one"); expect(rendered).toContain("two"); expect(rendered).not.toContain("two\n\n"); }); test("trims trailing blank display lines from read results", () => { const component = new ToolExecutionComponent( "read", "tool-8", { path: "README.md" }, {}, createReadToolDefinition(process.cwd()), createFakeTui(), ); component.updateResult( { content: [{ type: "text", text: "one\ntwo\n" }], details: undefined, isError: false }, false, ); const rendered = stripAnsi(component.render(120).join("\n")); expect(rendered).toContain("one"); expect(rendered).toContain("two"); expect(rendered).not.toContain("two\n\n"); }); });