import { describe, expect, test } from "bun:test"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Box, visibleWidth } from "@earendil-works/pi-tui"; import { renderMcpToolCall, renderMcpToolResult } from "./rendering.ts"; const WIDTH = 48; const THEME = { bold: (value: string) => value, fg: (_name: string, value: string) => value, }; const MARKED_THEME = { bold: (value: string) => `${value}`, fg: (name: string, value: string) => `<${name}>${value}`, }; describe("mcp-wrapper rendering", () => { test("renders call rows with the Pi tool name", () => { const component = new Box(1, 1); component.addChild( renderMcpToolCall( "fetch_fetch", { url: "https://pi.dev/docs/latest/extensions" }, THEME as never, ), ); const output = component.render(WIDTH).join("\n"); expect(output).toContain("fetch_fetch:"); expect(output).not.toContain("MCP:"); for (const line of component.render(WIDTH)) { expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH); } }); test("dims call arguments while keeping the Pi tool name prominent", () => { const output = renderMcpToolCall( "fetch_fetch", { url: "https://pi.dev/docs/latest/extensions" }, MARKED_THEME as never, ) .render(120) .join("\n"); expect(output).toStartWith( "fetch_fetch: ", ); expect(output).toContain( '{"url":"https://pi.dev/docs/latest/extensions"}', ); }); test("wraps long call arguments within the default Pi tool shell width", () => { const component = new Box(1, 1); component.addChild( renderMcpToolCall( "team_message_create", { topic_id: "17537fcd-7230-49bf-ab1d-a78b5ff4ab30", title: "Audit task context", content: "Task: audit implementation against pricing rules", }, THEME as never, { expanded: true } as never, ), ); const lines = component.render(WIDTH); const output = lines.join("\n"); expect(lines.length).toBeGreaterThan(3); expect(output).toContain("team_message_create:"); expect(output).toContain("pricing rules"); expect(output).not.toContain("…"); for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH); } }); test("counts wrapped call arguments against the call preview line budget", () => { // Purpose: mcp-wrapper call rendering must not let huge wrapped arguments consume unbounded TUI height. // Input and expected output: huge arguments are capped when collapsed and show a standard hidden-line hint. // Edge case: the limit is checked through the default Box shell, whose padding adds two outer rows. // Dependencies: this test uses the public Pi Box shell and visible-width measurement. const component = new Box(1, 1); component.addChild( renderMcpToolCall( "team_message_create", { content: Array.from( { length: 80 }, (_, index) => `argument-token-${index}`, ).join(" "), }, THEME as never, ), ); const lines = component.render(WIDTH); const output = lines.join("\n"); expect(lines).toHaveLength(6); expect(output).toContain("team_message_create:"); expect(output).toContain("more lines"); expect(output).toContain("total"); expect(output).toContain("to expand"); expect(output).not.toContain("argument-token-79"); for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH); } }); test("shows full wrapped call arguments when the call header is expanded", () => { // Purpose: mcp-wrapper must not lose hidden argument text when the user expands the call header. // Input and expected output: huge arguments are capped when collapsed and fully visible when expanded. // Edge case: the final token is beyond the collapsed line budget. // Dependencies: this test uses the renderCall expanded flag from Pi's ToolRenderContext. const args = { content: Array.from( { length: 80 }, (_, index) => `argument-token-${index}`, ).join(" "), }; const collapsedLines = renderMcpToolCall( "team_message_create", args, THEME as never, { expanded: false } as never, ).render(WIDTH); const expandedLines = renderMcpToolCall( "team_message_create", args, THEME as never, { expanded: true } as never, ).render(WIDTH); expect(collapsedLines.join("\n")).toContain("more lines"); expect(collapsedLines.join("\n")).not.toContain("argument-token-79"); expect(expandedLines.length).toBeGreaterThan(collapsedLines.length); expect(expandedLines.join("")).toContain("argument-token-79"); expect(expandedLines.join("\n")).not.toContain("more lines"); for (const line of expandedLines) { expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH); } }); test("normalizes collapsed JSON call arguments without changing expanded arguments", () => { // Purpose: compact MCP calls must hide formatting controls without corrupting literal backslashes. // Input and expected output: collapsed source text becomes one spaced line while expanded JSON retains its escaped formatting. // Edge case: Windows-style paths and regular expressions contain literal backslash sequences that must remain unchanged. // Dependencies: the public MCP call renderer owns both collapsed and expanded presentation. const args = { body: "first\n\tsecond third\r\nfourth", path: String.raw`C:\temp\new`, regex: String.raw`\n+`, }; const collapsed = renderMcpToolCall( "asteria_find_symbol", args, THEME as never, { expanded: false } as never, ) .render(240) .join("\n"); const expanded = renderMcpToolCall( "asteria_find_symbol", args, THEME as never, { expanded: true } as never, ) .render(240) .join("\n"); expect(collapsed).toContain('"body":"first second third fourth"'); expect(collapsed).toContain(String.raw`"path":"C:\\temp\\new"`); expect(collapsed).toContain(String.raw`"regex":"\\n+"`); expect(collapsed).not.toContain(String.raw`first\n\tsecond`); expect(expanded).toContain(String.raw`first\n\tsecond third\r\nfourth`); }); test("normalizes collapsed JSON results without changing data lexemes", () => { // Purpose: serialized MCP results must become readable previews without changing their represented data. // Input and expected output: JSON string controls collapse while literal escapes and a large integer remain exact. // Edge case: parsing and reserializing the complete JSON value would round the integer beyond JavaScript's safe range. // Dependencies: the public result renderer receives the original MCP text and the expansion state. const text = String.raw`{"symbols":[{"body":"first\n\tsecond third\r\nfourth","path":"C:\\temp\\new","regex":"\\n+","id":9007199254740993}]}`; const result: AgentToolResult = { content: [{ type: "text", text }], details: {}, }; const collapsed = renderMcpToolResult( result, { expanded: false }, THEME as never, { widgetLineBudget: 5 }, ) .render(240) .join("\n"); const expanded = renderMcpToolResult( result, { expanded: true }, THEME as never, { widgetLineBudget: 5 }, ) .render(240) .join("\n"); expect(collapsed).toContain('"body":"first second third fourth"'); expect(collapsed).toContain(String.raw`"path":"C:\\temp\\new"`); expect(collapsed).toContain(String.raw`"regex":"\\n+"`); expect(collapsed).toContain("9007199254740993"); expect(collapsed).not.toContain(String.raw`first\n\tsecond`); expect(expanded).toContain(String.raw`first\n\tsecond third\r\nfourth`); }); test("renders collapsed successful result with a prominent TUI-only header", () => { const result: AgentToolResult = { content: [{ type: "text", text: "result text" }], details: {}, }; expect( renderMcpToolResult(result, {}, MARKED_THEME as never, { widgetLineBudget: 5, }) .render(WIDTH) .join("\n"), ).toBe( "Result: result text", ); }); test("keeps collapsed error result text styled as error", () => { const result: AgentToolResult = { content: [{ type: "text", text: "error text" }], details: {}, }; expect( renderMcpToolResult(result, {}, MARKED_THEME as never, { isError: true, widgetLineBudget: 5, }) .render(WIDTH) .join("\n"), ).toBe( "Result: error text", ); }); test("renders collapsed result with bounded preview and segmented expand hint colors", () => { // Purpose: the visual-line budget must apply after collapsed whitespace normalization and Pi wrapping. // Input and expected output: normalized long text still produces a bounded preview and segmented expansion hint. // Edge case: source newlines no longer define the preview line count. // Dependencies: the public result renderer and Pi's width-aware text wrapping. const result: AgentToolResult = { content: [ { type: "text", text: Array.from( { length: 20 }, (_, index) => `line ${index} ${"content ".repeat(20)}`, ).join("\n"), }, ], details: {}, }; const markedOutput = renderMcpToolResult( result, {}, MARKED_THEME as never, { widgetLineBudget: 2 }, ) .render(200) .join("\n"); const component = new Box(1, 1); component.addChild( renderMcpToolResult(result, {}, THEME as never, { widgetLineBudget: 2 }), ); const lines = component.render(WIDTH); expect(lines.length).toBeLessThanOrEqual(5); expect(markedOutput).toContain("... ("); expect(markedOutput).toContain("more lines"); expect(markedOutput).toContain("total, "); expect(markedOutput).toContain(" to expand)"); for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH); } }); test("renders expanded full result content with a prominent TUI-only header", () => { const result: AgentToolResult = { content: [{ type: "text", text: "full result" }], details: {}, }; const lines = renderMcpToolResult( result, { expanded: true }, MARKED_THEME as never, { widgetLineBudget: 5 }, ).render(WIDTH); const output = lines.join("\n"); expect(output).toContain("Result:"); expect(output).toContain("full result"); }); });