feat(agents,backends,conversations,frontends,ui): progress thinking is told apart by its agent, raycast reasoning stays collapsed, read_conversation keeps progress
This commit is contained in:
1 parent
13873f857b
commit
120985deab
18 files changed
+374
-110
No files matched your search
@@ -24,7 +24,7 @@ from anthropic.types import TextBlock as AnthropicTextBlock
|
||||
from anthropic.types import ThinkingBlock
|
||||
|
||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
||||
from beaver_gateway.conversations.turns import reply_text
|
||||
from beaver_gateway.agents.raycast import RaycastAgent
|
||||
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
|
||||
from beaver_gateway.events.bus import EventBus
|
||||
from beaver_gateway.conversations.service import (
|
||||
@@ -40,6 +40,7 @@ from beaver_gateway.backends.transcript import (
|
||||
close_open_tool_uses,
|
||||
open_tool_uses,
|
||||
render_messages,
|
||||
reply_text,
|
||||
strip_tool_entries,
|
||||
window_entries,
|
||||
)
|
||||
@@ -718,12 +719,68 @@ def test_transcript_helpers() -> None:
|
||||
assert windowed[0]["message"]["content"] == "q2" and len(windowed) == 2
|
||||
assert (
|
||||
render_messages(
|
||||
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}]
|
||||
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}],
|
||||
thinking_is_progress=True,
|
||||
)
|
||||
== "user:\nq\n\nassistant:\na"
|
||||
)
|
||||
|
||||
|
||||
def test_read_conversation_keeps_progress_and_drops_reasoning() -> None:
|
||||
history = [
|
||||
{"role": "user", "content": "status?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Checking."},
|
||||
{"type": "thinking", "thinking": "", "signature": "s"},
|
||||
{"type": "tool_use", "id": "t1", "name": "komodo", "input": {}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "All up, reading logs.",
|
||||
"signature": "s",
|
||||
},
|
||||
{"type": "tool_use", "id": "t2", "name": "komodo", "input": {}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": "ok"}],
|
||||
},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Healthy."}]},
|
||||
]
|
||||
assert render_messages(history, thinking_is_progress=True) == (
|
||||
"user:\nstatus?\n\nassistant:\nChecking.\n\nAll up, reading logs.\n\n"
|
||||
"Healthy.\n(tools: komodo, komodo)"
|
||||
)
|
||||
assert render_messages(history, thinking_is_progress=False) == (
|
||||
"user:\nstatus?\n\nassistant:\nChecking.\n\nHealthy.\n(tools: komodo, komodo)"
|
||||
)
|
||||
|
||||
|
||||
def test_only_claude_agents_read_their_thinking_as_progress(tmp_path: Path) -> None:
|
||||
raycast = RaycastAgent(name="r", model="google-gemini-3.1-pro", system_prompt="")
|
||||
claude = ClaudeAgent(name="c", model="claude-opus-5-5", cwd=tmp_path)
|
||||
summarized = ClaudeAgent(
|
||||
name="s",
|
||||
model="claude-opus-5-5",
|
||||
cwd=tmp_path,
|
||||
options=ClaudeOptions(thinking={"type": "adaptive", "display": "summarized"}),
|
||||
)
|
||||
assert not raycast.thinking_is_progress
|
||||
assert claude.thinking_is_progress
|
||||
assert not summarized.thinking_is_progress
|
||||
|
||||
|
||||
async def test_agent_kinds_gate_create(world: World) -> None:
|
||||
with pytest.raises(ValueError, match="does not serve kind 'deep'"):
|
||||
await world.conversations.create(kind="deep", agent="a", origin="test")
|
||||
@@ -970,9 +1027,10 @@ def test_reply_text_keeps_progress_updates_and_drops_hidden_reasoning() -> None:
|
||||
ThinkingBlock(type="thinking", thinking="looking it up", signature="s"),
|
||||
AnthropicTextBlock(type="text", text="found it"),
|
||||
]
|
||||
assert reply_text(blocks) == "looking it up\n\nfound it"
|
||||
assert reply_text(blocks, thinking_is_progress=True) == "looking it up\n\nfound it"
|
||||
assert reply_text(blocks, thinking_is_progress=False) == "found it"
|
||||
|
||||
|
||||
def test_reply_text_on_a_plain_answer_is_unchanged() -> None:
|
||||
blocks = [AnthropicTextBlock(type="text", text=" just an answer ")]
|
||||
assert reply_text(blocks) == "just an answer"
|
||||
assert reply_text(blocks, thinking_is_progress=True) == "just an answer"
|
||||
@@ -1,7 +1,11 @@
|
||||
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:
|
||||
@@ -25,9 +29,10 @@ def test_tool_calls_leave_no_blank_lines() -> None:
|
||||
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"
|
||||
)
|
||||
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:
|
||||
@@ -44,38 +49,88 @@ def _progress_turn() -> Message:
|
||||
)
|
||||
|
||||
|
||||
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()) == (
|
||||
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_user_text("status?") + renderer.render_assistant_message(
|
||||
message
|
||||
)
|
||||
turns = parser.parse(body + renderer.render_user_text("thanks")).turns
|
||||
assert turns[1].structure == (
|
||||
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."
|
||||
),
|
||||
)
|
||||
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]
|
||||
_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).replace("Found it", "Fixed it")
|
||||
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
|
||||
@@ -83,7 +138,9 @@ def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() -
|
||||
{"role": "user", "content": "status?"},
|
||||
{"role": "assistant", "content": [b.model_dump() for b in message.content]},
|
||||
]
|
||||
outcome = history.diff_and_fork(stored=stored, incoming=turns)
|
||||
outcome = history.diff_and_fork(
|
||||
stored=stored, incoming=turns, thinking_is_progress=True
|
||||
)
|
||||
assert outcome.edited
|
||||
assert outcome.messages[1] == {
|
||||
"role": "assistant",
|
||||
@@ -91,6 +148,22 @@ def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() -
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
+20
-2
@@ -1215,7 +1215,9 @@ def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> Non
|
||||
draft = _progress_draft()
|
||||
texts = TelegramTexts()
|
||||
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
|
||||
feed = functools.partial(TelegramFrontend._feed_draft, cast("Any", stub), "k")
|
||||
feed = functools.partial(
|
||||
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True
|
||||
)
|
||||
|
||||
feed(_thinking_delta(""))
|
||||
assert draft.text == ""
|
||||
@@ -1226,6 +1228,20 @@ def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> Non
|
||||
assert draft.status == texts.writing
|
||||
|
||||
|
||||
def test_reasoning_from_an_agent_whose_thinking_is_not_progress_stays_hidden() -> None:
|
||||
draft = _progress_draft()
|
||||
texts = TelegramTexts()
|
||||
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
|
||||
TelegramFrontend._feed_draft(
|
||||
cast("Any", stub),
|
||||
"k",
|
||||
_thinking_delta("weighing it"),
|
||||
thinking_is_progress=False,
|
||||
)
|
||||
assert draft.text == ""
|
||||
assert draft.status == texts.thinking
|
||||
|
||||
|
||||
def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
|
||||
draft = _progress_draft()
|
||||
texts = TelegramTexts()
|
||||
@@ -1234,7 +1250,9 @@ def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
|
||||
texts=texts,
|
||||
_tool_status=lambda name, _input: texts.tool.format(label=name),
|
||||
)
|
||||
feed = functools.partial(TelegramFrontend._feed_draft, cast("Any", stub), "k")
|
||||
feed = functools.partial(
|
||||
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True
|
||||
)
|
||||
|
||||
feed(_thinking_delta("reading the note"))
|
||||
feed(
|
||||
|
||||
Reference in new issue
Block a user