feat(acp-bridge): stream assistant text in position instead of one end-of-turn block - #370
Conversation
…d-of-turn block ACPAgent emits tool calls as they happen, but buffers every AgentMessageChunk for the whole turn and persists the joined text only at the end, as the FinishAction message on the turn's closing ActionEvent. In the conversation view that means everything the agent said appears after everything it did: one undifferentiated block, with no way to tell which narration belonged to which step. On a long turn that block is the only agent text there is. The SDK already supports streaming here — Conversation takes `token_callbacks` and LocalConversation forwards them to the agent per turn — the bridge just never subscribed. Subscribe, buffer the chunks, and forward each run of text as a MessageEvent immediately before the next event we relay, which puts the narration back between the tool calls it describes. The web transformer already renders agent-sourced MessageEvents as inline text, so no UI change is needed. Because the FinishAction message is the join of those same chunks, relaying it unchanged would repeat the entire turn's text. Blank it when it is an exact duplicate of what already went out: nothing server-side reads that field (no FinishAction/finish consumer exists outside the web transformer, which renders an empty message as nothing), and the text is still persisted in the events it was split into. Anything short of an exact match is left alone. Two details worth knowing: - ACPAgent invokes token callbacks with a plain `str`, though the SDK's `TokenCallbackType` alias describes a litellm `ModelResponseStream`. `_chunk_text` accepts either so this survives that being reconciled. - Chunks are masked individually, so a secret split across two of them is only matchable once joined — which is why the SDK re-masks at its own persistence boundary. Segments are a persistence boundary too, so they are re-masked with the conversation's own registry before being emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Important
One serialization issue to fix before merge: blanking the duplicated FinishAction message re-serializes the payload with json.dumps, which escapes Unicode and changes spacing compared to Pydantic's model_dump_json(). This corrupts non-ASCII text in the same event (e.g., reasoning content) once the message is blanked.
Reviewed changes
This PR wires the ACP bridge's Conversation token callbacks into a new _AssistantTextRelay, flushes buffered assistant text as synthetic MessageEvents before each forwarded SDK event, and blanks the duplicate FinishAction message when it matches the already-streamed text. It also extracts the event-dispatch logic into _dispatch_event so synthetic and SDK events share the same loop/thread handling, and adds focused tests plus a README note.
apps/acp-bridge/src/paca_acp_bridge/runner.py: adds_chunk_text,_AssistantTextRelay,_strip_duplicated_finish_message,_dispatch_event, and wirestoken_callbacksintoConversation.apps/acp-bridge/tests/test_runner.py: adds 8 tests covering chunk handling, ordering, duplicate blanking, masking, per-turn reset, and registry tolerance.apps/acp-bridge/README.md: documents the new streaming behavior.
I verified ruff check src/ and ruff format --check src/ pass. Full pytest could not be run in this environment because installing openhands-sdk timed out.
⚠️ Unicode escaping when blanking the FinishAction payload
json.dumps(data) defaults to ensure_ascii=True and separators=(", ", ": "), while the original event.model_dump_json() keeps Unicode unescaped and omits spaces. When _strip_duplicated_finish_message blanks a finish message, any non-ASCII characters in the same payload (e.g., reasoning_content) will be escaped, and the payload formatting no longer matches the SDK's output. Use json.dumps(data, ensure_ascii=False, separators=(",", ":")), or better, mutate the Pydantic event.action.message before the initial model_dump_json() so the payload is only serialized once.
ℹ️ Nitpicks
_strip_duplicated_finish_messagematchestool_name in (None, "finish"); since SDKActionEvent.tool_nameis a required string,Noneonly matches malformed payloads. Narrowing to"finish"would make the guard's intent clearer._AssistantTextRelay.take_pendingswallows masker exceptions atlogger.debugand then drops the text; a warning would make masking failures more visible.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
…n masking Follow-up to the inline-assistant-text change, from review feedback on the upstream PR. Blanking the duplicated FinishAction message parsed the already-serialized payload and re-encoded it with `json.dumps`, which escapes non-ASCII and re-spaces the JSON. That is lossless — the payload column is `jsonb`, so Postgres normalizes both away on insert — but it serialized the event twice and made the bridge's output diverge from the SDK's for no reason. Build the payload once, with Pydantic's own serializer, clearing the message on a copy: `ActionEvent` and `FinishAction` are both frozen, so it cannot be cleared in place, and the SDK keeps the event in conversation history, so it must not be mutated either way. A masking failure now drops the segment instead of emitting it. It previously fell through with `text` still holding the *unmasked* value and shipped it, which could persist a secret — the opposite of what the SDK does, where a masking failure is fatal to the turn (`_raise_masking_error`). Dropping the segment also leaves the accumulated text short of the FinishAction message, so that message is no longer treated as a duplicate and the text still reaches the conversation at the end of the turn, masked by the SDK. Also narrows the finish guard to `tool_name == "finish"`. SDK ActionEvents always carry a tool_name, so the previous `in (None, "finish")` only ever matched a malformed payload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — addressed in 59fea55. All three points are handled, though two of them differ from the review's description, so here's what I found. Serialization: fixed, but it wasn't corrupting anythingThe double serialization was real and is gone — the payload is now built once by Pydantic's serializer, with the message cleared on a copy. But '{"t": "café"}'::jsonb = '{"t":"café"}'::jsonb -- t
'{"t": "café"}'::jsonb ->> 't' -- café
'{"a": 1, "b": 2}'::jsonb = '{"a":1,"b":2}'::jsonb -- tSo no non-ASCII text was being lost, in The suggested fix isn't possible as written
Both models are frozen: In-place mutation would also be wrong independently of that: the SDK keeps this event in conversation history, so writing to it would edit the agent's own record of the turn. 59fea55 uses The masking nitpick was the actual bug here
It didn't drop the text — that's the part worth flagging. try:
text = self._mask(text)
except Exception:
logger.debug(...) # falls through, `text` still unmaskedOn a masker failure that could persist a secret in cleartext, which is the opposite of how the SDK treats the same failure (fatal to the turn, via It now fails closed: the segment is dropped and logged at
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This re-review covers the single new commit since the prior Pullfrog review (738c4fb). It fixes the serialization and masking-visibility concerns raised there.
- Replaced JSON re-serialization with a single Pydantic serialization.
_strip_duplicated_finish_messagebecame_event_payload; it now clears the duplicateFinishAction.messageon amodel_copyand callsevent.model_dump_json()once, so non-ASCII fields likereasoning_contentare no longer corrupted. - Preserved Unicode in synthetic
MessageEventpayloads. The inlinejson.dumps({"content": streamed})now passesensure_ascii=False. - Failed closed on masking errors.
_AssistantTextRelay.take_pendingnow logs a warning, drops an unmaskable segment, and leaves the closingFinishActionmessage intact so the SDK can mask it at the turn boundary. - Added focused tests for the fixes. New coverage verifies non-ASCII payload preservation, masking-failure fallback, and that only
tool_name == "finish"events are rewritten.
All open threads from the prior Pullfrog review were addressed and resolved.
Kimi K2 (free via Pullfrog for OSS) | 𝕏
pikann
left a comment
There was a problem hiding this comment.
LGTM! Thank you for the contribution! 🚀

