diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d64763aa2788e..d13ca1da1148a 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -77,6 +77,33 @@ it("keeps historical plan detail accessible from its paged turn item", () => { expect(presented[2]).toMatchObject({ activities: [{ projectedItem: { item } }] }); }); +it("shows a page an execute script returned only when it was published for the row's thread", () => { + const executeCall = (id: string, thread: string) => + ({ + ...base(id, "2026-06-20T00:00:02.000Z", 1), + type: "dynamic_tool", + toolName: "execute", + input: { code: "return await tools.render()" }, + output: JSON.stringify({ + htmlRender: { + attachmentId: `${thread}-00000000-0000-4000-8000-000000000001-html`, + title: "Chart", + height: 420, + }, + }), + }) as OrchestrationV2TurnItem; + const pages = (row: OrchestrationV2ProjectedTurnItem) => + buildThreadFeed([row]).filter((entry) => entry.type === "html-render"); + + expect(pages(projected(executeCall("own", threadId), 0))).toHaveLength(1); + expect(pages(projected(executeCall("foreign", "thread-2"), 0))).toEqual([]); + // An inherited row is checked against the thread it came from, not the one showing it. + expect(pages(projected(executeCall("inherited", sourceThreadId), 0, "inherited"))).toHaveLength( + 1, + ); + expect(pages(projected(executeCall("inherited-local", threadId), 0, "inherited"))).toEqual([]); +}); + it("shows a compact command as one mobile divider before, during, and after compaction", () => { const startedAt = "2026-06-20T00:00:01.000Z"; const prompt = projected({ ...userMessage(), text: "/compact", idleCompaction: "onReturn" }, 0); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index b9fe76a8b07b2..c60ef51aa090b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2189,7 +2189,7 @@ export function buildThreadFeed( const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt); const render = item.type === "dynamic_tool" && item.status === "completed" - ? htmlRenderFromToolItem(item) + ? htmlRenderFromToolItem(item, row.sourceThreadId) : undefined; if (render) { const entry: RawThreadFeedEntry = { diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 5d8ab2e90181b..adde9f91f5324 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -4,6 +4,10 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; +import { + PENDING_ATTACHMENT_THREAD_SEGMENT, + toSafeThreadAttachmentSegment, +} from "@t3tools/shared/attachmentIds"; import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { @@ -13,7 +17,6 @@ import { import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; -const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; @@ -22,25 +25,11 @@ const ATTACHMENT_ID_PATTERN = new RegExp( "i", ); -export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; +export { PENDING_ATTACHMENT_THREAD_SEGMENT, toSafeThreadAttachmentSegment }; + const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; -export function toSafeThreadAttachmentSegment(threadId: string): string | null { - const segment = threadId - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/gi, "-") - .replace(/-+/g, "-") - .replace(/^[-_]+|[-_]+$/g, "") - .slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS) - .replace(/[-_]+$/g, ""); - if (segment.length === 0) { - return null; - } - return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; -} - export function attachmentFileExtension(fileName: string): string { const extension = NodePath.extname(fileName).toLowerCase(); // .part is reserved for pending uploads; the stale-upload sweep would delete stored files. @@ -133,7 +122,8 @@ export function threadHtmlRenderAttachmentIds( if (segment === null) return []; return Array.from(items).flatMap((item) => { const attachmentId = - htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId; + htmlRenderFromToolItem(item, threadId)?.attachmentId ?? + mcpAppFromToolItem(item)?.attachmentId; return attachmentId !== undefined && parseThreadSegmentFromAttachmentId(attachmentId) === segment ? [attachmentId] diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 1f65217e45212..35e16d36e04d4 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -576,7 +576,7 @@ const make = Effect.gen(function* () { const render = Effect.fn("GuestShareService.render")(function* (session: string, itemId: string) { const { thread, item } = yield* threadItem(session, itemId); if (item.type !== "dynamic_tool") return yield* unavailable; - const reference = htmlRenderFromToolItem(item); + const reference = htmlRenderFromToolItem(item, thread.id); const [attachmentId] = threadHtmlRenderAttachmentIds(thread.id, [item]); const path = attachmentId === undefined diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 1cb40e1fab2ed..aee3255e3644c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -32,6 +32,7 @@ import { GrokSettings } from "@t3tools/provider-grok/settings"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { deriveBlockedOnThreadWait } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import type * as Duration from "effect/Duration"; @@ -79,6 +80,7 @@ import { acpScopedNativeId, acpPostSettleContinuationOfferEvidence, acpPostSettleWakeEvidence, + acpMcpToolCallOutput, acpToolCallDiffPatch, makeAcpAdapterV2, type AcpAdapterV2ExtensionContext, @@ -110,6 +112,22 @@ const layerTest = Layer.mergeAll( const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +describe("acpMcpToolCallOutput", () => { + it("unwraps Grok's OkayOutput result string", () => { + const inner = JSON.stringify({ htmlRender: { attachmentId: "t-html", title: "T", height: 1 } }); + assert.strictEqual( + acpMcpToolCallOutput({ + type: "MCP", + tool_name: "html_render", + output: { OkayOutput: inner }, + }), + inner, + ); + const nested = { type: "MCP", output: { OkayOutput: { nested: true } } }; + assert.strictEqual(acpMcpToolCallOutput(nested), nested); + }); +}); + describe("acpToolCallDiffPatch", () => { it("builds a patch per file from ACP v1 oldText/newText, with /dev/null for a new file", () => { assert.equal( @@ -1538,6 +1556,7 @@ describe("AcpAdapterV2", () => { it.effect("keeps Devin parent paragraphs intact while projecting native child work", () => Effect.gen(function* () { + const childPage = { attachmentId: "child-html", title: "Child page", height: 120 }; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const mockAgentPath = yield* path.fromFileUrl( @@ -1649,6 +1668,21 @@ describe("AcpAdapterV2", () => { "cognition.ai/subagent_context": { parentAgentId: "child-a" }, }, }, + { + sessionUpdate: "tool_call", + toolCallId: "child-html", + title: "Render page", + status: "completed", + rawInput: { server: "t3-code", tool: "html_render", arguments: {} }, + rawOutput: { + type: "MCP", + output: { OkayOutput: JSON.stringify({ htmlRender: childPage }) }, + }, + _meta: { + is_mcp_tool_call: true, + "cognition.ai/subagent_context": { parentAgentId: "child-a" }, + }, + }, { sessionUpdate: "tool_call", toolCallId: "child-mcp-fallback", @@ -1821,6 +1855,16 @@ describe("AcpAdapterV2", () => { item.type === "dynamic_tool" && item.toolName === "weather.get_weather", ); + const childHtml = items.findLast( + (item) => + item.threadId === task?.childThreadId && + item.type === "dynamic_tool" && + item.toolName === "t3-code.html_render", + ); + assert.deepEqual( + childHtml?.type === "dynamic_tool" ? htmlRenderFromToolItem(childHtml) : undefined, + childPage, + ); for (const item of [parentMcp, childMcp]) { assert.equal(item?.title, "get weather"); assert.deepEqual(item?.toolSource, { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ed064325bfe16..250e3ecbc0290 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5323,7 +5323,12 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND type = 'dynamic_tool' - AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}) + AND ( + payload_json LIKE '%htmlRender%' + -- An execute script can return a page's reference without the htmlRender key. + OR payload_json LIKE '%-html%' + OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + ) `, // A guest's waiting files are pending uploads that no message row holds yet. sql<{ id: string }>` diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index de8b3373e44b2..19d11075d2230 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1509,6 +1509,41 @@ describe("HTML renders in the timeline", () => { expect(rowsFor(entries, true)).toEqual(["message", "turn-fold", "attempt-fold", "html-render"]); }); + it("shows a page an execute script returned only when it was published for the row's thread", () => { + const page = (thread: string) => ({ + attachmentId: `${thread}-00000000-0000-4000-8000-000000000001-html`, + title: "Chart", + height: 420, + }); + const executeCall = (render: ReturnType): OrchestrationV2TurnItem => ({ + ...base("render", 2), + type: "dynamic_tool", + toolName: "execute", + input: { code: "return await tools.render()" }, + output: JSON.stringify({ htmlRender: render }), + }); + expect( + turn(executeCall(page("render-thread"))).find((e) => e.kind === "html-render"), + ).toMatchObject({ htmlRender: page("render-thread") }); + const foreign = turn(executeCall(page("other-thread"))); + expect(foreign.some((entry) => entry.kind === "html-render")).toBe(false); + // A delegated child's row is checked against the child, not the thread showing it. + const child = ThreadId.make("child-thread"); + const merged = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { ...visible(executeCall(page("child-thread"))), sourceThreadId: child }, + { + ...visible({ ...executeCall(page("render-thread")), id: TurnItemId.make("render-2") }), + sourceThreadId: child, + }, + ], + optimisticMessages: [], + }); + expect(merged.filter((entry) => entry.kind === "html-render").map((entry) => entry.id)).toEqual( + ["render"], + ); + }); + it.each([ ["running", "running", undefined], ["failed", "failed", { content: [{ type: "text", text: "Invalid HTML" }], isError: true }], diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f386a858818b8..b07e6d98be525 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -755,7 +755,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( const htmlRender = item.type === "dynamic_tool" && item.status === "completed" - ? htmlRenderFromToolItem(item) + ? htmlRenderFromToolItem(item, row.sourceThreadId) : undefined; if (htmlRender !== undefined) { entries.push({ diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b3ce13af0a705..4971bc84b7522 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -700,10 +700,14 @@ function makeProviderThread(input: { }; } -function acpMcpToolCallOutput(rawOutput: unknown): unknown { +export function acpMcpToolCallOutput(rawOutput: unknown): unknown { const record = unknownRecord(rawOutput); if (record === undefined) return rawOutput; - if (!("result" in record) && !("error" in record)) return rawOutput; + if (!("result" in record) && !("error" in record)) { + // Grok serializes Rust's Result: `{ type: "MCP", output: { OkayOutput: "" } }`. + const okay = unknownRecord(record.output)?.OkayOutput; + return typeof okay === "string" ? okay : rawOutput; + } const result = unknownRecord(record.result); const resultOutput = result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined); @@ -2668,7 +2672,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? (merged.title ?? merged.kind ?? "Tool") : `${mcpIdentity.server}.${mcpIdentity.tool}`, input: merged.data.rawInput ?? null, - output: merged.data.rawOutput ?? merged.data.content ?? null, + output: + (mcpIdentity === undefined + ? merged.data.rawOutput + : acpMcpToolCallOutput(merged.data.rawOutput)) ?? + merged.data.content ?? + null, }, }); }); diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 70682ec4824a5..1fa36c6d39adf 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1168,6 +1168,80 @@ describe("extractMcpToolCallIdentity", () => { }); }); + it("recovers T3 identity from CodeBuddy's qualified rawInput.toolName", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-1", + kind: "Tool", + title: "Tool", + status: "in_progress", + rawInput: { toolName: "mcp__t3-code__html_render", arguments: {} }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "html_render", + }); + }); + + it("ignores an unqualified rawInput.toolName such as a ToolSearch query", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-1", + kind: "search", + title: "ToolSearch", + status: "in_progress", + rawInput: { toolName: "html_render", query: "render html" }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toBeUndefined(); + }); + + it("recovers T3 identity from Devin's namespaced inference tool name", () => { + const toolCall = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-2", + kind: "DeferExecuteTool", + title: "Deferred tool call", + status: "in_progress", + rawInput: { server_name: "other", tool_name: "html_render" }, + _meta: { "cognition.ai/inferenceToolName": "mcp__t3-code__html_render" }, + }); + + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "html_render", + }); + }); + + it("recovers T3 identity from Grok's snake_case rawInput and annotated titles", () => { + const snakeCase = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-3", + kind: "UseTool", + title: "UseTool", + status: "in_progress", + rawInput: { server_name: "t3-code", tool_name: "html_render" }, + _meta: { is_mcp_tool_call: true }, + }); + const filePassed = toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-4", + kind: "UseTool", + title: "t3-code__html_render — source file: /tmp/mcp-render.json", + status: "in_progress", + rawInput: { tool_name: "html_render", tool_input: { height: 200 }, variant: "UseTool" }, + _meta: { is_mcp_tool_call: true }, + }); + + for (const toolCall of [snakeCase, filePassed]) { + expect(extractMcpToolCallIdentity(toolCall)).toEqual({ + server: "t3-code", + tool: "html_render", + }); + } + }); + it("recovers T3 identity from acp-mcp-call fallback commands", () => { const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 15c583f67de42..d8d3adcd00b12 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1082,8 +1082,11 @@ export function extractMcpToolCallIdentity( ): AcpMcpToolCallIdentity | undefined { const rawInput = isRecord(toolCall.data.rawInput) ? toolCall.data.rawInput : undefined; const meta = isRecord(toolCall.data.meta) ? toolCall.data.meta : undefined; - const server = typeof rawInput?.server === "string" ? rawInput.server.trim() : ""; - const tool = typeof rawInput?.tool === "string" ? rawInput.tool.trim() : ""; + // Grok spells these snake_case. + const rawServer = rawInput?.server ?? rawInput?.server_name; + const rawTool = rawInput?.tool ?? rawInput?.tool_name; + const server = typeof rawServer === "string" ? rawServer.trim() : ""; + const tool = typeof rawTool === "string" ? rawTool.trim() : ""; if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { return { server, tool }; } @@ -1126,10 +1129,18 @@ export function extractMcpToolCallIdentity( meta?.toolName, claudeCode?.toolName, gooseToolCall?.toolName, + meta?.["cognition.ai/inferenceToolName"], + // CodeBuddy keeps the qualified `mcp____` name here; built-ins such as ToolSearch put bare names here. + typeof rawInput?.toolName === "string" && /^mcp__.+?__.+$/i.test(rawInput.toolName.trim()) + ? rawInput.toolName + : undefined, toolCall.data.title, ].filter((value): value is string => typeof value === "string"); for (const candidate of candidates) { - const trimmed = candidate.trim(); + // Grok annotates file-passed calls: "t3-code__html_render — source file: /tmp/x.json". + // oxlint-disable-next-line t3code/no-em-dash-copy -- Matches Grok's title, not product copy. + const annotation = candidate.indexOf(" — source file:"); + const trimmed = (annotation === -1 ? candidate : candidate.slice(0, annotation)).trim(); const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) return { server: qualified[1], tool: qualified[2] }; diff --git a/packages/shared/package.json b/packages/shared/package.json index f968d276883e1..1a0f4a7e44912 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -123,6 +123,10 @@ "types": "./src/toolOutput.ts", "import": "./src/toolOutput.ts" }, + "./attachmentIds": { + "types": "./src/attachmentIds.ts", + "import": "./src/attachmentIds.ts" + }, "./htmlRender": { "types": "./src/htmlRender.ts", "import": "./src/htmlRender.ts" diff --git a/packages/shared/src/attachmentIds.ts b/packages/shared/src/attachmentIds.ts new file mode 100644 index 0000000000000..6d66a718f0e31 --- /dev/null +++ b/packages/shared/src/attachmentIds.ts @@ -0,0 +1,30 @@ +const THREAD_SEGMENT_MAX_CHARS = 80; +const ATTACHMENT_ID_SUFFIX = + /^-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:-[a-z0-9]{1,10})?$/i; + +export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; + +export function toSafeThreadAttachmentSegment(threadId: string): string | null { + const segment = threadId + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, "") + .slice(0, THREAD_SEGMENT_MAX_CHARS) + .replace(/[-_]+$/g, ""); + if (segment.length === 0) { + return null; + } + return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; +} + +/** Thread segments can hold dashes, so everything after the segment must be exactly the uuid and extension. */ +export function attachmentIdBelongsToThread(attachmentId: string, threadId: string): boolean { + const segment = toSafeThreadAttachmentSegment(threadId); + return ( + segment !== null && + attachmentId.toLowerCase().startsWith(segment) && + ATTACHMENT_ID_SUFFIX.test(attachmentId.slice(segment.length)) + ); +} diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 793fde8b8246c..99ea1c95dde69 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -131,4 +131,37 @@ describe("htmlRenderFromToolItem", () => { expect(htmlRenderFromToolItem({ toolName, output })).toEqual(reference); } }); + + it("reads the reference from ACP content and bare text block wrappers", () => { + const text = JSON.stringify(result); + for (const output of [ + [{ type: "content", content: { type: "text", text } }], + { type: "text", text }, + ]) { + expect(htmlRenderFromToolItem({ toolName: "t3-code.html_render", output })).toEqual( + reference, + ); + } + }); + + it("shows a page an execute script returned only for the thread it was published for", () => { + const page = { + attachmentId: "thread-1-00000000-0000-4000-8000-000000000001-html", + title: "Chart", + height: 420, + }; + const execute = (value: unknown) => ({ + toolName: "execute", + output: [{ type: "text", text: JSON.stringify(value) }], + }); + expect(htmlRenderFromToolItem(execute({ htmlRender: page }), "thread-1")).toEqual(page); + expect(htmlRenderFromToolItem(execute(page), "thread-1")).toEqual(page); + expect(htmlRenderFromToolItem(execute({ htmlRender: page }), "thread-2")).toBeUndefined(); + expect(htmlRenderFromToolItem(execute(page), "thread")).toBeUndefined(); + expect(htmlRenderFromToolItem(execute(page))).toBeUndefined(); + // Only ids html_render mints are pages; another upload with the same fields is not. + expect( + htmlRenderFromToolItem(execute({ ...page, attachmentId: "thread-1-upload" }), "thread-1"), + ).toBeUndefined(); + }); }); diff --git a/packages/shared/src/toolOutput.test.ts b/packages/shared/src/toolOutput.test.ts index 405905bf2fad9..8544935cbe2ca 100644 --- a/packages/shared/src/toolOutput.test.ts +++ b/packages/shared/src/toolOutput.test.ts @@ -132,6 +132,18 @@ describe("compactDynamicToolOutput", () => { threadId: "known", }); }); + + it("keeps outer metadata when a text field holds no result", () => { + expect(compactDynamicToolOutput({ threadId: "thread-1", text: '{"a":1}' })).toEqual({ + threadId: "thread-1", + }); + expect(compactDynamicToolOutput({ type: "text", threadId: "thread-1", text: "{}" })).toEqual({ + threadId: "thread-1", + }); + expect(compactDynamicToolOutput({ threadId: "thread-1", text: "x".repeat(20_000) })).toEqual({ + threadId: "thread-1", + }); + }); }); describe("toolOutputIndicatesFailure", () => { diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index 2a80641d13b5e..3ea869c976296 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -9,6 +9,7 @@ import { readHtmlRenderReference, type HtmlRenderReference, } from "./htmlRender.ts"; +import { attachmentIdBelongsToThread } from "./attachmentIds.ts"; import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; @@ -75,7 +76,7 @@ function readResult(value: unknown, budget: ResultReadBudget, depth = 0): Result let data: ResultEnvelope["data"]; let failed = false; for (const block of value) { - const text = Predicate.isObject(block) ? block.text : undefined; + const text = Predicate.isObject(block) ? (block.text ?? block.content) : undefined; const result = readResult( Predicate.isObject(text) ? (text.text ?? text) : text, budget, @@ -88,6 +89,7 @@ function readResult(value: unknown, budget: ResultReadBudget, depth = 0): Result return { ...(data === undefined ? {} : { data }), failed }; } if (!Predicate.isObject(value)) return { failed: false }; + if (isTextContentBlock(value)) return readResult(value.text, budget, depth + 1); const failed = value.isError === true || value.is_error === true || @@ -101,6 +103,19 @@ function readResult(value: unknown, budget: ResultReadBudget, depth = 0): Result return { data: value, failed }; } +const TEXT_CONTENT_BLOCK_KEYS = new Set(["type", "text", "annotations", "_meta"]); + +/** Some ACP agents persist a bare MCP text block; anything with extra keys keeps its own metadata. */ +function isTextContentBlock( + value: Record, +): value is Record & { text: string } { + return ( + value.type === "text" && + typeof value.text === "string" && + Object.keys(value).every((key) => TEXT_CONTENT_BLOCK_KEYS.has(key)) + ); +} + function boundedId(value: unknown): string | undefined { if (typeof value !== "string" || value.length > MAX_ID_LENGTH || value.trim() === "") { return undefined; @@ -123,7 +138,12 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un if (id !== undefined) output[key] = id; } if (data.status === "rolled_back") output.status = "rolled_back"; - const htmlRender = readHtmlRenderReference(data.htmlRender); + // A Code Mode script may return html_render's reference unwrapped; only html_render mints `-html` ids. + const htmlRender = + readHtmlRenderReference(data.htmlRender) ?? + (typeof data.attachmentId === "string" && data.attachmentId.endsWith("-html") + ? readHtmlRenderReference(data) + : undefined); if (htmlRender !== undefined) output.htmlRender = htmlRender; const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp; @@ -176,13 +196,25 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un return Object.keys(output).length === 0 ? undefined : output; } -export function htmlRenderFromToolItem(item: { - readonly toolName: string | null | undefined; - readonly output?: unknown; -}): HtmlRenderReference | undefined { - if (resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME) return undefined; +/** + * OpenCode Code Mode runs T3 tools inside `execute`, whose output is whatever the script returned, + * so its page only counts when it was published for `threadId`, the thread that owns the item. + */ +export function htmlRenderFromToolItem( + item: { + readonly toolName: string | null | undefined; + readonly output?: unknown; + }, + threadId?: string, +): HtmlRenderReference | undefined { + const fromScript = item.toolName === "execute"; + if (!fromScript && resolveT3McpToolId(item.toolName) !== HTML_RENDER_TOOL_NAME) return undefined; const output = compactDynamicToolOutput(item.output); - return output?.isError ? undefined : output?.htmlRender; + const render = output?.isError ? undefined : output?.htmlRender; + if (render === undefined || !fromScript) return render; + return threadId !== undefined && attachmentIdBelongsToThread(render.attachmentId, threadId) + ? render + : undefined; } /** Accept app references only when server and tool match the item, so tools cannot impersonate another app source. */