fix(intake): emit the complete reply when no text deltas streamed (#665)

The intake/secretary chat driver treated StreamEvent text deltas as the
ONLY text channel: AssistantMessage TextBlocks were always swallowed as
already-streamed (the double-render guard). The CLI's partial-message
emission turned out to be remotely gated — on 2026-07-23 the NAS
containers got zero stream_event lines from the identical binary,
flags, SDK, model, and settings that stream fine elsewhere — so the
guard became a total blackout: replies were generated, the relay
carried only init/status/turn_end, and the CEO saw nothing.

SdkIntakeSession.send now tracks whether any text delta arrived during
the turn; normalize() emits an AssistantMessage's complete text only
when none did. Streaming mode is byte-identical (deltas render live,
completes stay suppressed); gated mode delivers the reply as one block
instead of nothing. Covers Secretary (same machinery). Regression
tests: fallback emission, default suppression, and both modes
end-to-end through the session layer.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-23 18:22:18 +02:00
committed by GitHub
co-authored by Renn F
parent d87ce2ca11
commit 0c1d450a05
2 changed files with 90 additions and 6 deletions
+32 -6
View File
@@ -235,12 +235,20 @@ def _block_to_chunk(
return None, None, None
def _blocks_to_chunks(content: list[Any]) -> list[StreamChunk]:
def _blocks_to_chunks(
content: list[Any], *, emit_text: bool = False
) -> list[StreamChunk]:
"""Map an assistant message's content blocks to chunks (duck-typed).
Text is deliberately NOT re-emitted here: with ``include_partial_messages``
Text is normally NOT re-emitted here: with ``include_partial_messages``
the live token deltas (``StreamEvent``) already streamed it, so re-emitting
the complete ``TextBlock`` would render every reply twice on the panel.
``emit_text=True`` is the no-deltas fallback — the CLI's partial-message
emission is remotely gated and can silently vanish (2026-07-23 live
incident: zero ``stream_event`` lines in the NAS containers while the
identical binary streams elsewhere), and without this fallback the
double-render guard becomes a total blackout: replies are generated but
never reach the panel.
The canonical draft signal is the agent calling the **``propose_draft``**
tool — that ToolUseBlock becomes a single ``draft`` chunk. As a fallback (if
@@ -257,6 +265,8 @@ def _blocks_to_chunks(content: list[Any]) -> list[StreamChunk]:
chunks.append(chunk)
if text_part is not None:
text_parts.append(text_part)
if emit_text and text_part:
chunks.append(StreamChunk(kind="text", text=text_part))
draft = draft or block_draft
draft = draft or _extract_draft("".join(text_parts))
if draft is not None:
@@ -275,17 +285,23 @@ def _stream_event_to_chunks(msg: Any) -> list[StreamChunk]:
return []
def normalize(msg: Any) -> list[StreamChunk]:
def normalize(msg: Any, *, text_already_streamed: bool = True) -> list[StreamChunk]:
"""Map a single ``claude-agent-sdk`` message to panel-facing chunks.
Duck-typed on type name + attributes so it works on real SDK messages and
on test fakes alike (no SDK import required).
on test fakes alike (no SDK import required). ``text_already_streamed``
is the caller's per-turn "any text delta seen yet?" tracker: when False,
an AssistantMessage emits its complete text as chunks (the no-deltas
fallback — see ``_blocks_to_chunks``); the default preserves the
suppress-always behavior for callers that don't track.
"""
name = type(msg).__name__
if name == "StreamEvent":
return _stream_event_to_chunks(msg)
if name == "AssistantMessage":
return _blocks_to_chunks(getattr(msg, "content", []))
return _blocks_to_chunks(
getattr(msg, "content", []), emit_text=not text_already_streamed
)
if name == "ResultMessage":
return [
StreamChunk(
@@ -617,6 +633,16 @@ class SdkIntakeSession: # pragma: no cover - requires the live claude binary
async def send(self, text: str) -> AsyncIterator[StreamChunk]:
await self._client.query(text)
# Per-turn delta tracker: once a live text delta arrives, complete
# AssistantMessage text stays suppressed (double-render guard); if
# none ever do (the remotely-gated no-partial-messages mode), the
# complete text is emitted instead so replies still reach the panel.
saw_text_delta = False
async for msg in self._client.receive_response():
for chunk in normalize(msg):
chunks = normalize(msg, text_already_streamed=saw_text_delta)
if type(msg).__name__ == "StreamEvent" and any(
c.kind == "text" for c in chunks
):
saw_text_delta = True
for chunk in chunks:
yield chunk
@@ -16,6 +16,7 @@ import pytest
from roboco.agent_sdk.intake_driver import (
_INTAKE_BASE_TOOLS,
IntakeDriver,
SdkIntakeSession,
StreamChunk,
normalize,
)
@@ -98,6 +99,63 @@ def test_normalize_assistant_message_blocks() -> None:
assert chunks[1].data["input"] == {"file": "metrics.tsx"}
def test_normalize_assistant_message_emits_text_when_no_deltas_streamed() -> None:
"""The no-deltas fallback (2026-07-23 incident): when the CLI's remotely
gated partial-message emission is off, no StreamEvent ever carries the
reply — the AssistantMessage's complete text must then be emitted instead
of being swallowed by the double-render guard."""
msg = AssistantMessage([TextBlock("hello there"), ThinkingBlock("hmm")])
chunks = normalize(msg, text_already_streamed=False)
assert [c.kind for c in chunks] == ["text", "thinking"]
assert chunks[0].text == "hello there"
def test_normalize_assistant_message_default_still_suppresses_text() -> None:
msg = AssistantMessage([TextBlock("hello there")])
assert normalize(msg) == []
@pytest.mark.asyncio
async def test_sdk_session_send_falls_back_to_complete_text_without_deltas() -> None:
"""End-to-end through SdkIntakeSession.send: a turn with zero StreamEvents
yields the reply text from the AssistantMessage; a turn WITH deltas keeps
the complete text suppressed (no double render)."""
class _FakeClient:
def __init__(self, messages: list) -> None:
self._messages = messages
async def query(self, _text: str) -> None: ...
async def receive_response(self) -> AsyncIterator[object]:
for m in self._messages:
yield m
# Gated mode: no StreamEvents at all.
session = SdkIntakeSession(options=None)
session._client = _FakeClient(
[
SystemMessage("init"),
AssistantMessage([TextBlock("the reply")]),
ResultMessage("s1"),
]
)
kinds = [(c.kind, c.text) async for c in session.send("hi")]
assert ("text", "the reply") in kinds
# Streaming mode: deltas first — complete text stays suppressed.
session._client = _FakeClient(
[
StreamEvent({"delta": {"type": "text_delta", "text": "the "}}),
StreamEvent({"delta": {"type": "text_delta", "text": "reply"}}),
AssistantMessage([TextBlock("the reply")]),
ResultMessage("s2"),
]
)
texts = [c.text async for c in session.send("hi") if c.kind == "text"]
assert texts == ["the ", "reply"]
def test_normalize_assistant_message_extracts_draft_block() -> None:
# A finished reply that ends with a fenced roboco-draft block yields a
# single `draft` chunk carrying the parsed object — and no `text` chunk.