Summary
All of an ACP agent's text arrives in one block at the end of the turn, after every tool call, instead of interleaved with the work it describes.
The cause is an asymmetry in
ACPAgent.session_update. Tool calls are emitted as they happen:Text is not — it is buffered and handed to a callback nothing had subscribed to:
It surfaces only at the end, in
_finalize_successful_turn:So the whole turn's narration is concatenated — with no separators — into a single
FinishActionmessage. On one conversation I traced, that was 13,253 characters in one event at index 620, following 618 tool-call events. You cannot tell which sentence went with which step, and there is no partial output while the turn runs.The SDK already supports this and the bridge simply never opted in:
Conversationacceptstoken_callbacks, andLocalConversationforwards them to the agent on every step (agent.step(self, on_event=..., on_token=self._on_token)). This subscribes a relay, buffers the chunks, and flushes each run as aMessageEventimmediately before the next event the bridge forwards.No UI or server change is required. The web transformer already renders agent-sourced
MessageEvents as inline text in event order, and bridge event ingestion passesevent_typestraight through.Type of Change
Other — behaviour change in how ACP conversation events are emitted. No API, schema, or UI change.
Why blanking the duplicate finish message is safe
The
FinishActionmessage is the join of the very chunks now sent asMessageEvents, so relaying both shows the entire turn's text twice.FinishAction/finishreference anywhere inservices/. It is rendered, not read.If you would rather not touch that field at all, the alternative is suppressing it in
conversation-to-thread-messages.tswhen the turn already produced streamed text. I chose the bridge because it keeps the change in one app and avoids persisting the same text twice — happy to switch if you prefer the presentation-layer fix.Two things reviewers should know
The token-callback argument type is inconsistent in the SDK.
TokenCallbackTypeisCallable[[LLMStreamChunk], None](a litellmModelResponseStream), but the ACP path callsself.on_token(text)with a plainstr._chunk_texthandles both so this keeps working whichever way that is reconciled.Masking. Chunks are masked individually, so a secret split across two of them only becomes matchable once joined — exactly why the SDK re-masks at its own persistence boundary. Segments are a persistence boundary too, so each is re-masked with the conversation's own
secret_registrybefore emission. Residual gap: a secret split across a segment boundary (interrupted by a tool call) still would not match — the same class of limitation the SDK has across turns.Verification
8 tests added to
test_runner.py, 31 pass total:MessageEventbefore the following event, correctly orderedbind_maskertolerates a conversation without a registry_chunk_textaccepts astr, a stream chunk, and neitherruff check src/andruff format --check src/clean.Checklist
_make_event_callbackmoved into_dispatch_eventso the synthetic and real events share one path, keeping the loop/thread handling identical — the two existing callback tests cover it unchanged.apps/acp-bridge/README.md.🤖 Generated with Claude Code