Skip to content

[Bug]: html_render pages never show for OpenCode 2 threads because Code Mode wraps the call in execute #16745

Description

@ehfreema

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use an OpenCode 2 provider instance (Code Mode is on by default, so T3's MCP tools are reachable only through OpenCode's execute tool).
  2. In a thread, ask the agent to show something visually, for example: "Build a small HTML chart and publish it with html_render."
  3. The agent runs execute with a script that calls tools["t3-code-<thread>"].html_preview(...) and tools["t3-code-<thread>"].html_render(...).
  4. Look at the thread on web, desktop, or mobile.

The simplest form that still fails is a script whose output is exactly html_render's result:

const out = await tools["t3-code-<thread>"].html_render({ html, title: "Agent roster", height: 1508 });
return out;

Expected behavior

The published page shows inline above the agent's reply, as it does for Claude, Codex, Cursor, Pi and OpenCode 1. Deleting the thread deletes the page.

Actual behavior

No page is shown. The thread only has a generic execute work row, which folds away when the turn completes. html_render still tells the model "Shown to the reader above your reply. Don't mention or describe the page", so the agent's reply refers to a page the user never sees.

Cause, at main @ bfec238:

  • Clients find a page only through htmlRenderFromToolItem (packages/shared/src/toolOutput.ts:168-175). That requires a completed dynamic_tool item whose tool name resolves to html_render.
  • OpenCode 2 runs nested Code Mode calls inside execute and reports only that one tool: no events or parts for the nested calls, and its metadata.toolCalls lists { tool, status, input } without results. The adapter projects it as dynamic_tool with toolName: "execute" (OpenCodeToolItems.ts:155-167), so the name check fails even when the script returns the reference. In the example above, the execute item's stored output is exactly {"htmlRender":{"attachmentId":"…-html","title":"Agent roster and redundancy","height":1508,"heights":[…]},"message":"Shown to the reader…"}, and nothing renders.
  • The same check decides which pages thread deletion removes (threadHtmlRenderAttachmentIds in apps/server/src/attachmentStore.ts:118-135, used by ProjectionStore.getThreadAttachmentIds). Pages published from execute are never cleaned up.

In one OpenCode 2 thread, html_render published four pages and none was shown. For the other three, the script returned something else (one returned the html_preview result), so no turn item carries their htmlRender reference at all.

OpenCode2AdapterV2 registers T3's server through mcp.add without codemode (OpenCode2AdapterV2.ts:3265-3275), so OpenCode's default (true) applies. Setting codemode: false would make each call a direct tool again. It would also put every T3 tool schema into every OpenCode 2 request, the same prefix bloat as #16651, so that is probably not the fix.

Impact

Major degradation or frequent failure

Version or commit

0.0.46-nightly.20261006.2752; also main @ bfec238

Environment

macOS arm64, T3 Code nightly, OpenCode 2.0.24 spawned by T3, opencode-go/muse-spark-1.3-contributor; also opencode-go/deepseek-v4.1-flash

Logs or stack traces

# Turn item for the call above (statev2.sqlite, orchestration_v2_projection_turn_items)
type=dynamic_tool status=completed toolName=execute
output={"htmlRender":{"attachmentId":"<thread>-901e8b83-…-html","title":"Agent roster and redundancy","height":1508,…},"message":"Shown to the reader above your reply. …"}

# Pages on disk for that thread (attachments dir); none appears in the thread
<thread>-089a2fbf-…-html.html
<thread>-1ca8810f-…-html.html
<thread>-901e8b83-…-html.html
<thread>-e2e8474c-…-html.html

Workaround

None in T3 Code. The page can be opened by hand from the attachments directory.

Related: #16140 / #16142 (OpenCode 2 threads get T3's MCP tools at all), #15736 (Code Mode catalog flapping, a different bug), #16651 (why turning Code Mode off is costly).

Model: Claude Opus 5.5. Harness: Claude Code in T3 Code.

