[BUG] Text after a tool result isn't streamed (arrives in one burst) when thinking is enabled

Status Open
Reported on v2.1.280
Maintainer reply None cached
Activity 5 comments · opened Sep 23, 2026

What's Wrong?

With --include-partial-messages, when thinking is enabled (adaptive thinking), the assistant text that follows a tool result isn't streamed progressively. Claude Code emits no text for about 10 seconds after the tool result. Then almost the whole reply arrives as text_delta events within 0.3–1 s.

The first response in a turn, before any tool call, streams normally with the same model and effort. With thinking disabled, text after a tool result also streams normally. The silent gap is roughly as long as generating the text takes when it streams normally, which suggests the text is generated during the gap and held back, not streamed.

Downstream UIs built on stream-json (for example Paseo desktop and mobile) show every post-tool reply appearing all at once.

What Should Happen?

text_delta events after a tool result should arrive progressively as the text is generated, the same way they do before a tool call or when thinking is disabled.

Steps to Reproduce

claude -p "Run the shell command 'echo hi' once using your Bash tool. After it finishes, write a plain-text story of about 400 words about a lighthouse keeper. No tools after the first one." \
  --model claude-opus-5-5 --effort high \
  --output-format stream-json --include-partial-messages --verbose \
  --permission-mode bypassPermissions < /dev/null

Timestamp each stdout line, then look at the stream_event / content_block_delta / text_delta events after the tool_result user message.

Measurements

Timings are seconds from process start. Share in 0.3 s is the largest fraction of post-tool text characters that arrived within any 0.3 s window. A value near 1.0 means the text arrived in one burst.

Claude Code CLI directly (no wrapper):

| Model | Thinking | Tool call | Tool result → first text | Text span | Share in 0.3 s |
|---|---|---|---|---|---|
| opus-5-5 | effort high | yes | 10.9 s | 0.9 s | 0.94 |
| opus-5-5 | effort high | yes (repeat) | 9.6 s | 1.0 s | 0.90 |
| opus-5-5 | effort low | yes | 9.6 s | 1.2 s | 0.92 |
| opus-5-5 | effort high + --thinking-display summarized | yes | 10.4 s | 1.2 s | 0.88 |
| sonnet-5 | effort high | yes | 10.4 s | 2.5 s | 0.81 |
| sonnet-5 | effort high + --thinking-display summarized | yes | 11.7 s | 1.3 s | 0.91 |
| sonnet-5 | --thinking disabled | yes | 1.4 s | 11.4 s | 0.05 |
| haiku-4-5 | default | yes | 1.3 s | 6.7 s | 0.07 |
| opus-5-5 | effort high | no | n/a | 4.8 s | 0.09 |

The same pattern shows up through Paseo, which uses the Agent SDK with includePartialMessages: true. Opus 5.5 high with a tool call: 0.75. Sonnet 5 high with a tool call: 0.80. Sonnet 5 with thinking off: 0.04. Opus 5.5 high without a tool call: 0.06. A Codex agent on the same host streams normally after a tool call: 0.07.

Summary:

  • It happens only when thinking is enabled and the text follows a tool result.
  • It happens on Opus 5.5 and Sonnet 5, at effort low and high.
  • --thinking-display summarized doesn't change it.
  • It reproduces with the plain CLI, so it isn't caused by a wrapper.

I haven't tested the raw Messages API directly (subscription login only), so I can't tell whether the buffering happens in Claude Code or upstream.

Error Messages/Logs

None. Every run exits 0 with complete output.

Is this a regression?

I don't know.

Claude Code Version

2.1.280

Platform

Anthropic API (first-party, claude.ai subscription OAuth login)

Operating System

macOS 27.2 (arm64, Mac mini)

Terminal/Shell

zsh (headless -p / stream-json)

View original on GitHub ↗

4 Comments

jtalmi · 17 days ago

I can reproduce this against the raw Messages API, so it isn't Claude Code. I used plain HTTP (Python http.client and curl -N) straight to api.anthropic.com, with no SDK or proxy, and timestamped each SSE line as it arrived.

After a tool_result, the API holds roughly the first 2,100 characters of text, sends them in one burst, then streams the rest normally. The cut lands mid-sentence (2,099 / 2,115 / 2,089 chars across 3 runs), so it looks like a fixed-size buffer, not a semantic boundary. Most replies after a tool call are shorter than that, so they arrive in one piece. When no thinking block comes first, even the HTTP headers wait for the buffer. On Opus 5.5 the headers and an empty thinking block arrive early, and the text is held.

It lines up exactly with the models that write progress updates between tool calls:

