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.
- Save
zed-markdown-split-agent.mjs (full source at the bottom of this issue).
- Register it in
settings.json:
"agent_servers": {
"acp-repro": { "type": "custom", "command": "node", "args": ["<path>/zed-markdown-split-agent.mjs"] }
}
- Open the Agent Panel, pick
acp-repro, send any prompt (every prompt triggers the same scripted turn).
- 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.
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.
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.
- 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.
- Do not let an interleaved tool call terminate the message it interrupts — anchor the card inside the message it belongs to.
- 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}` } });
}
});
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.
zed-markdown-split-agent.mjs(full source at the bottom of this issue).settings.json:acp-repro, send any prompt (every prompt triggers the same scripted turn).Case 1 streams one assistant message (
messageId: msg_repro_a) that opens a```pythonfence, then atool_callarrives, 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 newmessageId.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 2renders 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
messageIdrender 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.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.
Where it happens
AcpThread::push_assistant_content_block_with_message_id(crates/acp_thread/src/acp_thread.rs:2770-2863onmain) appends to the previous assistant message only whenself.entries.last_mut()is anAssistantMessage. AToolCallentry in between ends that lookup, so the fallback branch pushes a freshAssistantMessagewith a newContentBlock;streaming_markdown_target(2866) likewise inspects only the last entry. EachContentBlockparses its text independently, so no fence, list or table state survives the split.messageIdis available in that function and already drivescan_merge_message_chunkswhen 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
messageIdchanges across it:An asynchronous background sub-agent's tool call is not — the same message continues across it:
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
messageIdshould render as one Markdown document.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 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