fix(backends,markdown): stream turn events live, no blank lines for tool blocks
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -419,3 +420,41 @@ async def test_close_disconnects(cwd: Path) -> None:
|
||||
)
|
||||
assert FakeClient.instances[0].connected is False
|
||||
assert backend.sessions == {}
|
||||
|
||||
|
||||
class GatedClient(FakeClient):
|
||||
"""Streams one delta, then waits for ``gate`` before finishing the turn."""
|
||||
|
||||
gate: asyncio.Event
|
||||
|
||||
async def receive_response(self):
|
||||
yield StreamEvent(
|
||||
uuid="u", session_id="s", event={"type": "message_start", "message": {}}
|
||||
)
|
||||
for e in _stream(0, "first"):
|
||||
yield e
|
||||
await GatedClient.gate.wait()
|
||||
yield AssistantMessage(content=[TextBlock(text="first")], model="m")
|
||||
yield _result(self.session_id)
|
||||
|
||||
|
||||
async def test_deltas_reach_the_caller_before_the_turn_ends(cwd: Path) -> None:
|
||||
"""The turn is not buffered: a delta is observable while the CLI still runs."""
|
||||
GatedClient.gate = asyncio.Event()
|
||||
backend = _backend(cwd, InMemorySessionStore())
|
||||
backend._factory = GatedClient
|
||||
events = backend.complete(
|
||||
agent=backend.agent,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
conversation_id="conv-live",
|
||||
)
|
||||
seen: list[Any] = []
|
||||
async for ev in events:
|
||||
seen.append(ev)
|
||||
if isinstance(ev, RawContentBlockDeltaEvent):
|
||||
break
|
||||
assert seen[-1].delta.text == "first"
|
||||
assert not GatedClient.gate.is_set()
|
||||
GatedClient.gate.set()
|
||||
rest = [e async for e in events]
|
||||
assert isinstance(rest[-1], RawMessageStopEvent)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage
|
||||
|
||||
from beaver_gateway.frontends.markdown import renderer
|
||||
|
||||
|
||||
def _message(*blocks: object) -> Message:
|
||||
return Message(
|
||||
id="m",
|
||||
type="message",
|
||||
role="assistant",
|
||||
model="x",
|
||||
content=list(blocks), # type: ignore[arg-type]
|
||||
stop_reason="end_turn",
|
||||
stop_sequence=None,
|
||||
usage=Usage(input_tokens=1, output_tokens=1),
|
||||
)
|
||||
|
||||
|
||||
def test_tool_calls_leave_no_blank_lines() -> None:
|
||||
message = _message(
|
||||
TextBlock(type="text", text="first"),
|
||||
ToolUseBlock(type="tool_use", id="t1", name="Bash", input={"command": "ls"}),
|
||||
ToolUseBlock(type="tool_use", id="t2", name="Read", input={"file_path": "x"}),
|
||||
ToolUseBlock(type="tool_use", id="t3", name="Grep", input={"pattern": "y"}),
|
||||
TextBlock(type="text", text="second"),
|
||||
)
|
||||
assert renderer.render_assistant_message(message) == (
|
||||
"### Assistant:\n\nfirst\n\nsecond\n"
|
||||
)
|
||||
|
||||
|
||||
def test_thinking_renders_as_collapsed_callout() -> None:
|
||||
message = _message(
|
||||
ThinkingBlock(type="thinking", thinking="a\nb", signature="s"),
|
||||
TextBlock(type="text", text="answer"),
|
||||
)
|
||||
assert renderer.render_assistant_message(message) == (
|
||||
"### Assistant:\n\n> [!thinking]-\n> a\n> b\n\nanswer\n"
|
||||
)
|
||||
|
||||
|
||||
def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
|
||||
body = renderer.append_to_body("### Assistant:\n\nhi\n", renderer.USER_SCAFFOLD)
|
||||
assert body.endswith("\n\n---\n\n### User:\n\n")
|
||||
assert renderer.render_user_text("q").startswith("### User:\n\n")
|
||||
Reference in New Issue
Block a user