Summary
Reading a supported PNG can emit a valid session.tool.success event larger than the generated client's 1 MiB SSE buffer limit. Because /api/event is global, one image read disconnects every connected TUI with MalformedResponse, including TUIs displaying unrelated sessions.
The observed events also contained the image base64 twice: once in structured output and once in model content.
Environment
- opencode version:
0.0.0-next-15328
- OS: Darwin 25.5.0, arm64
- Terminal: WarpTerminal (
TERM=xterm-256color, COLORTERM=truecolor)
- Shell:
/bin/zsh
- Install/channel: global Bun installation,
next
- Active plugins:
share-with-team.ts, v2 debug workspace plugin, @warp-dot-dev/opencode-warp
Reproduction
-
Start multiple v2 TUIs connected to the same managed service.
-
In one session, use the built-in read tool on a supported PNG of roughly 750 KiB:
read({ path: "/tmp/example.png" })
-
Observe every connected TUI, including TUIs displaying unrelated sessions.
The incident produced this durable sequence:
| Seq |
Event |
Stored payload |
| 611 |
session.tool.input.ended.1 |
200 bytes |
| 612 |
session.tool.called.1 |
287 bytes |
| 613 |
session.tool.success.1 |
2,007,317 bytes |
| 614 |
session.step.ended.1 |
277 bytes |
All nine clients parsed sequence 612, then disconnected within 20 ms while receiving sequence 613:
event stream disconnected component=sdk attempt=1 error=MalformedResponse role=cli
A second image read reproduced it approximately 25 seconds later:
| Seq |
Event |
Stored payload |
| 648 |
session.tool.called.1 |
276 bytes |
| 649 |
session.tool.success.1 |
2,009,268 bytes |
| 650 |
session.step.ended.1 |
277 bytes |
Expected Behavior
- Every event the server can legitimately emit should be consumable by the generated client.
- Reading an image in one session should not disconnect unrelated clients.
- Image bytes should not be duplicated in one durable event.
- If an event is genuinely too large, the error should identify the size violation rather than report malformed data.
Actual Behavior
The server successfully persists and broadcasts an approximately 2 MiB valid SSE event. Every generated client rejects it after its local buffer exceeds 1 MiB and displays MalformedResponse.
The image is duplicated in the success event:
structured JSON: approximately 1,003,562 bytes
model content: approximately 1,003,572 bytes
complete event: 2,007,317 bytes
The two observed image payloads were 1,003,412 and 1,004,404 base64 characters respectively.
Additional Context
Client limit
packages/client/src/promise/generated/client.ts checks the accumulated SSE buffer before parsing complete event boundaries:
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
The server has no matching per-event limit. HTTP can deliver the valid 2 MiB SSE block over multiple reads, but the client throws once its accumulated block crosses 1 MiB.
Duplicate image data
The built-in read tool returns structured image data containing base64. Its toModelOutput projection creates a file content item from the same base64. Tool settlement preserves both representations in session.tool.success.
The observed structured output had these fields:
content, encoding, mime, name, uri
The same image was also present in content[].uri as a data:image/png;base64,... attachment.
Output bounding gap
ToolOutputStore.bound measures structured output only when model content is empty. If model content exists, it measures only text items:
const contextual =
input.output.content.length === 0
? JSON.stringify(input.output.structured, null, 2)
: text.map((item) => item.text).join("")
For an image read, the measured text is effectively only Image read successfully; neither the base64 in structured output nor the media attachment is included. The approximately 2 MiB event therefore bypasses the configured 50 KiB generic output bound.
Even if text bounding were triggered, the current result preserves structured output and media.
Existing test gap
packages/core/test/session-runner-tool-events.test.ts has a test named local tool success serializes media base64 once and reconstructs from structured content, but its structured fixture contains only small metadata. It does not use the real read output shape, where structured output itself contains the base64.
Suggested fix
Immediate:
- Ensure image base64 is serialized at most once in a durable tool-success event.
- Define a protocol-level maximum SSE event size that both producer and generated client enforce.
- Parse complete SSE boundaries before applying the incomplete-buffer guard.
- Report a specific
EventTooLarge error including received and maximum sizes.
- Add a regression test using the production-shaped
read output and multiple subscribers.
Longer term:
Persist media through ToolOutputStore as an opaque managed artifact and broadcast a reference rather than base64 bytes. This avoids base64 expansion, SQLite/event-log bloat, and globally broadcasting large media to every TUI.
This is distinct from #31281, which concerned an upstream idle timeout while processing large tool results. This failure is a deterministic generated-client rejection of a valid oversized SSE event.
Summary
Reading a supported PNG can emit a valid
session.tool.successevent larger than the generated client's 1 MiB SSE buffer limit. Because/api/eventis global, one image read disconnects every connected TUI withMalformedResponse, including TUIs displaying unrelated sessions.The observed events also contained the image base64 twice: once in structured output and once in model content.
Environment
0.0.0-next-15328TERM=xterm-256color,COLORTERM=truecolor)/bin/zshnextshare-with-team.ts, v2 debug workspace plugin,@warp-dot-dev/opencode-warpReproduction
Start multiple v2 TUIs connected to the same managed service.
In one session, use the built-in
readtool on a supported PNG of roughly 750 KiB:Observe every connected TUI, including TUIs displaying unrelated sessions.
The incident produced this durable sequence:
session.tool.input.ended.1session.tool.called.1session.tool.success.1session.step.ended.1All nine clients parsed sequence 612, then disconnected within 20 ms while receiving sequence 613:
A second image read reproduced it approximately 25 seconds later:
session.tool.called.1session.tool.success.1session.step.ended.1Expected Behavior
Actual Behavior
The server successfully persists and broadcasts an approximately 2 MiB valid SSE event. Every generated client rejects it after its local buffer exceeds 1 MiB and displays
MalformedResponse.The image is duplicated in the success event:
The two observed image payloads were 1,003,412 and 1,004,404 base64 characters respectively.
Additional Context
Client limit
packages/client/src/promise/generated/client.tschecks the accumulated SSE buffer before parsing complete event boundaries:The server has no matching per-event limit. HTTP can deliver the valid 2 MiB SSE block over multiple reads, but the client throws once its accumulated block crosses 1 MiB.
Duplicate image data
The built-in
readtool returns structured image data containing base64. ItstoModelOutputprojection creates a file content item from the same base64. Tool settlement preserves both representations insession.tool.success.The observed structured output had these fields:
The same image was also present in
content[].urias adata:image/png;base64,...attachment.Output bounding gap
ToolOutputStore.boundmeasures structured output only when model content is empty. If model content exists, it measures only text items:For an image read, the measured text is effectively only
Image read successfully; neither the base64 in structured output nor the media attachment is included. The approximately 2 MiB event therefore bypasses the configured 50 KiB generic output bound.Even if text bounding were triggered, the current result preserves structured output and media.
Existing test gap
packages/core/test/session-runner-tool-events.test.tshas a test namedlocal tool success serializes media base64 once and reconstructs from structured content, but its structured fixture contains only small metadata. It does not use the realreadoutput shape, where structured output itself contains the base64.Suggested fix
Immediate:
EventTooLargeerror including received and maximum sizes.readoutput and multiple subscribers.Longer term:
Persist media through
ToolOutputStoreas an opaque managed artifact and broadcast a reference rather than base64 bytes. This avoids base64 expansion, SQLite/event-log bloat, and globally broadcasting large media to every TUI.This is distinct from #31281, which concerned an upstream idle timeout while processing large tool results. This failure is a deterministic generated-client rejection of a valid oversized SSE event.