| Model | Reply after a tool_result |
|---|---|
| Sonnet 5.5, Opus 5.5, Fable 5.1, Fable 5 | held, then burst |
| Opus 5, Sonnet 5, Opus 4.8, Sonnet 4.6, Haiku 4.5 | streams |

Things that don't change it:

  • A first response with no tool call streams normally on every model.
  • thinking omitted, display: "summarized", display: "updates", between_tools, effort medium and high, and eager_input_streaming all still hold.
  • Built-in web_search (one request, no client tool round trip) holds too.
  • Every host I could reach holds. Through OpenRouter I tried Anthropic, Claude Platform on AWS, Vertex, Bedrock and Azure.
  • Many of the held turns report thinking_tokens: 0, so the wait isn't thinking.

One difference from your table: Sonnet 5 streams fine against the raw API for me at effort medium and high, so its burst in Claude Code may be something separate.

Matched pairs, same prompt and settings:

  • Sonnet 5.5 held: req_011CfWPc37GgV44S9iwFoEui. Sonnet 5 streamed: req_011CfWPcbEoHKgWFG2Mdik7Y
  • Opus 5.5 held: req_011CfWSK9TtGPnGmitLkFV6j. Opus 5 streamed: req_011CfWSYK8fhtkVR2KWbJi56

Repro (swap MODEL to compare). On claude-sonnet-5-5 nothing arrives for about 6s, then the whole reply lands within 20ms. On claude-sonnet-5 it streams over about 7s.

MODEL=claude-sonnet-5-5
BODY='{"model":"'$MODEL'","max_tokens":4000,"output_config":{"effort":"medium"},
"tools":[{"name":"get_weather","description":"Get the current weather for a city.","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}],
"messages":[{"role":"user","content":"Check the weather in Paris, then write about 250 words on what to pack for a day of sightseeing."}]}'

FIRST=$(curl -s https://api.anthropic.com/v1/messages -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" -H "content-type: application/json" -d "$BODY")

echo "$BODY" | jq --argjson first "$FIRST" '.stream = true | .messages += [
  {role:"assistant", content:$first.content},
  {role:"user", content:[$first.content[] | select(.type=="tool_use") |
    {type:"tool_result", tool_use_id:.id, content:"14°C, light rain in the morning, clearing by 2pm, wind 15 km/h."}]}]' |
curl -sN https://api.anthropic.com/v1/messages -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" -H "content-type: application/json" -d @- |
perl -MTime::HiRes=time -ne 'BEGIN{$|=1; $t=time} chomp; next unless /^data:/; printf "%6.2fs  %s\n", time-$t, substr($_,6,110)'

The thinking docs mention "a pause of several seconds before a progress-update block opens", but nothing about holding the reply itself. In a chat UI, any reply under ~2,100 characters that follows a tool call shows up all at once.

NikoLandgraf · 16 days ago

I have the same issue. Hard to believe that nobody at Anthropic is realizing this bug. That makes the models basically unusable for user facing chat use cases. Is there a better place to raise this issue ?

tomfrew · 16 days ago

I can reproduce this against the raw Messages API (no Claude Code or Agent SDK involved), and it also affects claude-sonnet-5-5. On Sonnet 5.5 it happens even with thinking at its lowest setting, so disabling thinking isn't an available workaround there.

Setup: Node fetch straight to api.anthropic.com/v1/messages with stream: true. It's the same through Cloudflare AI Gateway.

Minimal repro: a request whose history ends in a tool_result. Default settings, no thinking field:

// node repro.mjs [model]   — needs ANTHROPIC_API_KEY, Node 18+
const model = process.argv[2] ?? "claude-sonnet-5-5";
const messages = [
  { role: "user", content: "Look up my recent orders with the tool, then write me a ~250 word summary with a short table." },
  { role: "assistant", content: [{ type: "tool_use", id: "toolu_01", name: "listOrders", input: {} }] },
  { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_01",
    content: JSON.stringify({ orders: [{ id: 1, total: 120, status: "shipped" }, { id: 2, total: 80, status: "pending" }] }) }] },
];
const t0 = Date.now();
const res = await fetch("https://api.anthropic.com/v1/messages", {
  method: "POST",
  headers: { "content-type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": process.env.ANTHROPIC_API_KEY },
  body: JSON.stringify({
    model, max_tokens: 4000, stream: true, messages,
    tools: [{ name: "listOrders", description: "List recent orders", input_schema: { type: "object", properties: {} } }],
  }),
});
console.log(`${model}: headers after ${Date.now() - t0}ms`);
const reader = res.body.getReader(), dec = new TextDecoder();
let buf = "", chunks = 0, first = 0, last = 0, n = 0;
for (;;) {
  const { done, value } = await reader.read(); if (done) break; chunks++;
  buf += dec.decode(value, { stream: true });
  for (let i; (i = buf.indexOf("\n\n")) >= 0; buf = buf.slice(i + 2)) {
    const data = buf.slice(0, i).split("\n").find((l) => l.startsWith("data: "));
    const ev = data && JSON.parse(data.slice(6));
    if (ev?.type === "content_block_delta" && ev.delta.type === "text_delta") { n++; first ||= Date.now() - t0; last = Date.now() - t0; }
  }
}
console.log(`${n} text_delta events from ${first}ms to ${last}ms (span ${last - first}ms), ${chunks} network reads`);

