feat(frontends,ui): opus 5.5 progress updates show in the web panel and the vault transcript
This commit is contained in:
1 parent
bbce425eeb
commit
13873f857b
5 files changed
+123
-70
No files matched your search
@@ -255,8 +255,9 @@ def _summarize_assistant_group(
|
|||||||
) -> tuple[str, list[str], int]:
|
) -> tuple[str, list[str], int]:
|
||||||
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
|
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
|
||||||
|
|
||||||
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into
|
Must mirror what the renderer writes and the parser reads back: text and
|
||||||
one segment, tool_use becomes a skeleton entry, tool_result/thinking are invisible.
|
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
|
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
|
||||||
|
|
||||||
@@ -282,12 +283,10 @@ def _summarize_assistant_group(
|
|||||||
for blk in content:
|
for blk in content:
|
||||||
if not isinstance(blk, dict):
|
if not isinstance(blk, dict):
|
||||||
continue
|
continue
|
||||||
btype = blk.get("type")
|
text = _spoken(blk)
|
||||||
if btype == "text":
|
|
||||||
text = str(blk.get("text", "")).strip()
|
|
||||||
if text:
|
if text:
|
||||||
pending.append(text)
|
pending.append(text)
|
||||||
elif btype == "tool_use":
|
elif blk.get("type") == "tool_use":
|
||||||
_flush()
|
_flush()
|
||||||
segments.append(ToolSegment(name=str(blk.get("name", ""))))
|
segments.append(ToolSegment(name=str(blk.get("name", ""))))
|
||||||
_flush()
|
_flush()
|
||||||
@@ -440,11 +439,16 @@ def _splice_assistant_group(
|
|||||||
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
|
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):
|
if not isinstance(blk, dict):
|
||||||
return False
|
return ""
|
||||||
block = cast("dict[str, Any]", blk)
|
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(
|
def _splice_in_place(
|
||||||
@@ -454,7 +458,8 @@ def _splice_in_place(
|
|||||||
|
|
||||||
Preserves message/thinking-block boundaries that a rebuild would lose
|
Preserves message/thinking-block boundaries that a rebuild would lose
|
||||||
(and that a cache-hit continuation depends on); returns ``None`` if
|
(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
|
from beaver_gateway.frontends.markdown.parser import TextSegment
|
||||||
|
|
||||||
@@ -466,9 +471,12 @@ def _splice_in_place(
|
|||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
if msg["role"] != "assistant" or not isinstance(content, list):
|
if msg["role"] != "assistant" or not isinstance(content, list):
|
||||||
continue
|
continue
|
||||||
positions.extend(
|
for bi, blk in enumerate(cast("list[dict[str, Any]]", content)):
|
||||||
(mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk)
|
if not _spoken(blk):
|
||||||
)
|
continue
|
||||||
|
if blk["type"] == "thinking":
|
||||||
|
return None
|
||||||
|
positions.append((mi, bi))
|
||||||
if len(positions) != len(new_texts):
|
if len(positions) != len(new_texts):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Render Anthropic ``Message`` (and user turns) into markdown.
|
"""Render Anthropic ``Message`` (and user turns) into markdown.
|
||||||
|
|
||||||
One-way: produces the human-facing artifact only. The parser strips
|
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
|
from __future__ import annotations
|
||||||
@@ -12,8 +12,6 @@ from typing import TYPE_CHECKING, Any, cast
|
|||||||
from anthropic.types import Message, TextBlock, ThinkingBlock
|
from anthropic.types import Message, TextBlock, ThinkingBlock
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
from anthropic.types import MessageParam
|
from anthropic.types import MessageParam
|
||||||
|
|
||||||
|
|
||||||
@@ -48,21 +46,14 @@ def render_assistant_text(text: str) -> str:
|
|||||||
def render_assistant_message(message: Message) -> str:
|
def render_assistant_message(message: Message) -> str:
|
||||||
"""Render an assistant ``Message`` into a turn block.
|
"""Render an assistant ``Message`` into a turn block.
|
||||||
|
|
||||||
Tool-use blocks render to nothing; of the thinking blocks between two
|
Tool-use blocks and empty thinking render to nothing; a non-empty thinking
|
||||||
texts only the first one reaches the file.
|
block is what the model wrote between tool calls and renders as text.
|
||||||
"""
|
"""
|
||||||
parts: list[str] = ["### Assistant:", ""]
|
parts: list[str] = ["### Assistant:", ""]
|
||||||
after_thinking = False
|
|
||||||
for block in message.content:
|
for block in message.content:
|
||||||
thinking = isinstance(block, ThinkingBlock)
|
text = _spoken(block)
|
||||||
if thinking and after_thinking:
|
if text:
|
||||||
continue
|
parts.extend((text, ""))
|
||||||
lines = list(_render_block(block))
|
|
||||||
if not lines:
|
|
||||||
continue
|
|
||||||
after_thinking = thinking
|
|
||||||
parts.extend(lines)
|
|
||||||
parts.append("")
|
|
||||||
return "\n".join(parts).rstrip() + "\n"
|
return "\n".join(parts).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -108,21 +99,12 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
def _render_block(block: object) -> Iterable[str]:
|
def _spoken(block: object) -> str:
|
||||||
if isinstance(block, TextBlock):
|
if isinstance(block, TextBlock):
|
||||||
text = (block.text or "").strip()
|
return (block.text or "").strip()
|
||||||
if text:
|
|
||||||
yield text
|
|
||||||
return
|
|
||||||
if isinstance(block, ThinkingBlock):
|
if isinstance(block, ThinkingBlock):
|
||||||
yield from _render_thinking(block.thinking or "")
|
return (block.thinking or "").strip()
|
||||||
return
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _render_thinking(text: str) -> Iterable[str]:
|
|
||||||
yield "> [!thinking]-"
|
|
||||||
for line in text.strip().splitlines() or [""]:
|
|
||||||
yield f"> {line}" if line else ">"
|
|
||||||
|
|
||||||
|
|
||||||
def adaptive_fence(content: str) -> str:
|
def adaptive_fence(content: str) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage
|
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:
|
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:
|
def _progress_turn() -> Message:
|
||||||
message = _message(
|
return _message(
|
||||||
ThinkingBlock(type="thinking", thinking="a\nb", signature="s"),
|
TextBlock(type="text", text="Checking the containers."),
|
||||||
TextBlock(type="text", text="answer"),
|
ThinkingBlock(type="thinking", thinking="", signature="s"),
|
||||||
)
|
ToolUseBlock(type="tool_use", id="t1", name="komodo", input={}),
|
||||||
assert renderer.render_assistant_message(message) == (
|
ThinkingBlock(type="thinking", thinking="", signature="s"),
|
||||||
"### Assistant:\n\n> [!thinking]-\n> a\n> b\n\nanswer\n"
|
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:
|
def test_progress_renders_as_text_in_place_and_empty_thinking_vanishes() -> None:
|
||||||
message = _message(
|
assert renderer.render_assistant_message(_progress_turn()) == (
|
||||||
ThinkingBlock(type="thinking", thinking="one", signature="s"),
|
"### Assistant:\n\nChecking the containers.\n\nAll up, reading logs.\n\n"
|
||||||
ToolUseBlock(type="tool_use", id="t1", name="Read", input={}),
|
"Found it.\nOne more.\n\nThe stack is healthy.\n"
|
||||||
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"),
|
|
||||||
)
|
)
|
||||||
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:
|
def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
|
||||||
|
|||||||
@@ -233,8 +233,10 @@ export class ActivityModel {
|
|||||||
}
|
}
|
||||||
if (sdk.type === "content_block_delta" && parent === null) {
|
if (sdk.type === "content_block_delta" && parent === null) {
|
||||||
const delta = sdk.delta as Record<string, unknown> | undefined;
|
const delta = sdk.delta as Record<string, unknown> | undefined;
|
||||||
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
const text =
|
||||||
turn.text = joinText(turn.text, delta.text, turn.paused);
|
delta?.type === "thinking_delta" ? delta.thinking : delta?.text;
|
||||||
|
if (typeof text === "string" && text) {
|
||||||
|
turn.text = joinText(turn.text, text, turn.paused);
|
||||||
turn.paused = false;
|
turn.paused = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,9 +190,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function blocks(message: HistoryMessage): ContentBlock[] {
|
function blocks(message: HistoryMessage): ContentBlock[] {
|
||||||
return typeof message.content === "string"
|
if (typeof message.content === "string") {
|
||||||
? [{ text: message.content, type: "text" }]
|
return [{ text: message.content, type: "text" }];
|
||||||
: message.content;
|
}
|
||||||
|
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 {
|
function resultText(block: ContentBlock): string {
|
||||||
@@ -287,7 +295,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if !(isResultOnly && !showResults)}
|
{:else if parts.length > 0 && !(isResultOnly && !showResults)}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"group flex min-w-0 max-w-full flex-col gap-1.5",
|
"group flex min-w-0 max-w-full flex-col gap-1.5",
|
||||||
@@ -334,8 +342,6 @@
|
|||||||
: "bg-muted/40"
|
: "bg-muted/40"
|
||||||
)}
|
)}
|
||||||
>{clip(resultText(block), RESULT_CLIP)}</pre>
|
>{clip(resultText(block), RESULT_CLIP)}</pre>
|
||||||
{:else if block.type === "thinking"}
|
|
||||||
<p class="px-1 text-kind-deep text-xs">thinking</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user