mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user