Activity

  1. juliusmarminge commented on Oct 7, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the detailed report. I confirmed it against main (checked at 10f39eb9).

    Root cause

    • htmlRenderFromToolItem (packages/shared/src/toolOutput.ts:167-175) returns a page only when resolveT3McpToolId(item.toolName) === "html_render". It ignores the output otherwise.
    • OpenCode 2 Code Mode runs T3 tools inside execute, and OpenCodeToolItems.ts:155-167 (apps/server/src/orchestration-v2/Adapters/) projects that as dynamic_tool with toolName: "execute". The name check fails even when the output contains a valid htmlRender reference.
    • OpenCode2AdapterV2.ts:3264-3275 calls client.mcp.add(...) with a RemoteConfig that has no codemode field, so OpenCode's default (Code Mode on) applies.
    • Thread deletion uses the same check: threadHtmlRenderAttachmentIds (apps/server/src/attachmentStore.ts:122-135, called from ProjectionStore.ts:4618 and :5961). That means pages published through execute are orphaned when their thread is deleted.

    Proposed fix

    • Don't depend on the wrapping tool's name. The server's html_render MCP handler already knows the thread, so it should record each published page for that thread directly, for example as a thread attachment or a projection entry. Clients would then render it and deletion would clean it up, whatever provider or wrapper ran the call. This also covers scripts that never return the html_render result.
    • Smaller interim option: in htmlRenderFromToolItem, also accept an htmlRender reference from a completed execute item. Keep the existing parseThreadSegmentFromAttachmentId thread-ownership check so a script can't point at another thread's page.
    • Turning Code Mode off (codemode: false) isn't recommended, because every request would then carry every T3 tool schema (see [Bug]: Pi sessions declare every t3-code tool on every turn #16651).

    Workaround
    T3 Code has no in-app workaround yet. Published pages are still on disk as <thread>-…-html.html in the attachments directory, so you can open them by hand. Other providers (Claude, Codex, OpenCode 1) render pages normally.

    Related, not duplicates: #16140/#16142 (OpenCode 2 MCP wiring, fixed), #15736 (Code Mode catalog flapping), #16651 (schema bloat).

  2. added
    bugSomething is broken or behaving incorrectly.
    via-triageFiled through npx t3 triage
    on Oct 7, 2026
  3. ricardo-devis-agullo commented on Oct 7, 2026

    @ricardo-devis-agullo

    Additional reproduction: Devin through ACP Registry

    I reproduced the same user-visible failure on the latest nightly with Devin running through T3's acpRegistry harness, but with a different failure mode from OpenCode's execute wrapper.

    Environment

    • T3 Code: 0.0.46-nightly.20261007.2774 (611132c171f3)
    • Provider instance: acpRegistry_devin
    • macOS arm64

    Observed behavior

    • html_preview succeeds with zero console errors.
    • html_render succeeds and writes the HTML file under ~/.t3/userdata/attachments/.
    • The projected turn item has the correct recognizable tool name: mcp__t3-code__html_render.
    • Nothing renders in the thread, even after reload/reopen.

    The important difference is the persisted output shape. The ACP adapter stores the tool result as:

    [
      {
        "type": "content",
        "content": {
          "type": "text",
          "text": "{\"htmlRender\":{\"attachmentId\":\"<thread>-<uuid>-html\",\"title\":\"...\",\"height\":408,\"heights\":[...]},\"message\":\"Shown to the reader above your reply...\"}"
        }
      }
    ]

    compactDynamicToolOutput() cannot extract this result. In readResult()'s array branch, it only reads block.text:

    const text = Predicate.isObject(block) ? block.text : undefined;

    For the ACP Registry shape, the JSON is at block.content.text, so readResult() receives undefined, htmlRenderFromToolItem() gets no htmlRender reference, and the client never creates an inline render row. The current tests cover { type: "text", text: json } and { content: [{ text: { text: json } }] }, but not [{ type: "content", content: { type: "text", text: json } }].

    A narrow compatibility fix would unwrap both block.text and block.content, then extract .text from either object shape. For example:

    const content = Predicate.isObject(block) ? (block.text ?? block.content) : block;
    const text = Predicate.isObject(content) ? (content.text ?? content) : content;
    const result = readResult(text, budget, depth + 1);

    This supports the broader fix proposed above: published pages should ideally be recorded directly against the thread, independent of provider-specific result envelopes. Until then, Devin-over-ACP users can successfully publish HTML files but cannot see them inline.

  4. YL404 commented on Oct 8, 2026

    @YL404

    Additional reproductions: acpRegistry_codebuddy_code, plus a second gap beyond the tool name

    Reproduced on 0.0.46-nightly.20261008.2801 (macOS arm64) by reading orchestration_v2_projection_turn_items in ~/.t3/userdata/statev2.sqlite for every provider that ran html_render, and replaying each persisted output through readResult's logic:

    provider_instance_id recorded toolName name gate (resolveT3McpToolId) recorded output shape compactDynamicToolOutput().htmlRender
    codex (native) t3-code.html_render ✅ {htmlRender:{…}} ✅ renders in the thread
    acpRegistry_devin mcp__t3-code__html_render ✅ (see below) [{type:'content',content:{type:'text',text:'<json>'}}] ❌
    opencode execute ❌ {htmlRender:{…}} ✅ only the name is lost
    acpRegistry_codebuddy_code Tool / DeferExecuteTool ❌ {type:'text',text:'<json>'} bare object ❌ would fail even with the right name

    Two details that look new relative to the discussion so far:

    1. acpRegistry_codebuddy_code fails both gates, and its output shape is not covered by the narrow fix proposed above. The agent wraps every injected MCP call in its own ToolSearch → DeferExecuteTool layer, so the wire title/kind is just Tool/DeferExecuteTool; the real name only survives inside rawInput.toolName (mcp__t3-code__html_render). Its output is a bare {type:'text',text:'<json>'} object — readResult's object branch looks for structuredContent/content, finds neither, and treats the block itself as data, so data.htmlRender is never read. Unwrapping block.text ?? block.content in the array branch won't reach this shape.

    2. Devin-over-ACP's name gate only passes by accident. extractMcpToolCallIdentity (apps/server/src/provider/acp/AcpRuntimeModel.ts) checks meta.is_mcp_tool_call + rawInput.server/rawInput.tool, meta.serverId/meta.toolName, claudeCode/goose meta, and the wire data.title. Devin's real name lives in meta['cognition.ai/inferenceToolName'] (which normalizeDevinToolCall copies into tool.title) and in rawInput.server_name/tool_name — none of which are candidates. The item therefore lands in the default branch: toolName = presentation title (which resolveT3McpToolId happens to regex-match), and output recorded verbatim with no acpMcpToolCallOutput normalization. Any less canonical title would fail the name gate too.

    Both reinforce the systemic direction proposed above: recording the published page against the thread at html_render call time fixes every one of these rows regardless of naming or envelope. If the interim narrow fix lands first, the readResult array branch should recurse into block.content (covers [{type:'content',content:{…}}]) and the object branch should try value.text when no content/structuredContent key exists (covers the bare {type:'text',text} block).

  5. IsraelAraujo70 commented on Oct 8, 2026

    @IsraelAraujo70

    Additional reproduction: Grok Build (grok) wraps html_render in a UseTool MCP envelope

    Same user-visible failure on the latest nightly, with a Grok-specific envelope. Codex on the same machine, the same evening, renders the page.

    What happened

    Asked a Grok thread to publish a page with html_render. html_preview succeeded. html_render succeeded and wrote the HTML file. The thread never showed the page. One attempt painted a frame titled "October 8" that said "Image unavailable". A Codex thread running the same tool showed the page inline.

    Diagnosis

    Read orchestration_v2_projection_turn_items for the two threads. The Grok item that used the recognizable tool name still does not expose htmlRender where htmlRenderFromToolItem looks.

    Grok, inline use_tool call (variant: "UseTool"):

    • toolName: t3-code.html_render (the name gate passes)
    • input keys: tool_name, tool_input, variant. The document is at input.tool_input.html, not input.html
    • output:
    {
      "type": "MCP",
      "tool_name": "html_render",
      "server_name": "t3-code",
      "output": {
        "OkayOutput": "{\"htmlRender\":{\"attachmentId\":\"<thread>-<uuid>-html\",\"title\":\"October 8\",\"height\":2000,\"heights\":[...]},\"message\":\"Shown to the reader above your reply...\"}"
      }
    }

    OkayOutput is a JSON string. There is no output.htmlRender object.

    Codex, same night, native call:

    • toolName: t3-code.html_render
    • input keys: html, height, title
    • output: { "htmlRender": { "attachmentId", "title", "height", "heights" }, "message": "..." }
    • The page renders.

    Two earlier Grok attempts passed the HTML through a file. Those items are stored as:

    • toolName: t3-code__html_render — source file: /tmp/mcp-render.json

    That name fails resolveT3McpToolId before the envelope is even read. The HTML files were still written (three *-html.html files for this thread under the attachments directory).

    This is the same product gap as OpenCode's execute wrapper and the Devin / CodeBuddy envelopes already on this issue: the page is published, then the client only looks for a provider-specific tool name and a provider-specific result shape. Grok adds one more shape, { type: "MCP", output: { OkayOutput: "<json string>" } }, plus a tool name that sometimes has a "source file" suffix.

    Recording the published page on the thread inside the html_render handler would cover this row too. A narrower read of the Grok envelope would also need to parse output.output.OkayOutput when it is a string, and to accept the t3-code__html_render — source file: … title or ignore the suffix.

    Steps to reproduce

    1. Desktop app, Nightly, provider grok / grok-4.7-build-fast.
    2. Ask the agent to call html_preview, then html_render, with a small self-contained HTML document.
    3. The tool result reports htmlRender.attachmentId and the HTML file is on disk.
    4. The thread does not show the page. Codex html_render in another thread on the same app does.

    Version

    0.0.46-nightly.20261008.2819

    Environment

    macOS 26.5.2, T3 Code (Nightly) desktop app. Provider grok, model grok-4.7-build-fast. Compared with provider codex, model gpt-6.1-sol, on the same install.

    Evidence

    Grok thread 7614335e-d780-4175-9cde-b141a64c16a9. Codex thread where the page did render: bd78657b-5c88-4c5a-bc36-66c88f67f740. Successful Grok attachment id (file exists, page absent in the thread): 7614335e-d780-4175-9cde-b141a64c16a9-b15e5d09-3373-4768-85b7-9b83c9ba25c9-html.

    Workaround

    None in the Grok thread. The HTML file can be opened from the attachments directory. Codex renders the same tool normally.

    Filed by

    grok-4.7-build-fast, following the npx t3 triage playbook in-session. Did not launch a nested npx t3 triage agent, because that command starts an interactive Claude or Codex session.

  6. AshutoshGitMirror commented on Oct 10, 2026

    @AshutoshGitMirror

    Can confirm on opencode v2 and antigravity ACP.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions