Skip to content

Agent Panel: a tool call interleaved inside one streaming assistant message splits it into two Markdown documents, breaking open code fences #62631

Description

@Sergio-3

Reproduction steps

The bug needs a tool call that arrives inside a still-streaming assistant message. The agent below produces one deterministically — plain Node, no model, no network, no dependencies.

  1. Save zed-markdown-split-agent.mjs (full source at the bottom of this issue).
  2. Register it in settings.json:
"agent_servers": {
  "acp-repro": { "type": "custom", "command": "node", "args": ["<path>/zed-markdown-split-agent.mjs"] }
}
  1. Open the Agent Panel, pick acp-repro, send any prompt (every prompt triggers the same scripted turn).
  2. Compare Case 1 with the Case 2/3 controls.

Case 1 streams one assistant message (messageId: msg_repro_a) that opens a ```python fence, then a tool_call arrives, then further chunks of the same messageId close the fence. Cases 2/3 place the tool call at a genuine message boundary — the chunks after it carry a new messageId.

Current vs. Expected behavior

Current: the message is split at the card and the second half is parsed as a fresh Markdown document, so the open fence is lost. The damage does not stop at "no highlighting": the closing ``` of the original message is read as an opening fence in the new document, so code and prose swap roles for the rest of the message. In the repro, def second_half(): return 2 renders as prose, and the sentence after the closing fence renders inside a code block. In a real session the same effect turns a ### line that was inside a fence into a real heading, and tears tables and lists in half.

Cases 2/3 render correctly, which is the point: at a real message boundary a new document is the right call.

Expected: chunks carrying one messageId render as one Markdown document, whatever arrives between them.

Screenshots below: first the same effect in a real session (Claude Code via claude-agent-acp, where a background sub-agent's terminal cards land mid-message), then the repro agent from the steps above.

Real session — the fenced instruction block is cut by the sub-agent's terminal card (blue); the sentence that continues it renders as prose below the card, and ### 1b: …, which was inside the fence, becomes a heading.

Real session: a fenced code block cut by a sub-agent tool card, its continuation rendered as prose and an inline heading

Repro agent — Case 1 is broken (the fence is lost and code and prose swap roles); Cases 2 and 3, where the tool call sits at a real message boundary, render correctly.

Repro agent output: Case 1 broken, Cases 2 and 3 correct

Where it happens

AcpThread::push_assistant_content_block_with_message_id (crates/acp_thread/src/acp_thread.rs:2770-2863 on main) appends to the previous assistant message only when self.entries.last_mut() is an AssistantMessage. A ToolCall entry in between ends that lookup, so the fallback branch pushes a fresh AssistantMessage with a new ContentBlock; streaming_markdown_target (2866) likewise inspects only the last entry. Each ContentBlock parses its text independently, so no fence, list or table state survives the split.

messageId is available in that function and already drives can_merge_message_chunks when merging chunks within one entry — it is just not consulted across an interleaved entry.

What this looks like on the wire

From a real session, same turn. A synchronous tool call is a real message boundary — the messageId changes across it:

+2.784s  agent_message_chunk  messageId=msg_011Ce2DuFtx23cDHUjLx3kwg
+3.518s  agent_message_chunk  messageId=msg_011Ce2DuFtx23cDHUjLx3kwg
+3.522s  tool_call            title="Task"
+9.897s  agent_message_chunk  messageId=msg_011Ce2DuiMfV6dnSCJTBenb3   <- new message

An asynchronous background sub-agent's tool call is not — the same message continues across it:

+24.149s agent_message_chunk  messageId=msg_011Ce2DuiMfV6dnSCJTBenb3   <- inside ```python
+24.269s tool_call            _meta.claudeCode.parentToolUseId=toolu_01Gg6...
+24.781s agent_message_chunk  messageId=msg_011Ce2DuiMfV6dnSCJTBenb3   <- same message

Both render identically today. In that single turn, 43 message chunks were interleaved with 6 tool calls and 17 tool-call updates; five interruptions fell inside a message. Background sub-agents make this the normal case rather than an edge case, because their tool calls are dispatched asynchronously while the main agent keeps writing.

Possible directions

The violated invariant: chunks carrying one messageId should render as one Markdown document.

  1. Carry the Markdown parse state across the split — a continuation block inherits open fences/lists from the previous block of the same message. Leaves entry order alone; no scroll implications.
  2. Do not let an interleaved tool call terminate the message it interrupts — anchor the card inside the message it belongs to.
  3. Resume the earlier entry by messageId. Simplest to state, but text then grows above a card that arrived earlier, which affects follow-tail scrolling while streaming and would have to be mirrored in thread persistence and Markdown export.

Sub-agent tool calls also carry attribution (_meta.claudeCode.parentToolUseId), so a client could keep them out of the main flow entirely — that is a display design question (discussions #62385 / #60099) and not a substitute for the fix: any other in-message interleaving would still break.

Zed version and system specs

Zed: v1.15.0 (Build 20260812.151413)
OS: macOS 26.5.2
Memory: 16 GiB
Architecture: arm64

Zed log

Nothing relevant — no errors are logged; the notifications are well-formed ACP and the text arrives complete. The defect is purely in how the entries are turned into Markdown documents.

zed-markdown-split-agent.mjs
#!/usr/bin/env node
/**
 * Minimal ACP agent that reproduces the Markdown-split bug in Zed's Agent Panel
 * without any model, network or third-party adapter. Node >= 18, no dependencies.
 *
 * Register it in Zed's settings.json and start a thread with it; any prompt
 * triggers the scripted turn below.
 *
 *   "agent_servers": {
 *     "acp-repro": {
 *       "type": "custom",
 *       "command": "node",
 *       "args": ["<absolute path to this file>"]
 *     }
 *   }
 *
 * The turn sends two assistant messages:
 *
 *   Message 1 (messageId "msg_repro_a") — THE BUG. Text chunks with an open
 *   ```python fence, then a tool_call, then more chunks OF THE SAME messageId
 *   that close the fence. Expected: one code block. Actual: the code block ends
 *   at the card, the remainder renders as prose, and the "### Heading" line
 *   inside the fence becomes a real heading.
 *
 *   Message 2 (messageId "msg_repro_b") — THE CONTROL. A tool_call at a genuine
 *   message boundary, i.e. the chunks after it carry a new messageId. Starting a
 *   new Markdown document is correct here, and it renders fine.
 *
 * The difference between the two is exactly what a client can key on.
 */

import readline from "node:readline";

const SESSION_ID = "repro-session-1";
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const update = (update) =>
  send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: SESSION_ID, update } });

const chunk = (messageId, text) =>
  update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text }, messageId });

const toolCall = (toolCallId, title, parentToolUseId) =>
  update({
    sessionUpdate: "tool_call",
    toolCallId,
    title,
    kind: "execute",
    status: "pending",
    rawInput: { command: title },
    ...(parentToolUseId ? { _meta: { claudeCode: { toolName: "Bash", parentToolUseId } } } : {}),
  });

const toolDone = (toolCallId, output) =>
  update({
    sessionUpdate: "tool_call_update",
    toolCallId,
    status: "completed",
    rawOutput: output,
    content: [{ type: "content", content: { type: "text", text: "```console\n" + output + "\n```" } }],
  });

/** The scripted turn. Delays only make the streaming visible; they are not
 *  load-bearing — the bug is about ordering, not timing. */
async function runTurn() {
  const a = "msg_repro_a";

  await sleep(200);
  chunk(a, "Case 1 — a tool call interleaved *inside* one streaming message.\n\nHere is the script:\n\n");
  await sleep(150);
  chunk(a, "```python\ndef first_half():\n    return 1\n\n");
  await sleep(150);
  chunk(a, "# ### This line is inside the fence and must stay code\n");

  // The interruption. In the real world this is a background sub-agent's tool
  // call, dispatched while the orchestrator keeps writing.
  await sleep(150);
  toolCall("call_1", "sleep 1 && date +%T", "task_parent_1");
  await sleep(400);
  toolDone("call_1", "12:00:01");

  // Same messageId as before the card: this is a continuation, not a new message.
  await sleep(150);
  chunk(a, "\ndef second_half():\n    return 2\n");
  await sleep(150);
  chunk(a, "```\n\nThis sentence follows the closing fence and belongs to the same message.\n\n");

  const b = "msg_repro_b";

  await sleep(200);
  chunk(b, "Case 2 — control: the tool call sits at a real message boundary.\n\n");
  await sleep(150);
  toolCall("call_2", "sleep 1 && date +%T");
  await sleep(400);
  toolDone("call_2", "12:00:02");

  const c = "msg_repro_c";
  await sleep(150);
  chunk(c, "```python\ndef whole_block():\n    return 3\n```\n\nThis block opens and closes inside one message and renders correctly.\n");
  await sleep(150);
}

readline.createInterface({ input: process.stdin }).on("line", async (line) => {
  if (!line.trim()) return;
  let msg;
  try {
    msg = JSON.parse(line);
  } catch {
    return;
  }
  const { id, method } = msg;
  if (id === undefined) return; // notification, nothing to answer

  switch (method) {
    case "initialize":
      return send({
        jsonrpc: "2.0",
        id,
        result: {
          protocolVersion: 1,
          agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } },
          agentInfo: { name: "acp-repro", title: "Markdown split repro", version: "1.0.0" },
          authMethods: [],
        },
      });
    case "session/new":
      return send({ jsonrpc: "2.0", id, result: { sessionId: SESSION_ID } });
    case "session/prompt":
      await runTurn();
      return send({ jsonrpc: "2.0", id, result: { stopReason: "end_turn" } });
    default:
      return send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${method}` } });
  }
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:needs triagetriage is not complete: no `area:*` label, or no `severity:*`/`reach:*` for Bugs/Crashes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions