fix(review): avoid double broadcast when stream breaks mid-way

Code review finding (P2): when chat_stream successfully emits the first
chunk (broadcasted to UI) but then breaks during subsequent chunks, the
fallback path called gateway.chat() and broadcasted the FULL content
via _replay_stream — causing the UI to see partial streamed content
followed by duplicated full content.

Fix: if `total` already contains partial content from first_chunk,
return it directly without calling gateway.chat(). Partial content is
better than duplicated content. The non-streaming fallback only runs
when no chunks were broadcasted (total is empty).

Add test_stream_breaks_after_first_chunk_returns_partial covering this
edge case: first chunk yields "部分内容", second __anext__ raises
RuntimeError. Asserts result is the partial content, gateway.chat() is
never called, and broadcast_event is called exactly once (for the
first chunk only).
This commit is contained in:
Chiguyong 2026-07-06 15:53:27 +08:00
parent ed1e289785
commit 0daa4d21b0
2 changed files with 57 additions and 0 deletions

View File

@ -424,6 +424,17 @@ class BoardOrchestrator:
# Fallback: non-streaming path. Emit the whole content as small
# chunks so the UI still renders progressively rather than going
# silent and then dumping the whole text in one frame.
#
# If we already broadcasted partial content from first_chunk before
# the stream broke, do NOT call gateway.chat() — that would broadcast
# the full content again and the UI would see duplicated text.
# Return the partial content instead; partial is better than duplicated.
if total:
logger.info(
f"Expert '{expert.config.name}' stream broke after {len(total)} chars "
f"of partial content — returning partial (avoid double broadcast)"
)
return total.strip()
try:
response = await gateway.chat(
messages=[{"role": "user", "content": prompt}],

View File

@ -459,3 +459,49 @@ class TestStreamExpertSpeechFallback:
assert "fallback 内容" in result
gateway.chat.assert_called_once()
@pytest.mark.asyncio
async def test_stream_breaks_after_first_chunk_returns_partial(self):
"""首 chunk 成功但后续断流时返回部分内容,不调 chat() 避免双重广播。"""
class _BreakAfterFirstStream:
"""首个 chunk 正常返回,第二个 __anext__ 抛异常模拟断流。"""
def __init__(self):
self._first_yielded = False
async def __anext__(self):
if not self._first_yielded:
self._first_yielded = True
chunk = MagicMock()
chunk.content = "部分内容"
return chunk
raise RuntimeError("stream broke mid-way")
def __aiter__(self):
return self
async def aclose(self):
pass
team = BoardTeam()
orchestrator = BoardOrchestrator(team=team)
expert = _make_mock_expert("tester", is_lead=False)
gateway = _make_mock_gateway("完整 fallback 内容")
gateway.chat_stream = MagicMock(return_value=_BreakAfterFirstStream())
expert.agent._llm_gateway = gateway
orchestrator._get_llm_gateway = lambda e: gateway
with patch.object(
orchestrator, "_broadcast_event", new_callable=AsyncMock
) as mock_broadcast:
result = await orchestrator._stream_expert_speech(expert, 1, "test prompt")
# 返回部分内容,而不是完整 fallback 内容
assert "部分内容" in result
assert "完整 fallback" not in result
# 不应调 chat()(避免双重广播)
gateway.chat.assert_not_called()
# 只广播了 first_chunk 的部分内容
assert mock_broadcast.call_count == 1