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
+373
-109
No files matched your search
@@ -36,3 +36,8 @@ class BaseAgent(BaseModel):
|
||||
system_prompt: str
|
||||
expose_mcps: tuple[ExposedMcp, ...] = ()
|
||||
accept_client_tools: bool = False
|
||||
|
||||
@property
|
||||
def thinking_is_progress(self) -> bool:
|
||||
"""Whether non-empty thinking is text for the human rather than reasoning."""
|
||||
return False
|
||||
@@ -121,6 +121,11 @@ class ClaudeAgent(BaseAgent):
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
@property
|
||||
def thinking_is_progress(self) -> bool:
|
||||
"""Reasoning arrives empty unless ``display`` is ``summarized``."""
|
||||
return (self.options.thinking or {}).get("display") != "summarized"
|
||||
|
||||
def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None:
|
||||
return self.prompts.for_kind(kind)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import hashlib
|
||||
import uuid as _uuid
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
try:
|
||||
from claude_agent_sdk._cli_version import __cli_version__
|
||||
@@ -31,6 +31,8 @@ __all__ = [
|
||||
"open_tool_uses",
|
||||
"prompt_count",
|
||||
"render_messages",
|
||||
"reply_text",
|
||||
"spoken_text",
|
||||
"strip_tool_entries",
|
||||
"text_of",
|
||||
"window_entries",
|
||||
@@ -434,13 +436,45 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
|
||||
return sum(1 for e in entries if _is_prompt_entry(e))
|
||||
|
||||
|
||||
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
|
||||
"""What a content block (dict or model) says to the human.
|
||||
|
||||
Text always; thinking only when the agent's thinking is progress
|
||||
written for the human, never when it is reasoning.
|
||||
"""
|
||||
if isinstance(block, Mapping):
|
||||
fields = cast("Mapping[str, Any]", block)
|
||||
kind, text, thinking = (
|
||||
fields.get("type"),
|
||||
fields.get("text"),
|
||||
fields.get("thinking"),
|
||||
)
|
||||
else:
|
||||
kind = getattr(block, "type", None)
|
||||
text, thinking = getattr(block, "text", None), getattr(block, "thinking", None)
|
||||
if kind == "text":
|
||||
return str(text or "").strip()
|
||||
if kind == "thinking" and thinking_is_progress:
|
||||
return str(thinking or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def reply_text(content: Iterable[Any], *, thinking_is_progress: bool) -> str:
|
||||
"""The reply as the human reads it: spoken blocks in order, a blank line apart."""
|
||||
parts = (spoken_text(b, thinking_is_progress=thinking_is_progress) for b in content)
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def render_messages(
|
||||
messages: Iterable[Mapping[str, Any]], *, window: int | None = None
|
||||
messages: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
thinking_is_progress: bool,
|
||||
window: int | None = None,
|
||||
) -> str:
|
||||
"""Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns.
|
||||
|
||||
Tool calls collapse to a one-line summary per assistant turn; tool
|
||||
results and thinking are dropped.
|
||||
results and reasoning are dropped, progress thinking reads as text.
|
||||
"""
|
||||
turns: list[str] = []
|
||||
tools: list[str] = []
|
||||
@@ -466,8 +500,9 @@ def render_messages(
|
||||
turns.append("user:\n" + _text_of_content(content))
|
||||
continue
|
||||
for block in _assistant_blocks(content):
|
||||
if block.get("type") == "text" and block.get("text"):
|
||||
current.append(str(block["text"]))
|
||||
text = spoken_text(block, thinking_is_progress=thinking_is_progress)
|
||||
if text:
|
||||
current.append(text)
|
||||
elif block.get("type") == "tool_use":
|
||||
tools.append(str(block.get("name", "")))
|
||||
flush()
|
||||
|
||||
@@ -352,6 +352,8 @@ class Rows(State):
|
||||
for b in await self.bindings(conv)
|
||||
]
|
||||
live = self._pool.get(conv.external_id)
|
||||
agent = self._agents.get(conv.agent_name)
|
||||
out["thinking_is_progress"] = agent is not None and agent.thinking_is_progress
|
||||
out["live"] = live is not None
|
||||
out["busy"] = live.busy if live is not None else False
|
||||
runner = self._runners.get(cast("int", conv.id))
|
||||
@@ -466,7 +468,13 @@ class Rows(State):
|
||||
return marked
|
||||
|
||||
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
|
||||
return render_messages(await self.history(conv), window=window)
|
||||
return render_messages(
|
||||
await self.history(conv),
|
||||
thinking_is_progress=self._claude_agent(
|
||||
conv.agent_name
|
||||
).thinking_is_progress,
|
||||
window=window,
|
||||
)
|
||||
|
||||
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
|
||||
if conv.session_id is None:
|
||||
|
||||
@@ -23,7 +23,7 @@ from claude_agent_sdk import (
|
||||
)
|
||||
|
||||
from beaver_gateway.backends.capture import TurnCapture
|
||||
from beaver_gateway.backends.transcript import text_of
|
||||
from beaver_gateway.backends.transcript import reply_text, text_of
|
||||
from beaver_gateway.conversations import injects
|
||||
from beaver_gateway.conversations.seeds import Seeds
|
||||
from beaver_gateway.conversations.state import Runner, aware, iso
|
||||
@@ -32,11 +32,11 @@ from beaver_gateway.frontends.accumulate import StreamAccumulator
|
||||
from beaver_gateway.storage.models import Conversation, InjectQueueItem, RateLimit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
|
||||
from beaver_gateway.events.stream import MessageStreamEvent
|
||||
|
||||
__all__ = ["Turns", "content_of", "image_block", "reply_text"]
|
||||
__all__ = ["Turns", "content_of", "image_block"]
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,7 +164,9 @@ class Turns(Seeds):
|
||||
):
|
||||
acc.feed(event)
|
||||
message = acc.finalize(model=agent.model)
|
||||
return reply_text(message.content), capture
|
||||
return reply_text(
|
||||
message.content, thinking_is_progress=agent.thinking_is_progress
|
||||
), capture
|
||||
|
||||
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
|
||||
return None
|
||||
@@ -484,22 +486,6 @@ class Turns(Seeds):
|
||||
IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def reply_text(content: Iterable[Any]) -> str:
|
||||
"""Text blocks plus non-empty thinking blocks, in block order.
|
||||
|
||||
What the model writes between tool calls rides in thinking blocks; the
|
||||
hidden reasoning in them is empty and drops out on its own.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
kind = getattr(block, "type", "")
|
||||
if kind == "text":
|
||||
parts.append(getattr(block, "text", "") or "")
|
||||
elif kind == "thinking":
|
||||
parts.append(getattr(block, "thinking", "") or "")
|
||||
return "\n\n".join(p for p in parts if p).strip()
|
||||
|
||||
|
||||
def content_of(
|
||||
text: str, attachments: Sequence[dict[str, Any]] | None
|
||||
) -> str | list[dict[str, Any]]:
|
||||
|
||||
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from beaver_gateway.agents.claude import ClaudeAgent
|
||||
from beaver_gateway.backends.capture import TurnCapture
|
||||
from beaver_gateway.backends.transcript import fingerprint, text_of
|
||||
from beaver_gateway.backends.transcript import fingerprint, reply_text, text_of
|
||||
from beaver_gateway.frontends.accumulate import StreamAccumulator
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.frontends.bearer import require_token
|
||||
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from anthropic.types import Message, MessageParam
|
||||
|
||||
from beaver_gateway.agents.base import BaseAgent
|
||||
from beaver_gateway.conversations.service import Conversations
|
||||
from beaver_gateway.events.stream import MessageStreamEvent
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
@@ -158,6 +159,7 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
capture=capture,
|
||||
turn_id=turn_id,
|
||||
history=history,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
)
|
||||
else:
|
||||
options: dict[str, Any] = {}
|
||||
@@ -170,7 +172,7 @@ class AnthropicMessagesFrontend(Frontend):
|
||||
async def after(message: Message) -> None:
|
||||
await _broadcast_turn(
|
||||
runtime,
|
||||
agent_name=agent.name,
|
||||
agent=agent,
|
||||
input_messages=messages,
|
||||
output_message=message,
|
||||
system=system_str,
|
||||
@@ -235,12 +237,9 @@ async def _finish(
|
||||
capture: TurnCapture,
|
||||
turn_id: str,
|
||||
history: list[dict[str, str]] | None,
|
||||
thinking_is_progress: bool,
|
||||
) -> None:
|
||||
reply = "\n\n".join(
|
||||
getattr(b, "text", "")
|
||||
for b in message.content
|
||||
if getattr(b, "type", "") == "text"
|
||||
).strip()
|
||||
reply = reply_text(message.content, thinking_is_progress=thinking_is_progress)
|
||||
tail = capture.synthesized_messages or [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
|
||||
]
|
||||
@@ -291,7 +290,7 @@ async def _sse(
|
||||
async def _broadcast_turn(
|
||||
runtime: GatewayRuntime,
|
||||
*,
|
||||
agent_name: str,
|
||||
agent: BaseAgent,
|
||||
input_messages: list[MessageParam],
|
||||
output_message: Message,
|
||||
system: str | None,
|
||||
@@ -299,9 +298,10 @@ async def _broadcast_turn(
|
||||
if not runtime.turn_log_handlers:
|
||||
return
|
||||
record = TurnRecord(
|
||||
agent_name=agent_name,
|
||||
agent_name=agent.name,
|
||||
input_messages=list(input_messages),
|
||||
output_message=output_message,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
system=system,
|
||||
source=FRONTEND,
|
||||
)
|
||||
|
||||
@@ -142,14 +142,16 @@ class CrossFrontendLogger:
|
||||
new_user = record.input_messages[-1]
|
||||
new_block = renderer.render_user_param(new_user)
|
||||
new_block = renderer.append_to_body(
|
||||
new_block, renderer.render_assistant_message(record.output_message)
|
||||
new_block,
|
||||
renderer.render_assistant_message(
|
||||
record.output_message,
|
||||
thinking_is_progress=record.thinking_is_progress,
|
||||
),
|
||||
)
|
||||
new_body = renderer.append_to_body(body, new_block)
|
||||
metadata = dict(parsed.metadata)
|
||||
else:
|
||||
new_body = _render_full_history(
|
||||
record.input_messages, record.output_message
|
||||
)
|
||||
new_body = _render_full_history(record)
|
||||
metadata = {}
|
||||
|
||||
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
|
||||
@@ -204,10 +206,10 @@ def _flatten_text(message: Any) -> str:
|
||||
return "\n\n".join(c for c in chunks if c)
|
||||
|
||||
|
||||
def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
|
||||
def _render_full_history(record: TurnRecord) -> str:
|
||||
"""Render an entire conversation (used when materializing a new logged file)."""
|
||||
blocks: list[str] = []
|
||||
for msg in messages:
|
||||
for msg in record.input_messages:
|
||||
role = msg.get("role")
|
||||
if role == "user":
|
||||
blocks.append(renderer.render_user_param(msg))
|
||||
@@ -215,7 +217,11 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
|
||||
content = msg.get("content", "")
|
||||
text = content if isinstance(content, str) else _content_to_text(content)
|
||||
blocks.append(f"### Assistant:\n\n{text.strip()}\n" if text.strip() else "")
|
||||
blocks.append(renderer.render_assistant_message(assistant))
|
||||
blocks.append(
|
||||
renderer.render_assistant_message(
|
||||
record.output_message, thinking_is_progress=record.thinking_is_progress
|
||||
)
|
||||
)
|
||||
body = ""
|
||||
for block in blocks:
|
||||
if not block:
|
||||
|
||||
@@ -341,7 +341,11 @@ class MarkdownFrontend(Frontend):
|
||||
file_path=file_path,
|
||||
first_user_text=parsed.turns[0].text,
|
||||
)
|
||||
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
|
||||
outcome = diff_and_fork(
|
||||
stored=stored_msgs,
|
||||
incoming=parsed.turns,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
)
|
||||
capture = TurnCapture()
|
||||
events = runtime.conversations.turn(
|
||||
conv,
|
||||
@@ -356,6 +360,7 @@ class MarkdownFrontend(Frontend):
|
||||
file_path=file_path,
|
||||
parsed=parsed,
|
||||
model=agent.model or agent.name,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
filename=filename,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
@@ -371,6 +376,7 @@ class MarkdownFrontend(Frontend):
|
||||
parsed=parsed,
|
||||
message=message,
|
||||
agent_name=agent.name,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
conv_external_id=conv_external_id,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
@@ -388,6 +394,7 @@ class MarkdownFrontend(Frontend):
|
||||
agent_name=agent.name,
|
||||
input_messages=list(parsed.messages),
|
||||
output_message=message,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
system=None,
|
||||
source="markdown",
|
||||
)
|
||||
@@ -534,7 +541,11 @@ class MarkdownFrontend(Frontend):
|
||||
len(stored_msgs),
|
||||
len(parsed.turns),
|
||||
)
|
||||
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
|
||||
outcome = diff_and_fork(
|
||||
stored=stored_msgs,
|
||||
incoming=parsed.turns,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
)
|
||||
_log.info(
|
||||
"chat/stream: file=%s diff_and_fork divergence=%s "
|
||||
"backend_msgs=%d persist_msgs=%d",
|
||||
@@ -567,7 +578,9 @@ class MarkdownFrontend(Frontend):
|
||||
partial = acc.finalize(model=model)
|
||||
if not partial.content:
|
||||
return None
|
||||
rendered = renderer.render_assistant_message(partial)
|
||||
rendered = renderer.render_assistant_message(
|
||||
partial, thinking_is_progress=agent.thinking_is_progress
|
||||
)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
return reattach_frontmatter(parsed.metadata, new_body)
|
||||
|
||||
@@ -593,7 +606,10 @@ class MarkdownFrontend(Frontend):
|
||||
new_body = parsed.body
|
||||
if partial.content:
|
||||
new_body = renderer.append_to_body(
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
new_body,
|
||||
renderer.render_assistant_message(
|
||||
partial, thinking_is_progress=agent.thinking_is_progress
|
||||
),
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
if write_disk:
|
||||
@@ -616,6 +632,7 @@ class MarkdownFrontend(Frontend):
|
||||
parsed=parsed,
|
||||
message=message,
|
||||
agent_name=agent.name,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
conv_external_id=conv_external_id,
|
||||
write_disk=write_disk,
|
||||
)
|
||||
@@ -633,6 +650,7 @@ class MarkdownFrontend(Frontend):
|
||||
agent_name=agent.name,
|
||||
input_messages=list(parsed.messages),
|
||||
output_message=message,
|
||||
thinking_is_progress=agent.thinking_is_progress,
|
||||
system=None,
|
||||
source="markdown",
|
||||
)
|
||||
@@ -659,6 +677,7 @@ class MarkdownFrontend(Frontend):
|
||||
file_path: Path,
|
||||
parsed: parser.ParsedFile,
|
||||
model: str,
|
||||
thinking_is_progress: bool,
|
||||
filename: str,
|
||||
write_disk: bool = True,
|
||||
) -> Any:
|
||||
@@ -675,7 +694,9 @@ class MarkdownFrontend(Frontend):
|
||||
partial = acc.finalize(model=model)
|
||||
if not partial.content:
|
||||
return
|
||||
rendered = renderer.render_assistant_message(partial)
|
||||
rendered = renderer.render_assistant_message(
|
||||
partial, thinking_is_progress=thinking_is_progress
|
||||
)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
await write_atomic(
|
||||
file_path, reattach_frontmatter(parsed.metadata, new_body)
|
||||
@@ -698,7 +719,10 @@ class MarkdownFrontend(Frontend):
|
||||
new_body = parsed.body
|
||||
if partial.content:
|
||||
new_body = renderer.append_to_body(
|
||||
new_body, renderer.render_assistant_message(partial)
|
||||
new_body,
|
||||
renderer.render_assistant_message(
|
||||
partial, thinking_is_progress=thinking_is_progress
|
||||
),
|
||||
)
|
||||
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
|
||||
if write_disk:
|
||||
@@ -715,11 +739,14 @@ class MarkdownFrontend(Frontend):
|
||||
parsed: parser.ParsedFile,
|
||||
message: Any,
|
||||
agent_name: str,
|
||||
thinking_is_progress: bool,
|
||||
conv_external_id: str,
|
||||
write_disk: bool = True,
|
||||
) -> str:
|
||||
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
|
||||
rendered = renderer.render_assistant_message(message)
|
||||
rendered = renderer.render_assistant_message(
|
||||
message, thinking_is_progress=thinking_is_progress
|
||||
)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
|
||||
updated_metadata = dict(parsed.metadata)
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import select
|
||||
|
||||
from beaver_gateway.backends.transcript import spoken_text
|
||||
from beaver_gateway.storage.models import Conversation, ConversationMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -180,7 +181,9 @@ class _StoredDisplayTurn:
|
||||
text_segment_count: int
|
||||
|
||||
|
||||
def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTurn]:
|
||||
def _group_display_turns(
|
||||
stored: list[dict[str, Any]], *, thinking_is_progress: bool
|
||||
) -> list[_StoredDisplayTurn]:
|
||||
"""Walk raw stored messages, group them into Obsidian-visible turns.
|
||||
|
||||
A user-prompt message (no ``tool_result`` blocks) opens a new turn;
|
||||
@@ -210,7 +213,9 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
|
||||
break
|
||||
group.append(m)
|
||||
i += 1
|
||||
spoken, skeleton, text_count = _summarize_assistant_group(group)
|
||||
spoken, skeleton, text_count = _summarize_assistant_group(
|
||||
group, thinking_is_progress=thinking_is_progress
|
||||
)
|
||||
out.append(
|
||||
_StoredDisplayTurn(
|
||||
role="assistant",
|
||||
@@ -251,13 +256,13 @@ def _user_prompt_text(content: Any) -> str:
|
||||
|
||||
|
||||
def _summarize_assistant_group(
|
||||
group: list[dict[str, Any]],
|
||||
group: list[dict[str, Any]], *, thinking_is_progress: bool
|
||||
) -> tuple[str, list[str], int]:
|
||||
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
|
||||
|
||||
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.
|
||||
progress thinking collapse into one segment, tool_use becomes a skeleton
|
||||
entry, tool_result, reasoning and empty thinking are invisible.
|
||||
"""
|
||||
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
|
||||
|
||||
@@ -283,7 +288,7 @@ def _summarize_assistant_group(
|
||||
for blk in content:
|
||||
if not isinstance(blk, dict):
|
||||
continue
|
||||
text = _spoken(blk)
|
||||
text = spoken_text(blk, thinking_is_progress=thinking_is_progress)
|
||||
if text:
|
||||
pending.append(text)
|
||||
elif blk.get("type") == "tool_use":
|
||||
@@ -298,7 +303,10 @@ def _summarize_assistant_group(
|
||||
|
||||
|
||||
def diff_and_fork(
|
||||
*, stored: list[dict[str, Any]], incoming: list[ParsedTurn]
|
||||
*,
|
||||
stored: list[dict[str, Any]],
|
||||
incoming: list[ParsedTurn],
|
||||
thinking_is_progress: bool,
|
||||
) -> ForkOutcome:
|
||||
"""Align the incoming parsed file against stored history.
|
||||
|
||||
@@ -313,11 +321,15 @@ def diff_and_fork(
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
stored_groups = _group_display_turns(stored)
|
||||
stored_groups = _group_display_turns(
|
||||
stored, thinking_is_progress=thinking_is_progress
|
||||
)
|
||||
new_user_turn = incoming[-1]
|
||||
prior_incoming = incoming[:-1]
|
||||
|
||||
spliced_groups, divergence, edited = _walk_prefix(prior_incoming, stored_groups)
|
||||
spliced_groups, divergence, edited = _walk_prefix(
|
||||
prior_incoming, stored_groups, thinking_is_progress=thinking_is_progress
|
||||
)
|
||||
|
||||
if divergence is None and len(prior_incoming) < len(stored_groups):
|
||||
if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
|
||||
@@ -357,7 +369,10 @@ def _file_lags_store(
|
||||
|
||||
|
||||
def _walk_prefix(
|
||||
prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn]
|
||||
prior_incoming: list[ParsedTurn],
|
||||
stored_groups: list[_StoredDisplayTurn],
|
||||
*,
|
||||
thinking_is_progress: bool,
|
||||
) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
|
||||
"""Walk incoming vs stored side-by-side until first divergence.
|
||||
|
||||
@@ -390,7 +405,9 @@ def _walk_prefix(
|
||||
continue
|
||||
if inc_skeleton and inc_text_count != st.text_segment_count:
|
||||
return spliced_groups, i, edited
|
||||
spliced = _splice_assistant_group(stored_group=st, incoming=inc)
|
||||
spliced = _splice_assistant_group(
|
||||
stored_group=st, incoming=inc, thinking_is_progress=thinking_is_progress
|
||||
)
|
||||
if spliced is None:
|
||||
return spliced_groups, i, edited
|
||||
spliced_groups.append(spliced)
|
||||
@@ -425,7 +442,10 @@ def _assemble_tail(
|
||||
|
||||
|
||||
def _splice_assistant_group(
|
||||
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
|
||||
*,
|
||||
stored_group: _StoredDisplayTurn,
|
||||
incoming: ParsedTurn,
|
||||
thinking_is_progress: bool,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Apply the file's prose to a stored assistant turn.
|
||||
|
||||
@@ -433,26 +453,21 @@ def _splice_assistant_group(
|
||||
markdown can't express. Falls back to rebuilding from the file's
|
||||
structure when the text blocks don't line up one-to-one.
|
||||
"""
|
||||
preserved = _splice_in_place(stored_group=stored_group, incoming=incoming)
|
||||
preserved = _splice_in_place(
|
||||
stored_group=stored_group,
|
||||
incoming=incoming,
|
||||
thinking_is_progress=thinking_is_progress,
|
||||
)
|
||||
if preserved is not None:
|
||||
return preserved
|
||||
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
|
||||
|
||||
|
||||
def _spoken(blk: object) -> str:
|
||||
if not isinstance(blk, dict):
|
||||
return ""
|
||||
block = cast("dict[str, Any]", blk)
|
||||
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(
|
||||
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
|
||||
*,
|
||||
stored_group: _StoredDisplayTurn,
|
||||
incoming: ParsedTurn,
|
||||
thinking_is_progress: bool,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Copy the stored messages, swapping only their text block contents.
|
||||
|
||||
@@ -472,7 +487,7 @@ def _splice_in_place(
|
||||
if msg["role"] != "assistant" or not isinstance(content, list):
|
||||
continue
|
||||
for bi, blk in enumerate(cast("list[dict[str, Any]]", content)):
|
||||
if not _spoken(blk):
|
||||
if not spoken_text(blk, thinking_is_progress=thinking_is_progress):
|
||||
continue
|
||||
if blk["type"] == "thinking":
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Render Anthropic ``Message`` (and user turns) into markdown.
|
||||
|
||||
One-way: produces the human-facing artifact only. The parser strips
|
||||
the tool/thinking callouts of older files when reshaping history for replay.
|
||||
tool/thinking callouts separately when reshaping history for backend replay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -43,17 +43,25 @@ def render_assistant_text(text: str) -> str:
|
||||
return f"### Assistant:\n\n{text.strip()}\n"
|
||||
|
||||
|
||||
def render_assistant_message(message: Message) -> str:
|
||||
def render_assistant_message(message: Message, *, thinking_is_progress: bool) -> str:
|
||||
"""Render an assistant ``Message`` into a turn block.
|
||||
|
||||
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.
|
||||
Tool-use blocks render to nothing. Progress thinking renders as text in
|
||||
place and vanishes when empty; reasoning renders as a collapsed callout,
|
||||
of which only the first between two texts reaches the file.
|
||||
"""
|
||||
parts: list[str] = ["### Assistant:", ""]
|
||||
after_reasoning = False
|
||||
for block in message.content:
|
||||
if isinstance(block, ThinkingBlock) and not thinking_is_progress:
|
||||
if not after_reasoning:
|
||||
parts.extend((*_render_reasoning(block.thinking or ""), ""))
|
||||
after_reasoning = True
|
||||
continue
|
||||
text = _spoken(block)
|
||||
if text:
|
||||
parts.extend((text, ""))
|
||||
after_reasoning = False
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
|
||||
|
||||
@@ -99,6 +107,11 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def _render_reasoning(text: str) -> tuple[str, ...]:
|
||||
lines = text.strip().splitlines() or [""]
|
||||
return ("> [!thinking]-", *(f"> {line}" if line else ">" for line in lines))
|
||||
|
||||
|
||||
def _spoken(block: object) -> str:
|
||||
if isinstance(block, TextBlock):
|
||||
return (block.text or "").strip()
|
||||
|
||||
@@ -852,7 +852,10 @@ class TelegramFrontend(Frontend):
|
||||
if event.get("origin") in ("user", "task"):
|
||||
await self._open_draft(key, event, target)
|
||||
case "stream":
|
||||
self._feed_draft(key, event)
|
||||
agents = cast("GatewayRuntime", self._runtime).agents
|
||||
agent = agents.get(conv.agent_name)
|
||||
progress = agent is not None and agent.thinking_is_progress
|
||||
self._feed_draft(key, event, thinking_is_progress=progress)
|
||||
case "tool":
|
||||
draft = self._drafts.get(key)
|
||||
if draft is not None and event.get("parent_tool_use_id") is None:
|
||||
@@ -971,7 +974,9 @@ class TelegramFrontend(Frontend):
|
||||
self._drafts[key] = draft
|
||||
draft.start()
|
||||
|
||||
def _feed_draft(self, key: str, event: Event) -> None:
|
||||
def _feed_draft(
|
||||
self, key: str, event: Event, *, thinking_is_progress: bool
|
||||
) -> None:
|
||||
draft = self._drafts.get(key)
|
||||
if draft is None or event.get("parent_tool_use_id") is not None:
|
||||
return
|
||||
@@ -984,7 +989,7 @@ class TelegramFrontend(Frontend):
|
||||
draft.append(str(delta.get("text") or ""))
|
||||
elif delta.get("type") == "thinking_delta":
|
||||
progress = str(delta.get("thinking") or "")
|
||||
if progress:
|
||||
if progress and thinking_is_progress:
|
||||
draft.set_status(self.texts.writing)
|
||||
draft.append(progress)
|
||||
else:
|
||||
|
||||
@@ -46,6 +46,7 @@ class TurnRecord:
|
||||
agent_name: str
|
||||
input_messages: list[MessageParam]
|
||||
output_message: Message
|
||||
thinking_is_progress: bool
|
||||
system: str | None = None
|
||||
source: str = "anthropic"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ConversationInfo extends ConversationSummary {
|
||||
parent: string | null;
|
||||
question: PendingQuestion | null;
|
||||
queue: QueueItem[];
|
||||
thinking_is_progress?: boolean;
|
||||
turn: TurnSnapshot | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -233,8 +233,11 @@ export class ActivityModel {
|
||||
}
|
||||
if (sdk.type === "content_block_delta" && parent === null) {
|
||||
const delta = sdk.delta as Record<string, unknown> | undefined;
|
||||
const progress = this.conversation?.thinking_is_progress === true;
|
||||
const text =
|
||||
delta?.type === "thinking_delta" ? delta.thinking : delta?.text;
|
||||
delta?.type === "thinking_delta" && progress
|
||||
? delta.thinking
|
||||
: delta?.text;
|
||||
if (typeof text === "string" && text) {
|
||||
turn.text = joinText(turn.text, text, turn.paused);
|
||||
turn.paused = false;
|
||||
|
||||
@@ -193,6 +193,9 @@
|
||||
if (typeof message.content === "string") {
|
||||
return [{ text: message.content, type: "text" }];
|
||||
}
|
||||
if (!model.conversation?.thinking_is_progress) {
|
||||
return message.content;
|
||||
}
|
||||
return message.content.flatMap((block) => {
|
||||
if (block.type !== "thinking") {
|
||||
return [block];
|
||||
@@ -342,6 +345,8 @@
|
||||
: "bg-muted/40"
|
||||
)}
|
||||
>{clip(resultText(block), RESULT_CLIP)}</pre>
|
||||
{:else if block.type === "thinking"}
|
||||
<p class="px-1 text-kind-deep text-xs">thinking</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user