import assert from "node:assert/strict"; import test from "node:test"; import type { ExtensionAPI, ToolRendererResolver, ToolRenderers } from "@selesai/code"; import { registerToolDisplayOverrides } from "../src/tool-overrides.ts"; import { resolveToolRenderers } from "./tool-renderer-harness.ts"; import { DEFAULT_TOOL_DISPLAY_CONFIG, type ToolDisplayConfig } from "../src/types.ts"; interface RenderThemeLike { fg(color: string, value: string): string; bold(value: string): string; } interface RenderComponentLike { render(width: number): string[]; } interface RenderCallContextLike { lastComponent?: unknown; state?: Record; invalidate(): void; executionStarted: boolean; isPartial: boolean; } interface RegisteredToolLike { name?: string; description?: string; parameters?: unknown; promptSnippet?: string; promptGuidelines?: string[]; renderCall?: (...args: unknown[]) => RenderComponentLike; renderResult?: (...args: unknown[]) => RenderComponentLike; } interface ToolEventHandlers { session_start?: () => Promise | void; before_agent_start?: () => Promise | void; } function buildConfig(overrides: Partial): ToolDisplayConfig { return { ...DEFAULT_TOOL_DISPLAY_CONFIG, ...overrides, boxedToolCalls: overrides.boxedToolCalls ?? false, registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides, ...overrides.registerToolOverrides, }, }; } function withDefaultReadEditOwners(tools: unknown[] = []): unknown[] { const names = new Set( tools .map((tool) => (tool as { name?: unknown }).name) .filter((name): name is string => typeof name === "string"), ); const defaults = ["read", "edit"] .filter((name) => !names.has(name)) .map((name) => ({ name, sourceInfo: { source: "builtin", path: `builtin:${name}` } })); return [...defaults, ...tools]; } function createExtensionApiStub(allTools: Array> = []): { api: ExtensionAPI; registeredTools: RegisteredToolLike[]; resolvers: ToolRendererResolver[]; resolveTool: (toolName: string) => ToolRenderers | undefined; runtimeTools: Array>; eventHandlers: ToolEventHandlers; } { const registeredTools: RegisteredToolLike[] = []; const resolvers: ToolRendererResolver[] = []; const eventHandlers: ToolEventHandlers = {}; const api = { registerTool(tool: RegisteredToolLike): void { registeredTools.push(tool); }, registerToolRenderer(resolver: ToolRendererResolver): void { resolvers.push(resolver); }, on(event: keyof ToolEventHandlers, handler: () => Promise | void): void { eventHandlers[event] = handler; }, getAllTools(): unknown[] { return withDefaultReadEditOwners(allTools); }, } as unknown as ExtensionAPI; return { api, registeredTools, resolvers, resolveTool: (toolName: string): ToolRenderers | undefined => resolveToolRenderers(resolvers, toolName), runtimeTools: allTools, eventHandlers, }; } async function runLifecycle(eventHandlers: ToolEventHandlers): Promise { await eventHandlers.session_start?.(); await eventHandlers.before_agent_start?.(); } function createTheme(): RenderThemeLike { return { fg: (_color: string, value: string): string => value, bold: (value: string): string => value, }; } function normalizeRenderedText(component: RenderComponentLike): string { return component .render(120) .map((line) => line.trimEnd()) .join("\n") .trim(); } function renderToolResult( tool: RegisteredToolLike | undefined, input: | string | { text: string; details?: unknown; expanded?: boolean; isPartial?: boolean; isError?: boolean; }, ): string { assert.ok(tool?.renderResult, `expected renderResult for tool '${tool?.name ?? "unknown"}'`); const payload = typeof input === "string" ? { text: input } : input; return normalizeRenderedText( tool.renderResult( { content: [{ type: "text", text: payload.text }], details: payload.details ?? {}, isError: payload.isError ?? false, }, { isPartial: payload.isPartial ?? false, expanded: payload.expanded ?? false }, createTheme(), ), ); } function renderToolCall( tool: RegisteredToolLike | undefined, args: { command: string; timeout?: number }, contextOverrides: Partial = {}, ): { output: string; component: RenderComponentLike; context: RenderCallContextLike } { assert.ok(tool?.renderCall, `expected renderCall for tool '${tool?.name ?? "unknown"}'`); const context: RenderCallContextLike = { lastComponent: contextOverrides.lastComponent, state: contextOverrides.state ?? {}, invalidate: contextOverrides.invalidate ?? (() => {}), executionStarted: contextOverrides.executionStarted ?? false, isPartial: contextOverrides.isPartial ?? false, }; const component = tool.renderCall(args, createTheme(), context); return { output: normalizeRenderedText(component), component, context, }; } test("current local-style config keeps read/search/MCP output modes distinct", async () => { const config = buildConfig({ readOutputMode: "summary", searchOutputMode: "count", mcpOutputMode: "summary", }); const { api, registeredTools, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await runLifecycle(eventHandlers); const mcpTool = resolveTool("mcp__test__tool"); assert.equal(registeredTools.length, 0); for (const toolName of ["read", "grep", "find", "ls", "bash", "powershell", "edit", "write"]) { assert.ok(resolveTool(toolName), `expected renderers for ${toolName}`); } assert.ok(mcpTool?.renderResult); assert.equal( renderToolResult(resolveTool("read"), "alpha\nbeta\n"), "↳ loaded 2 lines • Ctrl+O to expand", ); assert.equal( renderToolResult(resolveTool("grep"), "a.txt:1\nb.txt:2\n"), "↳ 2 matches returned • Ctrl+O to expand", ); assert.equal( renderToolResult(resolveTool("find"), "a.txt\nb.txt\n"), "↳ 2 results returned • Ctrl+O to expand", ); assert.equal( renderToolResult(resolveTool("ls"), "a.txt\nb.txt\n"), "↳ 2 entries returned • Ctrl+O to expand", ); assert.equal( renderToolResult(mcpTool, "one\ntwo\n"), "↳ 2 lines returned • Ctrl+O to expand", ); assert.equal( renderToolResult(resolveTool("read"), { text: "alpha\nbeta\n", expanded: true, }), "alpha\nbeta", ); assert.equal( renderToolResult(resolveTool("grep"), { text: "a.txt:1\nb.txt:2\n", expanded: true, }), "a.txt:1\nb.txt:2", ); assert.equal( renderToolResult(mcpTool, { text: "one\ntwo\n", expanded: true, }), "one\ntwo", ); }); test("MCP prompt metadata and execution identity stay owned by the tool definition", async () => { const mcpTool = { name: "mcp__exa__web_search_exa", label: "exa/web_search_exa", description: "Search the web for current information. Direct MCP wrapper for 'exa:web_search_exa'. Common args: query*.", parameters: {}, execute(): void { // No-op test stub. }, }; const before = JSON.stringify(mcpTool); const { api, resolveTool, eventHandlers } = createExtensionApiStub([mcpTool]); registerToolDisplayOverrides(api, () => DEFAULT_TOOL_DISPLAY_CONFIG); await runLifecycle(eventHandlers); assert.ok(resolveTool("mcp__exa__web_search_exa")?.renderCall); assert.equal(JSON.stringify(mcpTool), before, "the extension never rewrites prompt metadata or execution"); }); test("read-only ownership keeps summary line counts confined to read", async () => { const config = buildConfig({ registerToolOverrides: { read: true, grep: false, find: false, ls: false, bash: false, powershell: false, edit: false, write: false, }, readOutputMode: "summary", }); const { api, registeredTools, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await eventHandlers.before_agent_start?.(); assert.equal(registeredTools.length, 0); assert.ok(resolveTool("read")); assert.equal(resolveTool("grep"), undefined); assert.equal( renderToolResult(resolveTool("read"), "single line\n"), "↳ loaded 1 line • Ctrl+O to expand", ); }); test("showTruncationHints=false suppresses backend truncation summaries across read/search/MCP modes", async () => { const config = buildConfig({ readOutputMode: "summary", searchOutputMode: "count", mcpOutputMode: "summary", showTruncationHints: false, }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await runLifecycle(eventHandlers); const mcpTool = resolveTool("mcp__test__tool"); assert.equal( renderToolResult(resolveTool("read"), { text: "alpha\n", details: { truncation: { truncated: true } }, }), "↳ loaded 1 line • Ctrl+O to expand", ); assert.equal( renderToolResult(resolveTool("grep"), { text: "a.txt:1\n", details: { truncation: { truncated: true } }, }), "↳ 1 match returned • Ctrl+O to expand", ); assert.equal( renderToolResult(mcpTool, { text: "alpha\n", details: { truncation: { truncated: true } }, }), "↳ 1 line returned • Ctrl+O to expand", ); }); test("showRtkCompactionHints stays independent from showTruncationHints for summary modes", async () => { const config = buildConfig({ readOutputMode: "summary", searchOutputMode: "count", mcpOutputMode: "summary", showTruncationHints: false, showRtkCompactionHints: true, }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); const rtkDetails = { rtkCompaction: { applied: true, techniques: ["trimmed context"], }, }; registerToolDisplayOverrides(api, () => config); await runLifecycle(eventHandlers); const mcpTool = resolveTool("mcp__test__tool"); assert.match( renderToolResult(resolveTool("read"), { text: "alpha\n", details: rtkDetails, }), /compacted by RTK • trimmed context/, ); assert.match( renderToolResult(resolveTool("grep"), { text: "a.txt:1\n", details: rtkDetails, }), /compacted by RTK • trimmed context/, ); assert.match( renderToolResult(mcpTool, { text: "alpha\n", details: rtkDetails, }), /compacted by RTK • trimmed context/, ); }); test("showRtkCompactionHints stays independent from showTruncationHints for preview modes", async () => { const config = buildConfig({ readOutputMode: "preview", searchOutputMode: "preview", mcpOutputMode: "preview", previewLines: 1, showTruncationHints: false, showRtkCompactionHints: true, }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); const rtkDetails = { rtkCompaction: { applied: true, techniques: ["trimmed context"], originalLineCount: 10, compactedLineCount: 1, }, }; registerToolDisplayOverrides(api, () => config); await runLifecycle(eventHandlers); const mcpTool = resolveTool("mcp__test__tool"); assert.match( renderToolResult(resolveTool("read"), { text: "alpha\nbeta\n", details: rtkDetails, }), /compacted by RTK: trimmed context • 1\/10 lines kept/, ); assert.match( renderToolResult(resolveTool("grep"), { text: "a.txt:1\nb.txt:2\n", details: rtkDetails, }), /compacted by RTK: trimmed context • 1\/10 lines kept/, ); assert.match( renderToolResult(mcpTool, { text: "alpha\nbeta\n", details: rtkDetails, }), /compacted by RTK: trimmed context • 1\/10 lines kept/, ); }); test("bash output modes stay distinct across opencode, summary, and preview", async () => { const output = "alpha\nbeta\ngamma\n"; const opencodeConfig = buildConfig({ bashOutputMode: "opencode", bashCollapsedLines: 1, }); const opencodeStub = createExtensionApiStub(); registerToolDisplayOverrides(opencodeStub.api, () => opencodeConfig); await opencodeStub.eventHandlers.before_agent_start?.(); assert.equal( renderToolResult(opencodeStub.resolveTool("bash"), output), "alpha\n... (2 more lines • Ctrl+O to expand)", ); const summaryConfig = buildConfig({ bashOutputMode: "summary", bashCollapsedLines: 1, }); const summaryStub = createExtensionApiStub(); registerToolDisplayOverrides(summaryStub.api, () => summaryConfig); await summaryStub.eventHandlers.before_agent_start?.(); assert.equal( renderToolResult(summaryStub.resolveTool("bash"), output), "↳ 3 lines returned • Ctrl+O to expand", ); assert.equal( renderToolResult(summaryStub.resolveTool("bash"), { text: output, expanded: true, }), "alpha\nbeta\ngamma", ); const previewConfig = buildConfig({ bashOutputMode: "preview", previewLines: 2, bashCollapsedLines: 1, }); const previewStub = createExtensionApiStub(); registerToolDisplayOverrides(previewStub.api, () => previewConfig); await previewStub.eventHandlers.before_agent_start?.(); assert.equal( renderToolResult(previewStub.resolveTool("bash"), output), "alpha\nbeta\n... (1 more line • Ctrl+O to expand)", ); }); test("bash call spinner appears only while execution is active", async () => { const config = buildConfig({ bashOutputMode: "summary", }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await eventHandlers.before_agent_start?.(); const bashTool = resolveTool("bash"); const idle = renderToolCall(bashTool, { command: "npm test" }); assert.equal(idle.output, "$ npm test"); let invalidateCount = 0; const running = renderToolCall( bashTool, { command: "npm test" }, { state: {}, executionStarted: true, isPartial: true, invalidate: () => { invalidateCount++; }, }, ); assert.match(running.output, /^⠋ \$ npm test · 0s$/); await new Promise((resolve) => setTimeout(resolve, 800)); const animatedFrame = normalizeRenderedText(running.component); assert.notEqual(animatedFrame, running.output); assert.match(animatedFrame, /^⠙ \$ npm test · 0s$/); assert.ok(invalidateCount > 0); const complete = renderToolCall( bashTool, { command: "npm test" }, { state: running.context.state, lastComponent: running.component, executionStarted: true, isPartial: false, }, ); assert.equal(complete.output, "$ npm test"); }); test("bash render keeps the running result area empty until output exists", async () => { const config = buildConfig({ bashOutputMode: "summary", }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await eventHandlers.before_agent_start?.(); const bashTool = resolveTool("bash"); assert.equal( renderToolResult(bashTool, { text: "", isPartial: true }), "", ); }); test("bash render shows live partial output once streaming begins", async () => { const config = buildConfig({ bashOutputMode: "summary", previewLines: 2, }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await eventHandlers.before_agent_start?.(); const bashTool = resolveTool("bash"); assert.equal( renderToolResult(bashTool, { text: "alpha\nbeta\ngamma\n", isPartial: true, }), "alpha\nbeta\n... (1 more line • Ctrl+O to expand)", ); }); test("bash live partial output respects opencode collapse settings", async () => { const config = buildConfig({ bashOutputMode: "opencode", bashCollapsedLines: 1, previewLines: 4, }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await eventHandlers.before_agent_start?.(); const bashTool = resolveTool("bash"); assert.equal( renderToolResult(bashTool, { text: "alpha\nbeta\ngamma\n", isPartial: true, }), "alpha\n... (2 more lines • Ctrl+O to expand)", ); }); test("bash errors render with an explicit failure header and preview", async () => { const config = buildConfig({ bashOutputMode: "summary", previewLines: 2, }); const { api, resolveTool, eventHandlers } = createExtensionApiStub(); registerToolDisplayOverrides(api, () => config); await eventHandlers.before_agent_start?.(); const bashTool = resolveTool("bash"); assert.equal( renderToolResult(bashTool, { text: "npm ERR! missing script: test\nSee npm help run-script\n", isError: true, }), "↳ command failed\nnpm ERR! missing script: test\nSee npm help run-script", ); });