From 13873f857b6ec2fe5b2173594714fae921ae0eed Mon Sep 17 00:00:00 2001 From: h Date: Tue, 22 Sep 2026 20:36:23 +0200 Subject: [PATCH] feat(frontends,ui): opus 5.5 progress updates show in the web panel and the vault transcript --- .../frontends/markdown/history.py | 38 +++++--- .../frontends/markdown/renderer.py | 38 ++------ tests/test_markdown_renderer.py | 97 +++++++++++++++---- ui/src/lib/panel/activity.svelte.ts | 6 +- ui/src/lib/panel/chat-view.svelte | 18 ++-- 5 files changed, 125 insertions(+), 72 deletions(-) diff --git a/src/beaver_gateway/frontends/markdown/history.py b/src/beaver_gateway/frontends/markdown/history.py index cee0443..4612899 100644 --- a/src/beaver_gateway/frontends/markdown/history.py +++ b/src/beaver_gateway/frontends/markdown/history.py @@ -255,8 +255,9 @@ def _summarize_assistant_group( ) -> tuple[str, list[str], int]: """Compute (spoken_text, tool_skeleton, text_segment_count) for a display group. - Must mirror ``parser.parse_assistant_structure``: text blocks collapse into - one segment, tool_use becomes a skeleton entry, tool_result/thinking are invisible. + Must mirror what the renderer writes and the parser reads back: text and + non-empty thinking collapse into one segment, tool_use becomes a skeleton + entry, tool_result and empty thinking are invisible. """ from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment @@ -282,12 +283,10 @@ def _summarize_assistant_group( for blk in content: if not isinstance(blk, dict): continue - btype = blk.get("type") - if btype == "text": - text = str(blk.get("text", "")).strip() - if text: - pending.append(text) - elif btype == "tool_use": + text = _spoken(blk) + if text: + pending.append(text) + elif blk.get("type") == "tool_use": _flush() segments.append(ToolSegment(name=str(blk.get("name", "")))) _flush() @@ -440,11 +439,16 @@ def _splice_assistant_group( return _splice_by_rebuild(stored_group=stored_group, incoming=incoming) -def _is_text_block(blk: object) -> bool: +def _spoken(blk: object) -> str: if not isinstance(blk, dict): - return False + return "" block = cast("dict[str, Any]", blk) - return block.get("type") == "text" and bool(str(block.get("text", "")).strip()) + kind = block.get("type") + if kind == "text": + return str(block.get("text", "")).strip() + if kind == "thinking": + return str(block.get("thinking", "")).strip() + return "" def _splice_in_place( @@ -454,7 +458,8 @@ def _splice_in_place( Preserves message/thinking-block boundaries that a rebuild would lose (and that a cache-hit continuation depends on); returns ``None`` if - text blocks aren't 1:1 with incoming segments, so the caller rebuilds instead. + text blocks aren't 1:1 with incoming segments or the prose runs through + signed thinking blocks, so the caller rebuilds instead. """ from beaver_gateway.frontends.markdown.parser import TextSegment @@ -466,9 +471,12 @@ def _splice_in_place( content = msg.get("content") if msg["role"] != "assistant" or not isinstance(content, list): continue - positions.extend( - (mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk) - ) + for bi, blk in enumerate(cast("list[dict[str, Any]]", content)): + if not _spoken(blk): + continue + if blk["type"] == "thinking": + return None + positions.append((mi, bi)) if len(positions) != len(new_texts): return None diff --git a/src/beaver_gateway/frontends/markdown/renderer.py b/src/beaver_gateway/frontends/markdown/renderer.py index 7926dd7..5634e9d 100644 --- a/src/beaver_gateway/frontends/markdown/renderer.py +++ b/src/beaver_gateway/frontends/markdown/renderer.py @@ -1,7 +1,7 @@ """Render Anthropic ``Message`` (and user turns) into markdown. One-way: produces the human-facing artifact only. The parser strips -tool/thinking callouts separately when reshaping history for backend replay. +the tool/thinking callouts of older files when reshaping history for replay. """ from __future__ import annotations @@ -12,8 +12,6 @@ from typing import TYPE_CHECKING, Any, cast from anthropic.types import Message, TextBlock, ThinkingBlock if TYPE_CHECKING: - from collections.abc import Iterable - from anthropic.types import MessageParam @@ -48,21 +46,14 @@ def render_assistant_text(text: str) -> str: def render_assistant_message(message: Message) -> str: """Render an assistant ``Message`` into a turn block. - Tool-use blocks render to nothing; of the thinking blocks between two - texts only the first one reaches the file. + Tool-use blocks and empty thinking render to nothing; a non-empty thinking + block is what the model wrote between tool calls and renders as text. """ parts: list[str] = ["### Assistant:", ""] - after_thinking = False for block in message.content: - thinking = isinstance(block, ThinkingBlock) - if thinking and after_thinking: - continue - lines = list(_render_block(block)) - if not lines: - continue - after_thinking = thinking - parts.extend(lines) - parts.append("") + text = _spoken(block) + if text: + parts.extend((text, "")) return "\n".join(parts).rstrip() + "\n" @@ -108,21 +99,12 @@ def summarize_tool_input(name: str, tool_input: object) -> str: return name -def _render_block(block: object) -> Iterable[str]: +def _spoken(block: object) -> str: if isinstance(block, TextBlock): - text = (block.text or "").strip() - if text: - yield text - return + return (block.text or "").strip() if isinstance(block, ThinkingBlock): - yield from _render_thinking(block.thinking or "") - return - - -def _render_thinking(text: str) -> Iterable[str]: - yield "> [!thinking]-" - for line in text.strip().splitlines() or [""]: - yield f"> {line}" if line else ">" + return (block.thinking or "").strip() + return "" def adaptive_fence(content: str) -> str: diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index eecc64d..3990ff3 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -1,6 +1,7 @@ from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage -from beaver_gateway.frontends.markdown import renderer +from beaver_gateway.frontends.markdown import history, parser, renderer +from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment def _message(*blocks: object) -> Message: @@ -29,31 +30,85 @@ def test_tool_calls_leave_no_blank_lines() -> None: ) -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 _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 test_thinking_between_hidden_tool_calls_collapses_to_the_first() -> None: - message = _message( - ThinkingBlock(type="thinking", thinking="one", signature="s"), - ToolUseBlock(type="tool_use", id="t1", name="Read", input={}), - ThinkingBlock(type="thinking", thinking="two", signature="s"), - ToolUseBlock(type="tool_use", id="t2", name="Read", input={}), - ThinkingBlock(type="thinking", thinking="three", signature="s"), - TextBlock(type="text", text="answer"), - ThinkingBlock(type="thinking", thinking="four", signature="s"), - TextBlock(type="text", text="more"), +def test_progress_renders_as_text_in_place_and_empty_thinking_vanishes() -> None: + assert renderer.render_assistant_message(_progress_turn()) == ( + "### Assistant:\n\nChecking the containers.\n\nAll up, reading logs.\n\n" + "Found it.\nOne more.\n\nThe stack is healthy.\n" ) - assert renderer.render_assistant_message(message) == ( - "### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n" - "> [!thinking]-\n> four\n\nmore\n" + + +def test_progress_round_trips_through_the_parser_and_the_stored_history() -> None: + message = _progress_turn() + body = renderer.render_user_text("status?") + renderer.render_assistant_message( + message ) + turns = parser.parse(body + renderer.render_user_text("thanks")).turns + assert turns[1].structure == ( + TextSegment( + text="Checking the containers.\n\nAll up, reading logs.\n\n" + "Found it.\nOne more.\n\nThe stack is healthy." + ), + ) + 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) + assert outcome.divergence_index is None + assert not outcome.edited + assert outcome.messages[1] == stored[1] + + +def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() -> None: + message = _progress_turn() + body = renderer.render_assistant_message(message).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) + assert outcome.edited + assert outcome.messages[1] == { + "role": "assistant", + "content": [{"type": "text", "text": turns[1].text}], + } + + +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: diff --git a/ui/src/lib/panel/activity.svelte.ts b/ui/src/lib/panel/activity.svelte.ts index 76ed785..a3e0417 100644 --- a/ui/src/lib/panel/activity.svelte.ts +++ b/ui/src/lib/panel/activity.svelte.ts @@ -233,8 +233,10 @@ export class ActivityModel { } if (sdk.type === "content_block_delta" && parent === null) { const delta = sdk.delta as Record | undefined; - if (delta?.type === "text_delta" && typeof delta.text === "string") { - turn.text = joinText(turn.text, delta.text, turn.paused); + const text = + delta?.type === "thinking_delta" ? delta.thinking : delta?.text; + if (typeof text === "string" && text) { + turn.text = joinText(turn.text, text, turn.paused); turn.paused = false; } } diff --git a/ui/src/lib/panel/chat-view.svelte b/ui/src/lib/panel/chat-view.svelte index 0ed9641..4892935 100644 --- a/ui/src/lib/panel/chat-view.svelte +++ b/ui/src/lib/panel/chat-view.svelte @@ -190,9 +190,17 @@ } function blocks(message: HistoryMessage): ContentBlock[] { - return typeof message.content === "string" - ? [{ text: message.content, type: "text" }] - : message.content; + if (typeof message.content === "string") { + return [{ text: message.content, type: "text" }]; + } + return message.content.flatMap((block) => { + if (block.type !== "thinking") { + return [block]; + } + return block.thinking?.trim() + ? [{ text: block.thinking, type: "text" }] + : []; + }); } function resultText(block: ContentBlock): string { @@ -287,7 +295,7 @@ {/if} - {:else if !(isResultOnly && !showResults)} + {:else if parts.length > 0 && !(isResultOnly && !showResults)}
{clip(resultText(block), RESULT_CLIP)} - {:else if block.type === "thinking"} -

thinking

{/if} {/each}