diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 0a1b3c4d793f4..bbd13f61e4480 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -35,6 +35,23 @@ describe("summarizeT3ToolCalls", () => { ).toBe("Sent 2 messages to 1 thread"); }); + it("reads an MCP OkayOutput string as the tool result", () => { + const result = { messageId: "message-1", threadId: "thread-1" }; + expect( + summarizeT3ToolCalls("thread-send", [ + completed( + { toolName: "t3_thread_send", args: { threadId: "thread-1" } }, + { + type: "MCP", + tool_name: "t3_thread_send", + server_name: "t3-code", + output: { OkayOutput: JSON.stringify(result) }, + }, + ), + ]).label, + ).toBe("Sent 1 message to 1 thread"); + }); + it("excludes failed and unfinished sends even if the provider reports completed", () => { const calls: T3ToolSummaryCall[] = [ completed({ threadId: "success" }, { messageId: "ok", threadId: "success" }), diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index df655c1d6c218..1d5f6cdca6bb1 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -21,6 +21,13 @@ interface ToolResult { readonly failed: boolean; } +function mcpOkayOutput(record: Record): string | undefined { + if (record.type !== "MCP") return undefined; + const output = asRecord(record.output); + const okay = output?.OkayOutput; + return typeof okay === "string" && okay.length > 0 ? okay : undefined; +} + function readResult(value: unknown, depth = 0): ToolResult { if (depth > 4) return { failed: false }; if (typeof value === "string") { @@ -49,6 +56,11 @@ function readResult(value: unknown, depth = 0): ToolResult { record.is_error === true || (typeof record._tag === "string" && /(?:Error|Failure)$/.test(record._tag)) || record.error != null; + const okay = mcpOkayOutput(record); + if (okay !== undefined) { + const result = readResult(okay, depth + 1); + return { ...(result.data ? { data: result.data } : {}), failed: failed || result.failed }; + } const content = record.structuredContent ?? record.content; if (content !== undefined) { const result = readResult(content, depth + 1); diff --git a/packages/shared/src/t3McpToolPresentation.test.ts b/packages/shared/src/t3McpToolPresentation.test.ts index fc3345d6c537a..99243de852e68 100644 --- a/packages/shared/src/t3McpToolPresentation.test.ts +++ b/packages/shared/src/t3McpToolPresentation.test.ts @@ -31,4 +31,25 @@ describe("resolveT3McpToolPresentation", () => { ).toBe("Delegate a child task"); expect(resolveT3McpToolPresentation("t3-code-thread_opencode2-adapter_not_a_tool")).toBeNull(); }); + + it("ignores a trailing source annotation after the tool id", () => { + expect(resolveT3McpToolPresentation("t3-code__html_render — source file: page.html")).toEqual({ + displayName: "Render an HTML page", + logo: "t3-code", + }); + expect(resolveT3McpToolPresentation("t3-code.html_preview — source file: page.html")).toEqual({ + displayName: "Preview an HTML page", + logo: "t3-code", + }); + expect( + resolveT3McpToolPresentation("mcp__t3-code__html_render — source file: page.html"), + ).toEqual({ + displayName: "Render an HTML page", + logo: "t3-code", + }); + expect(resolveT3McpToolPresentation("t3-code.html_render-v2")).toBeNull(); + expect(resolveT3McpToolPresentation("t3-code__html_render-v2")).toBeNull(); + expect(resolveT3McpToolPresentation("mcp__t3-code__html_render-v2")).toBeNull(); + }); + }); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 21f8be3941f95..a6ebbfba03609 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -348,7 +348,7 @@ function normalizeT3McpToolLabel(value: string): string { */ function resolveT3McpToolName(value: string): string | null { const label = normalizeT3McpToolLabel(value); - const mcpMatch = /^mcp__(?.+?)__(?.+)$/i.exec(label); + const mcpMatch = /^mcp__(?.+?)__(?[A-Za-z0-9_]+)(?=$|[^A-Za-z0-9_-])/i.exec(label); if (mcpMatch?.groups) { const { server, tool } = mcpMatch.groups; return server !== undefined && @@ -358,14 +358,19 @@ function resolveT3McpToolName(value: string): string | null { : null; } - const namespaceMatch = /^(?t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?.+)$/i.exec( - label, - ); + const namespaceMatch = + /^(?t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*)(?[A-Za-z0-9_]+)(?=$|[^A-Za-z0-9_-])/i.exec( + label, + ); if (namespaceMatch?.groups) { return namespaceMatch.groups.tool ?? null; } - const prefixed = /^(?:mcp[-_]{1,2})?t3[-_ ]?code(?:__|[-_.:/ ])(?.+)$/i.exec(label); + // The id ends at the title or at an annotation. A hyphen, as in html_render-v2, is still part of the name. + const prefixed = + /^(?:mcp[-_]{1,2})?t3[-_ ]?code(?:__|[-_.:/ ])(?[A-Za-z0-9_]+)(?=$|[^A-Za-z0-9_-])/i.exec( + label, + ); const candidate = prefixed?.groups?.tool ?? label; if (Object.hasOwn(T3_MCP_TOOLS, candidate)) return candidate; // OpenCode 2 registers one server per thread, `t3-code-`, and joins diff --git a/packages/shared/src/toolOutput.test.ts b/packages/shared/src/toolOutput.test.ts index 405905bf2fad9..20c3ca4166a6a 100644 --- a/packages/shared/src/toolOutput.test.ts +++ b/packages/shared/src/toolOutput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { compactDynamicToolOutput, + htmlRenderFromToolItem, MAX_TOOL_OUTPUT_IMAGES, toolOutputImages, toolOutputIndicatesFailure, @@ -132,6 +133,33 @@ describe("compactDynamicToolOutput", () => { threadId: "known", }); }); + + it("reads an MCP OkayOutput string, including a suffixed html_render name", () => { + const reference = { attachmentId: "render-1", title: "Example", height: 120 }; + const output = { + type: "MCP", + tool_name: "html_render", + server_name: "t3-code", + output: { OkayOutput: JSON.stringify({ htmlRender: reference, message: "Shown" }) }, + }; + expect(compactDynamicToolOutput(output)?.htmlRender).toEqual(reference); + expect( + htmlRenderFromToolItem({ + toolName: "t3-code__html_render — source file: page.html", + output, + }), + ).toEqual(reference); + expect( + compactDynamicToolOutput({ + type: "MCP", + isError: true, + output: { OkayOutput: JSON.stringify({ threadId: "thread-1" }) }, + }), + ).toEqual({ isError: true, threadId: "thread-1" }); + expect( + compactDynamicToolOutput({ type: "MCP", output: { OkayOutput: "" }, threadId: "thread-1" }), + ).toEqual({ threadId: "thread-1" }); + }); }); describe("toolOutputIndicatesFailure", () => { diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index be788033d0fbe..b8fd0fc0d7b80 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -45,6 +45,14 @@ interface CompactToolOutput { threads?: Array<{ threadId?: string; status?: "rolled_back" }>; } +function mcpOkayOutput(value: object): string | undefined { + if (!("type" in value) || value.type !== "MCP" || !("output" in value)) return undefined; + const output = value.output; + if (!Predicate.isObject(output) || !("OkayOutput" in output)) return undefined; + const okay = output.OkayOutput; + return typeof okay === "string" && okay.length > 0 ? okay : undefined; +} + function readResult(value: unknown, budget: ResultReadBudget, depth = 0): ResultEnvelope { if (depth > MAX_ENVELOPE_DEPTH || budget.remainingNodes-- <= 0) { budget.exceeded = true; @@ -93,6 +101,11 @@ function readResult(value: unknown, budget: ResultReadBudget, depth = 0): Result value.is_error === true || value._tag === "OrchestratorMcpFailure" || value.error != null; + const okay = mcpOkayOutput(value); + if (okay !== undefined) { + const nested = readResult(okay, budget, depth + 1); + return { ...nested, failed: failed || nested.failed }; + } const content = value.structuredContent ?? value.content; if (content !== undefined) { const nested = readResult(content, budget, depth + 1);