diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 486510b2a..81502f12e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `oauth.clientRegistration: "cimd"` for MCP servers, which identifies pi with its Client ID Metadata Document on pi.dev instead of dynamic client registration, so authorization servers can allow pi by URL ([#10302](https://github.com/earendil-works/pi/issues/10302)) - Added project overrides for user-level MCP servers: a `.pi/mcp.json` entry without `command` or `url` sets only `enabled`, `exposure`, and `toolExposure` of the user-level server, and `/mcp` can enable or disable a server for the current project ([#10277](https://github.com/earendil-works/pi/issues/10277)) - Added Cloudflare's Clef and Clef Flash classifier models to `cloudflare-workers-ai`, usable from codemode scripts and extensions ([#10316](https://github.com/earendil-works/pi/pull/10316) by [@ndisidore](https://github.com/ndisidore), [#10322](https://github.com/earendil-works/pi/pull/10322) by [@RealAlexandreAI](https://github.com/RealAlexandreAI)) +- Added `pi.registerToolRenderer()`, which chooses how calls to a tool are drawn, including tools that are not registered ([#10285](https://github.com/earendil-works/pi/issues/10285)) ### Changed @@ -23,6 +24,7 @@ - Fixed a trailing comma in `--models` adding an extra model to the model cycle ([#10334](https://github.com/earendil-works/pi/issues/10334)) - Fixed a `codemode` script that prints in a loop crashing pi by running out of memory: a script fails once its output passes 16 Mi characters or 100000 items ([#10283](https://github.com/earendil-works/pi/issues/10283)) - Fixed JPEG, GIF, and WebP images rendered by extensions through `Image` not appearing in Kitty, Ghostty, WezTerm, and Warp ([#10292](https://github.com/earendil-works/pi/issues/10292)) +- Fixed MCP tool calls in resumed sessions and HTML exports rendering fully expanded until their server connected, or for good if it never did ([#10285](https://github.com/earendil-works/pi/issues/10285)) ## [1.0.0] - 2026-10-01 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 9ecc4d995..e389d0338 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -185,6 +185,10 @@ Register every tool first, keep optional tools inactive, and use `pi.setActiveTo Pi records the initial prompt and tool set in the transcript's first system message, then appends tool and prompt changes before the next model request. Providers that cannot represent the transition receive a complete transcript checkpoint, which can invalidate the cached prefix. +### Tool rendering + +A tool's `renderCall` and `renderResult` draw its calls in the interactive transcript and in HTML exports. `pi.registerToolRenderer((toolName, next) => renderers)` chooses renderers for calls to any tool, including tools that are not registered yet, such as MCP tools in a resumed session before their server connected. `next()` returns what the remaining resolvers (in extension load order), then the registered tool, would use, so `next() ?? mine` only fills in. + ### MCP servers `pi.registerMcpServer(name, config)` adds an MCP server for the current session. `config` has the shape of an `mcpServers` entry in [`mcp.json`](mcp.md): `command`, `args`, `env`, and `cwd` for stdio servers, `url`, `headers`, and `oauth` for HTTP servers, plus `exposure`, `toolExposure`, `description`, `enabled`, and `timeout`. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 63aa588da..f641d6ec9 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4243,7 +4243,8 @@ export class AgentSession { // Create tool renderer if we have an extension runner (for custom tool HTML rendering) const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({ - getToolDefinition: (name) => this.getToolDefinition(name), + getToolRenderers: (name) => + this._extensionRunner.resolveToolRenderers(name, () => this.getToolDefinition(name)), theme, cwd: this.sessionManager.getCwd(), }); diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts index c2030a271..abcf4cfd9 100644 --- a/packages/coding-agent/src/core/export-html/tool-renderer.ts +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -8,12 +8,12 @@ import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type { Component } from "@earendil-works/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.ts"; -import type { ToolDefinition, ToolRenderContext } from "../extensions/types.ts"; +import type { ToolRenderContext, ToolRenderers } from "../extensions/types.ts"; import { ansiLinesToHtml } from "./ansi-to-html.ts"; export interface ToolHtmlRendererDeps { - /** Function to look up tool definition by name */ - getToolDefinition: (name: string) => ToolDefinition | undefined; + /** Renderers of calls to a tool, as resolved by extensions and the registered tool */ + getToolRenderers: (name: string) => ToolRenderers | undefined; /** Theme for styling */ theme: Theme; /** Working directory for render context */ @@ -56,7 +56,7 @@ function trimRenderedResultLines(lines: string[]): string[] { } export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer { - const { getToolDefinition, theme, cwd, width = 100 } = deps; + const { getToolRenderers, theme, cwd, width = 100 } = deps; const renderedCallComponents = new Map(); const renderedResultComponents = new Map(); @@ -99,7 +99,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend renderCall(toolCallId: string, toolName: string, args: unknown): string | undefined { try { renderedArgs.set(toolCallId, args); - const toolDef = getToolDefinition(toolName); + const toolDef = getToolRenderers(toolName); if (!toolDef?.renderCall) { return undefined; } @@ -126,7 +126,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend isError: boolean, ): { collapsed?: string; expanded?: string } | undefined { try { - const toolDef = getToolDefinition(toolName); + const toolDef = getToolRenderers(toolName); if (!toolDef?.renderResult) { return undefined; } diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 4172d0186..aebd5c60d 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -187,6 +187,8 @@ export type { ToolLoadout, ToolLoadoutChanges, ToolNamespace, + ToolRendererResolver, + ToolRenderers, ToolRenderResultOptions, ToolResultEvent, ToolResultEventResult, diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index a35adbb86..bee134d35 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -33,6 +33,7 @@ import type { ProviderConfig, RegisteredCommand, ToolDefinition, + ToolRendererResolver, } from "./types.ts"; const require = createRequire(import.meta.url); @@ -363,6 +364,12 @@ function createExtensionAPI( extension.entryRenderers.set(customType, renderer as EntryRenderer); }, + registerToolRenderer(resolver: ToolRendererResolver): void { + assertActive(); + extension.toolRenderers ??= []; + extension.toolRenderers.push(resolver); + }, + // Flag access - checks extension registered it, reads from runtime getFlag(name: string): boolean | string | undefined { assertActive(); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 59e1eb62e..11ffaca7e 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -82,6 +82,7 @@ import type { SessionShutdownEvent, ToolCallEvent, ToolCallEventResult, + ToolRenderers, ToolResultEvent, ToolResultEventResult, TurnEndEvent, @@ -785,6 +786,14 @@ export class ExtensionRunner { return this.extensions.flatMap((ext) => (ext.markdownTransformer ? [ext.markdownTransformer] : [])); } + /** Renderers of calls to `toolName`: extension resolvers in load order, then `base`. */ + resolveToolRenderers(toolName: string, base: () => ToolRenderers | undefined): ToolRenderers | undefined { + const resolvers = this.extensions.flatMap((ext) => ext.toolRenderers ?? []); + const resolve = (index: number): ToolRenderers | undefined => + index < resolvers.length ? resolvers[index](toolName, () => resolve(index + 1)) : base(); + return resolve(0); + } + getEntryRenderer(customType: string): EntryRenderer | undefined { for (const ext of this.extensions) { const renderer = ext.entryRenderers?.get(customType); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 3e0ea47ba..7eb20f5ec 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -646,6 +646,17 @@ export interface ToolDefinition; +export type ToolRenderers = Pick; + +/** + * Chooses how calls to a tool are drawn, including tools that are not registered. `next()` returns + * the renderers the remaining resolvers, then the registered tool, would use. + */ +export type ToolRendererResolver = ( + toolName: string, + next: () => ToolRenderers | undefined, +) => ToolRenderers | undefined; + /** * Preserve parameter inference for standalone tool definitions. * @@ -1668,6 +1679,9 @@ export interface ExtensionAPI { /** Register a custom renderer for CustomEntry. Custom entries do not participate in LLM context. */ registerEntryRenderer(customType: string, renderer: EntryRenderer): void; + /** Choose how tool calls are drawn. Resolvers run in extension load order. */ + registerToolRenderer(resolver: ToolRendererResolver): void; + // ========================================================================= // Actions // ========================================================================= @@ -2217,6 +2231,7 @@ export interface Extension { handlers: Map; tools: Map; messageRenderers: Map; + toolRenderers?: ToolRendererResolver[]; markdownTransformer?: MarkdownTransformer; entryRenderers?: Map; commands: Map; diff --git a/packages/coding-agent/src/core/tools/renderers/index.ts b/packages/coding-agent/src/core/tools/renderers/index.ts index 6a88b7e8a..bd15d01de 100644 --- a/packages/coding-agent/src/core/tools/renderers/index.ts +++ b/packages/coding-agent/src/core/tools/renderers/index.ts @@ -6,7 +6,7 @@ * graph out of a process that only renders. */ -import type { ToolDefinition } from "../../extensions/types.ts"; +import type { ToolRenderers } from "../../extensions/types.ts"; import type { ToolName } from "../index.ts"; import { createShellRenderers } from "./bash.ts"; import { editRenderers } from "./edit.ts"; @@ -16,7 +16,7 @@ import { lsRenderers } from "./ls.ts"; import { readRenderers } from "./read.ts"; import { writeRenderers } from "./write.ts"; -export type ToolRenderers = Pick, "renderCall" | "renderResult">; +export type { ToolRenderers }; export { createShellRenderers, diff --git a/packages/coding-agent/src/extensions/mcp/index.ts b/packages/coding-agent/src/extensions/mcp/index.ts index 64ceb56c8..b2b8cb71d 100644 --- a/packages/coding-agent/src/extensions/mcp/index.ts +++ b/packages/coding-agent/src/extensions/mcp/index.ts @@ -61,7 +61,7 @@ import { import { loadMcpRuntime } from "./runtime.lazy.ts"; import type * as McpRuntime from "./runtime.ts"; import type { McpServerConnection, McpServerLog, McpTransportFactory } from "./runtime.ts"; -import { createMcpToolDefinition, createMcpToolName, type McpToolDetails } from "./tools.ts"; +import { createMcpToolDefinition, createMcpToolName, createMcpToolRenderers, type McpToolDetails } from "./tools.ts"; import { type McpMenu, type McpUi, showMcpManager } from "./ui.ts"; export type { McpTransportFactory } from "./runtime.ts"; @@ -359,6 +359,12 @@ export function createMcpExtension(options: McpExtensionOptions = {}): Extension /** Last definition registered under each tool name, to re-register withdrawn tools as hidden. */ const definitions = new Map>(); + // A resumed session renders calls to MCP tools before their server connected, if it ever does. + pi.registerToolRenderer((toolName, next) => { + const match = /^mcp__(.+?)__(.+)$/.exec(toolName); + return next() ?? (match ? createMcpToolRenderers(`${match[1]}/${match[2]}`) : undefined); + }); + const registerTools = (connection: McpServerConnection) => { const server = connection.entry.name; const entry = findServer(server)?.entry ?? connection.entry; diff --git a/packages/coding-agent/src/extensions/mcp/tools.ts b/packages/coding-agent/src/extensions/mcp/tools.ts index 979a7330c..4f38d9155 100644 --- a/packages/coding-agent/src/extensions/mcp/tools.ts +++ b/packages/coding-agent/src/extensions/mcp/tools.ts @@ -25,7 +25,13 @@ import { } from "@earendil-works/pi-mcp"; import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; -import type { ToolAnnotations, ToolDefinition, ToolExposure, ToolNamespace } from "../../core/extensions/types.ts"; +import type { + ToolAnnotations, + ToolDefinition, + ToolExposure, + ToolNamespace, + ToolRenderers, +} from "../../core/extensions/types.ts"; import { formatToolCallWithArgs, getTextOutput, replaceTabs } from "../../core/tools/render-utils.ts"; import { formatSize, truncateMiddle } from "../../core/tools/truncate.ts"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -275,6 +281,26 @@ export function createMcpToolDefinition(options: { exposure: toToolExposure(options.exposure), namespace: options.namespace, ...(annotations ? { annotations } : {}), + ...createMcpToolRenderers(label), + async execute(_toolCallId, params, signal, onUpdate) { + const client = await options.getClient(); + const result = await client.callTool(tool.name, (params ?? {}) as Record, { + signal, + timeoutMs: options.timeoutMs, + onProgress: (progress) => { + const total = progress.total === undefined ? "" : `/${progress.total}`; + const text = progress.message ?? `Progress ${progress.progress}${total}`; + onUpdate?.({ content: [{ type: "text", text }], details: { server, tool: tool.name } }); + }, + }); + return convertMcpResult(server, tool.name, result, { readableResources: options.readableResources?.() }); + }, + }; +} + +/** Renderers of calls to an MCP tool, labeled `server/tool`, also used before the tool is registered. */ +export function createMcpToolRenderers(label: string): ToolRenderers { + return { renderCall(args, theme, context) { const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); component.setText(formatToolCallWithArgs(label, args, theme, context.expanded)); @@ -304,23 +330,10 @@ export function createMcpToolDefinition(options: { `${theme.fg("muted", `... (${hidden} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`, }), ); - const fullOutputPath = result.details?.fullOutputPath; + const fullOutputPath = (result.details as McpToolDetails | undefined)?.fullOutputPath; if (fullOutputPath) component.addChild(new Text(theme.fg("muted", `Full output: ${fullOutputPath}`), 0, 0)); } return component; }, - async execute(_toolCallId, params, signal, onUpdate) { - const client = await options.getClient(); - const result = await client.callTool(tool.name, (params ?? {}) as Record, { - signal, - timeoutMs: options.timeoutMs, - onProgress: (progress) => { - const total = progress.total === undefined ? "" : `/${progress.total}`; - const text = progress.message ?? `Progress ${progress.progress}${total}`; - onUpdate?.({ content: [{ type: "text", text }], details: { server, tool: tool.name } }); - }, - }); - return convertMcpResult(server, tool.name, result, { readableResources: options.readableResources?.() }); - }, }; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 9e2d993e7..3a25f85c6 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -179,6 +179,8 @@ export type { ToolLoadout, ToolLoadoutChanges, ToolNamespace, + ToolRendererResolver, + ToolRenderers, ToolRenderResultOptions, ToolResultEvent, ToolResultEventResult, diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 9f4c44a60..ac5615da7 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -1,4 +1,3 @@ -import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Box, type Component, @@ -11,26 +10,10 @@ import { type TUI, type TuiMouseEvent, } from "@earendil-works/pi-tui"; -import type { ToolDefinition, ToolRenderContext, ToolRenderResultOptions } from "../../../core/extensions/types.ts"; -import type { Theme } from "../theme/theme.ts"; +import type { ToolDefinition, ToolRenderContext, ToolRenderers } from "../../../core/extensions/types.ts"; -/** - * What this component needs from a tool: how to draw it. It neither executes tools nor reads their - * parameter schemas, so a definition and a bare renderer pair are equally acceptable. - * - * The renderer parameters are `any` on purpose: a `ToolDefinition` types them from its schema, and - * narrowing them here would make those definitions unassignable. - */ -export interface ToolRenderers { - renderShell?: "default" | "self"; - renderCall?: (args: any, theme: Theme, context: ToolRenderContext) => Component; - renderResult?: ( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - context: ToolRenderContext, - ) => Component; -} +/** What this component needs from a tool: how to draw it, without executing it. */ +export type { ToolRenderers }; import { formatToolCallWithArgs, getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts"; import { ensurePngTranscoder } from "../../../utils/image-convert.ts"; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8dbeb06a0..8d195a715 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2190,7 +2190,9 @@ export class InteractiveMode { * whatever this returns, so they never reach into the tool registry themselves. */ private getRegisteredToolDefinition(toolName: string) { - return withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName)); + return this.session.extensionRunner.resolveToolRenderers(toolName, () => + withBuiltInRenderers(toolName, this.session.getToolDefinition(toolName)), + ); } private getMarkdownTransformers(): MarkdownTransformer[] { diff --git a/packages/coding-agent/test/export-html-whitespace.test.ts b/packages/coding-agent/test/export-html-whitespace.test.ts index 9d2a0e84d..5941821ca 100644 --- a/packages/coding-agent/test/export-html-whitespace.test.ts +++ b/packages/coding-agent/test/export-html-whitespace.test.ts @@ -30,7 +30,7 @@ describe("export HTML tool output whitespace", () => { renderResult: () => component, } as unknown as ToolDefinition; const renderer = createToolHtmlRenderer({ - getToolDefinition: () => tool, + getToolRenderers: () => tool, theme: {} as Theme, cwd: "/tmp", }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 55a8066c2..51d535adc 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -888,6 +888,30 @@ describe("ExtensionRunner", () => { }); }); + // Issue #10285: the MCP extension renders calls to tools that are not registered. + it("resolves tool renderers in extension load order, each able to defer to the next", async () => { + const runtime = createExtensionRuntime(); + const eventBus = createEventBus(); + const renderCall = () => ({ render: () => [], invalidate: () => {} }); + const first = await loadExtensionFromFactory( + (pi) => pi.registerToolRenderer((toolName, next) => (toolName === "a" ? { renderCall } : next())), + tempDir, + eventBus, + runtime, + ); + const second = await loadExtensionFromFactory( + (pi) => pi.registerToolRenderer((_toolName, next) => next() ?? { renderShell: "self" }), + tempDir, + eventBus, + runtime, + ); + const runner = new ExtensionRunner([first, second], runtime, tempDir, sessionManager, modelRegistry); + + expect(runner.resolveToolRenderers("a", () => undefined)).toEqual({ renderCall }); + expect(runner.resolveToolRenderers("b", () => undefined)).toEqual({ renderShell: "self" }); + expect(runner.resolveToolRenderers("b", () => ({ renderCall }))).toEqual({ renderCall }); + }); + describe("boundary chaining", () => { it("chains shared draft proposals and preserves omitted result fields", async () => { const runtime = createExtensionRuntime(); diff --git a/packages/coding-agent/test/suite/regressions/10285-mcp-tool-renderers.test.ts b/packages/coding-agent/test/suite/regressions/10285-mcp-tool-renderers.test.ts new file mode 100644 index 000000000..7ae711332 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/10285-mcp-tool-renderers.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { ToolRenderContext } from "../../../src/core/extensions/types.ts"; +import { SessionManager } from "../../../src/core/session-manager.ts"; +import { createMcpExtension } from "../../../src/extensions/mcp/index.ts"; +import { initTheme, theme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; +import { createHarness } from "../harness.ts"; + +const mcpExtension = createMcpExtension({ loadConfig: () => ({ servers: [], errors: [] }) }); + +describe("MCP tool renderers", () => { + // Regression #10285: a resumed session renders MCP tool calls before their server connected, if it + // ever does. They render with the MCP renderers anyway, instead of the expanded fallback. + it("renders calls to MCP tools that are not registered", async () => { + initTheme("dark"); + const harness = await createHarness({ extensionFactories: [mcpExtension] }); + try { + const resolve = (toolName: string) => + harness.session.extensionRunner.resolveToolRenderers(toolName, () => + harness.session.getToolDefinition(toolName), + ); + + const call = resolve("mcp__my_docs__search")?.renderCall?.({ query: "pi" }, theme, { + expanded: false, + } as unknown as ToolRenderContext); + expect(stripAnsi(call?.render(100).join("\n") ?? "")).toContain('my_docs/search query="pi"'); + expect(resolve("not_mcp")).toBeUndefined(); + // Registered tools keep their own renderers. + expect(resolve("read")?.renderCall).toBe(harness.session.getToolDefinition("read")?.renderCall); + } finally { + harness.cleanup(); + } + }); + + it("renders them in HTML exports too", async () => { + initTheme("dark"); + const dir = mkdtempSync(join(tmpdir(), "pi-10285-")); + const sessionManager = SessionManager.create(dir, join(dir, "sessions")); + const harness = await createHarness({ extensionFactories: [mcpExtension], sessionManager }); + try { + sessionManager.appendMessage({ role: "user", content: "search", timestamp: 1 }); + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "mcp__my_docs__search", arguments: { query: "pi" } }], + api: "anthropic-messages", + provider: "anthropic", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 2, + }); + + const html = readFileSync(await harness.session.exportToHtml(join(dir, "export.html")), "utf8"); + const data = /