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:
parent
ed1e289785
commit
0daa4d21b0
|
|
@ -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}],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue