fix(backends,markdown): stream turn events live, no blank lines for tool blocks

This commit is contained in:
hh
2026-08-28 17:27:35 +02:00
parent fd8cbacd7b
commit 7ae87aeeb8
4 changed files with 121 additions and 15 deletions
+28 -13
View File
@@ -296,15 +296,21 @@ class ClaudeSdkBackend:
live = await self._acquire(key, session_id=session_id, history=prior, spec=spec)
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=self._agent.model)
turn = _Turn()
async with live.lock:
live.running_turn = turn_id or message_id
live.last_used = time.monotonic()
try:
turn = await self._run_turn(live, prompt, observer=observer)
# Events go out as the CLI produces them: the frontends
# stream text and thinking live, the turn is not buffered.
async for event in self._run_turn(live, prompt, turn, observer):
yield event
except Exception:
live.running_turn = None
await self._pool.close(key)
if not (live.resumed and live.turns == 0):
# A dead resume can be reseeded from history, but only
# while nothing of this turn has reached the caller yet.
if not (live.resumed and live.turns == 0) or turn.events:
raise
_log.exception(
"resume of %s failed, reseeding from history", live.session_id
@@ -312,11 +318,11 @@ class ClaudeSdkBackend:
live = await self._acquire(
key, session_id=None, history=prior, spec=spec
)
turn = _Turn()
async with live.lock:
live.running_turn = turn_id or message_id
turn = await self._run_turn(live, prompt, observer=observer)
for event in turn.events:
yield event
async for event in self._run_turn(live, prompt, turn, observer):
yield event
live.turns += 1
live.last_used = time.monotonic()
live.running_turn = None
@@ -373,11 +379,16 @@ class ClaudeSdkBackend:
self,
live: Session,
prompt: str,
*,
turn: _Turn,
observer: Callable[[Any], None] | None = None,
) -> _Turn:
) -> AsyncIterator[MessageStreamEvent]:
"""Run one prompt, yielding wire events as they arrive.
``turn`` is filled in place (result, synthesized history, count of
events already yielded) so the caller can finish bookkeeping - and
decide whether a retry is still possible - after a failure.
"""
streaming = self._agent.options.include_partial_messages
turn = _Turn()
raw: list[Any] = []
next_index = 0
offset = 0
@@ -404,12 +415,16 @@ class ClaudeSdkBackend:
if isinstance(index, int):
next_index = max(next_index, offset + index + 1)
if streaming:
turn.events.extend(_emit_stream_event(event, offset + index))
for out in _emit_stream_event(event, offset + index):
turn.events += 1
yield out
elif isinstance(message, AssistantMessage):
raw.append(message)
if not streaming:
for block in message.content:
turn.events.extend(_emit_block(block, next_index))
for out in _emit_block(block, next_index):
turn.events += 1
yield out
next_index += 1
elif isinstance(message, UserMessage):
raw.append(message)
@@ -423,11 +438,10 @@ class ClaudeSdkBackend:
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
self._agent.name,
live.session_id,
len(turn.events),
turn.events,
len(turn.synthesized),
turn.stop_reason,
)
return turn
async def _acquire(
self,
@@ -606,7 +620,8 @@ class _SessionSpec:
@dataclass
class _Turn:
events: list[Any] = field(default_factory=list)
events: int = 0
"""Wire events already yielded to the caller."""
synthesized: list[dict[str, Any]] = field(default_factory=list)
result: ResultMessage | None = None
stop_reason: StopReason = "end_turn"