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
@@ -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.