from pathlib import Path from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage from beaver_gateway.frontends.markdown import history, parser, renderer from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment from beaver_gateway.frontends.turn_record import TurnRecord 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"), ) for progress in (True, False): assert renderer.render_assistant_message( message, thinking_is_progress=progress ) == ("### Assistant:\n\nfirst\n\nsecond\n") def _progress_turn() -> Message: return _message( TextBlock(type="text", text="Checking the containers."), ThinkingBlock(type="thinking", thinking="", signature="s"), ToolUseBlock(type="tool_use", id="t1", name="komodo", input={}), ThinkingBlock(type="thinking", thinking="", signature="s"), ThinkingBlock(type="thinking", thinking="All up, reading logs.", signature="s"), ToolUseBlock(type="tool_use", id="t2", name="komodo", input={}), ThinkingBlock(type="thinking", thinking="Found it.\nOne more.", signature="s"), ToolUseBlock(type="tool_use", id="t3", name="komodo", input={}), TextBlock(type="text", text="The stack is healthy."), ) def _reasoning_turn() -> Message: return _message( ThinkingBlock(type="thinking", thinking="one", signature=""), ToolUseBlock(type="tool_use", id="t1", name="web_search", input={}), ThinkingBlock(type="thinking", thinking="two", signature=""), ToolUseBlock(type="tool_use", id="t2", name="read_page", input={}), ThinkingBlock(type="thinking", thinking="three", signature=""), TextBlock(type="text", text="answer"), ThinkingBlock(type="thinking", thinking="four\nfive", signature=""), TextBlock(type="text", text="more"), ) REASONING_TURN_AS_BEFORE = ( "### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n" "> [!thinking]-\n> four\n> five\n\nmore\n" ) def _full_circle( message: Message, *, thinking_is_progress: bool ) -> history.ForkOutcome: body = renderer.render_user_text("status?") + renderer.render_assistant_message( message, thinking_is_progress=thinking_is_progress ) turns = parser.parse(body + renderer.render_user_text("thanks")).turns stored = [ {"role": "user", "content": "status?"}, {"role": "assistant", "content": [b.model_dump() for b in message.content]}, ] outcome = history.diff_and_fork( stored=stored, incoming=turns, thinking_is_progress=thinking_is_progress ) assert outcome.divergence_index is None assert not outcome.edited assert outcome.messages[1] == stored[1] return outcome def test_progress_renders_as_text_in_place_and_empty_thinking_vanishes() -> None: assert renderer.render_assistant_message( _progress_turn(), thinking_is_progress=True ) == ( "### Assistant:\n\nChecking the containers.\n\nAll up, reading logs.\n\n" "Found it.\nOne more.\n\nThe stack is healthy.\n" ) def test_reasoning_renders_as_collapsed_callouts_as_before() -> None: assert ( renderer.render_assistant_message(_reasoning_turn(), thinking_is_progress=False) == REASONING_TURN_AS_BEFORE ) def test_progress_round_trips_through_the_parser_and_the_stored_history() -> None: message = _progress_turn() body = renderer.render_assistant_message(message, thinking_is_progress=True) assert parser.parse_assistant_structure(body.removeprefix("### Assistant:")) == [ TextSegment( text="Checking the containers.\n\nAll up, reading logs.\n\n" "Found it.\nOne more.\n\nThe stack is healthy." ) ] _full_circle(message, thinking_is_progress=True) def test_reasoning_round_trips_through_the_parser_and_the_stored_history() -> None: message = _reasoning_turn() body = renderer.render_assistant_message(message, thinking_is_progress=False) assert parser.parse_assistant_structure(body.removeprefix("### Assistant:")) == [ TextSegment(text="answer"), TextSegment(text="more"), ] _full_circle(message, thinking_is_progress=False) def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() -> None: message = _progress_turn() body = renderer.render_assistant_message( message, thinking_is_progress=True ).replace("Found it", "Fixed it") turns = parser.parse( renderer.render_user_text("status?") + body + renderer.render_user_text("ok") ).turns stored = [ {"role": "user", "content": "status?"}, {"role": "assistant", "content": [b.model_dump() for b in message.content]}, ] outcome = history.diff_and_fork( stored=stored, incoming=turns, thinking_is_progress=True ) assert outcome.edited assert outcome.messages[1] == { "role": "assistant", "content": [{"type": "text", "text": turns[1].text}], } async def test_a_raycast_archive_with_reasoning_renders_as_before( tmp_path: Path, ) -> None: logger = CrossFrontendLogger(vault_path=tmp_path, logged_subdir="logged") record = TurnRecord( agent_name="beaver-gemini-pro-high", input_messages=[{"role": "user", "content": "q"}], output_message=_reasoning_turn(), thinking_is_progress=False, ) await logger.handle(record) [written] = (tmp_path / "logged" / "beaver-gemini-pro-high").glob("*.md") body = parser.parse(written.read_text()).body assert f"---\n\n{REASONING_TURN_AS_BEFORE}\n---\n\n### User:" in body def test_a_file_with_collapsed_thinking_callouts_parses_as_before() -> None: body = ( "### User:\n\nq\n\n---\n\n### Assistant:\n\n" "> [!thinking]-\n> weighing it\n> twice\n\nanswer\n\n" "> [!tool]- Read ยท notes.md\n> input\n\n" "> [!thinking]-\n>\n\nmore\n\n---\n\n### User:\n\nnext\n" ) parsed = parser.parse(body) assert parsed.turns[1].structure == ( TextSegment(text="answer"), ToolSegment(name="Read"), TextSegment(text="more"), ) assert parsed.messages == [ {"role": "user", "content": "q"}, {"role": "assistant", "content": "answer\n\nmore"}, {"role": "user", "content": "next"}, ] 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")