Output:

claude-sonnet-5-5: headers after 1072ms
125 text_delta events from 6023ms to 6027ms (span 4ms), 7 network reads
claude-sonnet-5: headers after 974ms
113 text_delta events from 1416ms to 6213ms (span 4797ms), 109 network reads

Test matrix: same prompt, direct API. "Burst" means about 5 seconds of silence, then every text delta within about 1–30ms.

| | no tools | tools declared, no tool result yet | tool_result in history |
|---|---|---|---|
| Sonnet 5.5, between_tools | streams | streams | burst |
| Sonnet 5.5, adaptive (omitted / summarized) | streams | streams | burst |
| Sonnet 5.5, adaptive + display: "updates" (beta) | – | – | burst |
| Sonnet 5.5, server-side web_search (in-turn) | – | – | burst (4.9s gap, then all text within 616ms) |
| Sonnet 5, disabled or adaptive medium | streams | streams | streams |

Replaying the previous assistant turn exactly as returned, or with thinking blocks removed, makes no difference.

Event trace of a real three-step turn (Sonnet 5.5, display: "updates", ms from request start):

step 1 [tool_use]  headers@1046 → message_start@1047 → tool_use … message_stop@1597
step 2 [tool_use]  headers@759  → message_start@760  → tool_use, tool_use … message_stop@1085
step 3 [end_turn]  headers@4308 → message_start@4309 → text (109 deltas in 16ms) → message_stop@4325

Two things stand out:

  • Nothing is sent during the gap. Not the response headers, message_start or a ping, so a client can't tell the request is alive.
  • Only a final answer after a tool result is held back. Steps that end in another tool_use start within about a second, and a short text block right before a tool_use arrives on time.

One difference from the original report: in my tests Sonnet 5 streams normally after a tool result, even with adaptive thinking at medium. Only Sonnet 5.5 bursts. That may depend on how much thinking actually happens in the turn.

For any chat UI that streams answers after tool calls, this looks like the app has hung, and then the whole message appears at once. Is this expected behaviour on the newer models? If so, is there a request option to stream the final text as it's generated?

rohanlad · 16 days ago

Three things we measured on the raw Messages API (Python SDK, claude-sonnet-5-5, default thinking, one client tool) that may help narrow this down or work around it.

1. It depends on where the tool_result sits, not on whether history contains one.

| History | First text | Pattern |
|---|---|---|
| A. ends in a tool_result | 5.2-5.5s | whole reply in one burst |
| B. the same tool pair in an earlier, finished turn (assistant text, then a new user message) | 0.9-1.0s | streams |
| C. tool_result, then the question as a text block in the same user message | 5.1-5.4s | burst |
| D. the same information as plain text, no tool pair | 1.0s | streams |
| claude-sonnet-5, case A | 0.8-1.3s | streams |

Request ids: A req_011CfXyedu4TPjttTfhiGnSB, req_011CfXyf1pgL4mAiqQqhTwgK; B req_011CfXyfRUUdmSXqEEmstabx; C req_011CfXyg8ACnQww3LtPboN5f; D req_011CfXygtqYME8tyeRaUtWML; Sonnet 5 A req_011CfXyhsXgtp6HtZqbrumLd.

2. The memory tool puts every first reply behind the hold. With memory_20250818, the API adds "ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE DOING ANYTHING ELSE" to the system prompt. So Claude calls view /memories even for a request that needs nothing from memory, and the answer that follows is held: 4.8s and 5.1s of silence, then all the text at once (req_011CfXyopdUXpQokJTB7wqic, req_011CfXypFvjxkQpGsQ9J7kJZ). In a chat product with memory on, that's the first reply of every conversation.

3. A workaround for context your app fetches itself. If you pre-run a tool and inject the call and its result into the history, to save the model a round trip, send the result as plain text in the user message instead (case D). On our production request this took time to first text from about 9s to about 2.7s, and the model still used the context. It doesn't help replies after tool calls the model makes on its own.

We also saw message_start held in some runs (5.2s) and sent at once in others (0.9s) with identical requests, so it can't be relied on as a sign the request is alive.

Showing cached comments. Read the full discussion on GitHub ↗