fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn

This commit is contained in:
hh
2026-09-04 20:23:33 +02:00
parent 1f051f261e
commit 96e18156eb
15 changed files with 608 additions and 91 deletions
+218 -36
View File
@@ -37,6 +37,7 @@ from claude_agent_sdk import (
PermissionResultDeny,
ResultMessage,
StreamEvent,
TaskNotificationMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
@@ -72,7 +73,14 @@ from beaver_gateway.events.stream import (
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from collections.abc import (
AsyncIterator,
Awaitable,
Callable,
Coroutine,
Iterable,
Sequence,
)
from anthropic.types import MessageParam
from claude_agent_sdk import (
@@ -201,8 +209,16 @@ class ClaudeSdkBackend:
tool_server: Callable[[str, str], McpSdkServerConfig | None] | None = None,
asker: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None,
audit_sink: Callable[[policy_mod.ToolAudit], Awaitable[None]] | None = None,
waker: Callable[[str, str], Coroutine[Any, Any, None]] | None = None,
) -> None:
"""One backend per Claude agent.
``waker(key, note)`` is called when a session starts a turn by itself
(a background task reported back); the caller then attaches.
"""
self._agent = agent
self.waker = waker
self._tasks: set[asyncio.Task[None]] = set()
self._audit_sink = audit_sink
self._store = session_store
self._runner = runner or RunnerConfig()
@@ -256,6 +272,51 @@ class ClaudeSdkBackend:
def live(self, key: str) -> Session | None:
return self._pool.get(key)
def has_pending(self, key: str) -> bool:
"""A turn the session started on its own is waiting for ``attach``."""
live = self._pool.get(key)
return live is not None and bool(live.pending)
def pending_note(self, key: str) -> str | None:
live = self._pool.get(key)
return live.note if live is not None else None
async def attach(
self,
*,
key: str,
capture: TurnCapture | None = None,
observer: Callable[[Any], None] | None = None,
turn_id: str | None = None,
) -> AsyncIterator[MessageStreamEvent]:
"""Consume the turn a live session started by itself as a normal turn.
The CLI runs one when a background task (a subagent) reports back:
no prompt goes out, the events are what the model said in reply to
the report, the turn ends on that result.
"""
live = self._pool.get(key)
if live is None or not live.pending:
return
message_id = f"msg_{uuid.uuid4().hex}"
yield build_message_start(message_id=message_id, model=self._agent.model)
turn = _Turn()
async with live.lock:
live.running_turn = turn_id or message_id
live.last_used = time.monotonic()
async for event in self._run_turn(live, None, turn, observer, capture):
yield event
live.turns += 1
live.last_used = time.monotonic()
live.running_turn = None
usage, _ = await self._after_turn(
live, turn, conversation_id=key, history=[], capture=capture
)
yield build_message_delta(
stop_reason=turn.stop_reason, usage=_wire_usage(usage)
)
yield build_message_stop()
async def repair_session(
self, session_id: str, *, text: str = "interrupted"
) -> int:
@@ -404,27 +465,84 @@ class ClaudeSdkBackend:
async def _run_turn(
self,
live: Session,
prompt: str | list[dict[str, Any]],
prompt: str | list[dict[str, Any]] | None,
turn: _Turn,
observer: Callable[[Any], None] | None = None,
capture: TurnCapture | None = None,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one prompt, yielding wire events as they arrive.
"""Run one prompt, or attach to a self-started one, yielding events.
``turn`` is filled in place (result, synthesized history, count of
events already yielded) so the caller can finish bookkeeping - and
decide whether a retry is still possible - after a failure. The
session id lands in ``capture`` with the first frame, so a turn cut
by a restart still leaves a resumable session behind.
A result whose ``origin`` is not the human's ends a turn the CLI ran
on its own; inside a prompted turn it is passed over, the events it
came with stay in this turn rather than getting lost.
"""
streaming = self._agent.options.include_partial_messages
raw: list[Any] = []
next_index = 0
offset = 0
await live.client.query(prompt if isinstance(prompt, str) else _stream(prompt))
async for message in live.client.receive_response():
cursor = _Cursor()
live.observer = observer
live.attached = True
for queued in live.pending:
live.inbox.put_nowait(queued)
live.pending.clear()
try:
if prompt is not None:
await live.client.query(
prompt if isinstance(prompt, str) else _stream(prompt)
)
while True:
message = await live.inbox.get()
if message is _END:
if prompt is not None:
msg = f"claude exited before answering ({live.session_id})"
raise RuntimeError(msg)
break
if isinstance(message, BaseException):
raise message
if observer is not None:
observer(message)
if not self._note(live, message, capture):
continue
if isinstance(message, ResultMessage):
if prompt is not None and _self_started(message):
_log.info(
"session %s: a self-started turn ended inside a "
"prompted one, folded in",
live.session_id,
)
continue
turn.result = message
turn.stop_reason = _STOP_REASONS.get(
message.stop_reason or "", "end_turn"
)
break
for out in self._emit(message, turn, cursor, raw):
turn.events += 1
yield out
if turn.events == 0:
for out in _emit_whole(raw, cursor):
turn.events += 1
yield out
finally:
live.attached = False
live.woken = False
turn.synthesized = synthesize_turn_messages(raw)
_log.info(
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
self._agent.name,
live.session_id,
turn.events,
len(turn.synthesized),
turn.stop_reason,
)
@staticmethod
def _note(live: Session, message: Any, capture: TurnCapture | None) -> bool:
"""Session bookkeeping for one message; False if not the main thread's."""
session_id = getattr(message, "session_id", None)
if isinstance(session_id, str) and session_id and live.session_id is None:
live.session_id = session_id
@@ -437,46 +555,72 @@ class ClaudeSdkBackend:
live.session_id,
message.error,
)
continue
if getattr(message, "parent_tool_use_id", None) is not None:
continue
return False
if isinstance(message, TaskNotificationMessage):
live.note = _note_of(message)
return False
return getattr(message, "parent_tool_use_id", None) is None
def _emit(
self, message: Any, turn: _Turn, cursor: _Cursor, raw: list[Any]
) -> Iterable[MessageStreamEvent]:
streaming = self._agent.options.include_partial_messages
if isinstance(message, StreamEvent):
event = message.event
if event.get("type") == "message_start":
offset = next_index
cursor.offset = cursor.next_index
turn.context_tokens = _context_of(event.get("message"))
continue
return
index = event.get("index")
if isinstance(index, int):
next_index = max(next_index, offset + index + 1)
cursor.next_index = max(cursor.next_index, cursor.offset + index + 1)
if streaming:
for out in _emit_stream_event(event, offset + index):
turn.events += 1
yield out
yield from _emit_stream_event(event, cursor.offset + index)
elif isinstance(message, AssistantMessage):
raw.append(message)
if not streaming:
for block in message.content:
for out in _emit_block(block, next_index):
turn.events += 1
yield out
next_index += 1
yield from _emit_block(block, cursor.next_index)
cursor.next_index += 1
elif isinstance(message, UserMessage):
raw.append(message)
elif isinstance(message, ResultMessage):
turn.result = message
turn.stop_reason = _STOP_REASONS.get(
message.stop_reason or "", "end_turn"
)
turn.synthesized = synthesize_turn_messages(raw)
_log.info(
"turn: agent=%s session=%s events=%d synthesized=%d stop=%s",
self._agent.name,
live.session_id,
turn.events,
len(turn.synthesized),
turn.stop_reason,
)
async def _read(self, live: Session) -> None:
"""Read the client for the session's whole life.
During a turn every message goes to the turn's inbox. Between turns
a subagent still running is shown to the last observer, and the
first frame of the main thread means the CLI started a turn by
itself: it is buffered in ``pending`` and the waker is told once.
"""
try:
async for message in live.client.receive_messages():
if live.attached:
live.inbox.put_nowait(message)
continue
if isinstance(message, TaskNotificationMessage):
live.note = _note_of(message)
if _main_thread(message):
live.pending.append(message)
if not live.woken:
live.woken = True
self._wake(live)
elif live.observer is not None:
live.observer(message)
live.inbox.put_nowait(_END)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
_log.error("session %s: reader died: %s", live.session_id, exc)
live.dirty = True
live.inbox.put_nowait(exc)
def _wake(self, live: Session) -> None:
if self.waker is None:
return
task = asyncio.create_task(self.waker(live.key, live.note or ""))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _acquire(
self,
@@ -541,7 +685,7 @@ class ClaudeSdkBackend:
spec.tools,
self._runner.user,
)
return Session(
live = Session(
key=key,
agent=self._agent.name,
kind=spec.kind,
@@ -550,6 +694,8 @@ class ClaudeSdkBackend:
resumed=resume is not None,
pinned=spec.pinned,
)
live.reader = asyncio.create_task(self._read(live))
return live
def _default_factory(self, options: ClaudeAgentOptions) -> SessionClient:
return _RunnerClient(options, uid=self._uid)
@@ -739,6 +885,15 @@ class _SessionSpec:
tools: bool
_END = object()
@dataclass
class _Cursor:
next_index: int = 0
offset: int = 0
@dataclass
class _Turn:
events: int = 0
@@ -866,6 +1021,33 @@ async def _stream(content: list[dict[str, Any]]) -> AsyncIterator[dict[str, Any]
}
def _emit_whole(raw: Iterable[Any], cursor: _Cursor) -> Iterable[MessageStreamEvent]:
"""Blocks of a turn that streamed no deltas, e.g. one reported whole."""
for message in raw:
if isinstance(message, AssistantMessage):
for block in message.content:
yield from _emit_block(block, cursor.next_index)
cursor.next_index += 1
def _self_started(result: ResultMessage) -> bool:
origin = result.origin
return origin is not None and origin.get("kind") != "human"
def _main_thread(message: Any) -> bool:
if getattr(message, "parent_tool_use_id", None) is not None:
return False
return isinstance(
message, (StreamEvent, AssistantMessage, UserMessage, ResultMessage)
)
def _note_of(message: TaskNotificationMessage) -> str:
summary = message.summary or message.data.get("description") or message.task_id
return f"{summary} ({message.status})"
def synthesize_turn_messages(raw: Iterable[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for message in raw:
+25 -2
View File
@@ -20,7 +20,13 @@ from typing import TYPE_CHECKING, Any, Protocol
import psutil
if TYPE_CHECKING:
from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping
from collections.abc import (
AsyncIterable,
AsyncIterator,
Callable,
Iterator,
Mapping,
)
__all__ = ["DEFAULT_TTL", "Session", "SessionClient", "SessionPool", "cgroup_limit"]
@@ -42,7 +48,7 @@ _RSS_HEADROOM = 0.8
class SessionClient(Protocol):
async def connect(self) -> None: ...
async def query(self, prompt: str | AsyncIterable[dict[str, Any]]) -> None: ...
def receive_response(self) -> AsyncIterator[Any]: ...
def receive_messages(self) -> AsyncIterator[Any]: ...
async def interrupt(self) -> None: ...
async def disconnect(self) -> None: ...
@@ -67,6 +73,20 @@ class Session:
state: dict[str, Any] = field(default_factory=dict)
"""Scratch for policy rules (``agents/policy.py``); dies with the process."""
reader: asyncio.Task[None] | None = None
"""Reads the client for the session's whole life, not only during a turn."""
inbox: asyncio.Queue[Any] = field(default_factory=asyncio.Queue)
"""Where the reader puts messages while a turn is attached."""
pending: list[Any] = field(default_factory=list)
"""A turn the CLI started on its own (a background task reported back),
buffered until a consumer attaches."""
attached: bool = False
woken: bool = False
note: str | None = None
"""Summary of the last task notification, the pending turn's prompt."""
observer: Callable[[Any], None] | None = None
"""The last turn's observer; sees subagent traffic between turns."""
@property
def busy(self) -> bool:
return self.lock.locked() or self.running_turn is not None
@@ -192,6 +212,8 @@ class SessionPool:
if session is None:
return
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
if session.reader is not None and session.reader is not asyncio.current_task():
session.reader.cancel()
try:
await session.client.disconnect()
except Exception: # noqa: BLE001
@@ -238,6 +260,7 @@ class SessionPool:
"turns": s.turns,
"busy": s.busy,
"running_turn": s.running_turn,
"pending": len(s.pending),
"pending_question": s.pending_question,
"pinned": s.pinned,
"dirty": s.dirty,
@@ -127,6 +127,12 @@ class State:
self._tasks = set()
self._idle_task = None
self.scheduler = None
for backend in backends.values():
if hasattr(backend, "waker"):
backend.waker = self._woken
async def _woken(self, key: str, note: str) -> None:
raise NotImplementedError
@property
def db(self) -> Database:
@@ -77,6 +77,8 @@ class ConversationTexts:
)
inject_header: Callable[[injects.InjectContext], str] = injects.inject_header
bundle_header: str = "[injects accumulated since {since}; not the user]"
task_prompt: str = "[subagent reported back: {note}]"
"""Stands in for the user's words in mirrors of a turn a subagent started."""
interrupted: str = "interrupted"
answered: str = "The user answered: {answer}"
unanswered: str = (
+77 -4
View File
@@ -54,8 +54,15 @@ class Turns(Seeds):
tools: bool = True,
turn_id: str | None = None,
item_origin: str | None = None,
shown: str | None = None,
attach: bool = False,
) -> AsyncIterator[MessageStreamEvent]:
"""Run one turn under the conversation's lock; the only path to the backend."""
"""Run one turn under the conversation's lock; the only path to the backend.
``shown`` is what frontends display as the turn's prompt (the user's
words, not the envelope). ``attach`` sends nothing: the turn is one
the live session started by itself, a subagent reporting back.
"""
row_id = cast("int", conv.id)
runner = self._runner(row_id)
backend = self._backend(conv.agent_name)
@@ -65,7 +72,7 @@ class Turns(Seeds):
async with runner.lock:
runner.turn_id = turn_id
runner.origin = origin
runner.text = _prompt_preview(messages)
runner.text = shown if shown is not None else _prompt_preview(messages)
runner.started_at = datetime.now(UTC)
runner.tools = {}
await self._mark_running(conv, turn_id)
@@ -81,7 +88,16 @@ class Turns(Seeds):
stop = "error"
cut = False
try:
events = backend.complete(
observer = self._observer(conv, runner, turn_id, origin)
events = (
backend.attach(
key=conv.external_id,
capture=capture,
observer=observer,
turn_id=turn_id,
)
if attach
else backend.complete(
agent=self._claude_agent(conv.agent_name),
messages=messages,
conversation_id=conv.external_id,
@@ -91,9 +107,10 @@ class Turns(Seeds):
kind=conv.kind,
pinned=conv.kind == "master",
tools=tools,
observer=self._observer(conv, runner, turn_id, origin),
observer=observer,
turn_id=turn_id,
)
)
async for event in events:
yield event
stop = "interrupted" if capture.interrupted else "end_turn"
@@ -128,6 +145,8 @@ class Turns(Seeds):
turn_id: str | None = None,
item_origin: str | None = None,
attachments: Sequence[dict[str, Any]] | None = None,
shown: str | None = None,
attach: bool = False,
) -> tuple[str, TurnCapture]:
capture = TurnCapture()
acc = StreamAccumulator()
@@ -140,6 +159,8 @@ class Turns(Seeds):
tools=tools,
turn_id=turn_id,
item_origin=item_origin,
shown=shown,
attach=attach,
):
acc.feed(event)
message = acc.finalize(model=agent.model)
@@ -160,6 +181,47 @@ class Turns(Seeds):
runner = self._runners.get(cast("int", conv.id))
return runner.origin if runner is not None and runner.turn_id else None
async def _woken(self, key: str, note: str) -> None:
"""The live session of ``key`` started a turn by itself; run it next."""
conv = await self.get(key)
if conv is None:
_log.warning("self-started turn on unknown conversation %s: %s", key, note)
return
_log.info("conversation %s woke by itself: %s", key, note)
self._ensure_worker(cast("int", conv.id))
async def _run_pending(self, conv: Conversation) -> None:
turn_id = f"turn_{uuid4().hex[:12]}"
note = self._backend(conv.agent_name).pending_note(conv.external_id) or ""
try:
text, _ = await self.run_text_turn(
conv,
note,
origin="task",
turn_id=turn_id,
item_origin="task",
shown=note,
attach=True,
)
except Exception: # noqa: BLE001
_log.exception(
"self-started turn %s on %s failed", turn_id, conv.external_id
)
return
if not text.strip():
return
self._bus.publish(
"reply",
conversation_id=conv.external_id,
turn_id=turn_id,
item=None,
item_origin="task",
source="task",
prompt=self._texts.task_prompt.format(note=note),
user_text=None,
text=text,
)
def _ensure_worker(self, row_id: int) -> None:
runner = self._runner(row_id)
runner.wake.set()
@@ -170,6 +232,10 @@ class Turns(Seeds):
async def _worker(self, row_id: int) -> None:
runner = self._runner(row_id)
while True:
conv = await self.get_row(row_id)
if conv is not None and self._has_pending(conv):
await self._run_pending(conv)
continue
items = await self._queue.pending(row_id)
batch, wait = self._pick(items)
if batch is None:
@@ -233,6 +299,7 @@ class Turns(Seeds):
turn_id=turn_id,
item_origin=head.origin,
attachments=head.attachments if origin == "user" else None,
shown=head.text if origin == "user" else None,
)
except Exception: # noqa: BLE001
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
@@ -254,6 +321,12 @@ class Turns(Seeds):
text=text,
)
def _has_pending(self, conv: Conversation) -> bool:
try:
return self._backend(conv.agent_name).has_pending(conv.external_id)
except LookupError:
return False
def _bundle(self, items: Sequence[InjectQueueItem]) -> str:
lines = [self._texts.bundle_header.format(since=iso(items[0].created_at))]
lines.extend(f"- [{i.origin}] {i.text}" for i in items)
@@ -47,6 +47,7 @@ class Draft:
self._interval = interval
self.status = status
self.text = ""
self._paused = False
self._dirty = True
self._broken = False
self._last_sent = 0.0
@@ -65,10 +66,21 @@ class Draft:
if status != self.status:
self.status = status
self._dirty = True
self._paused = bool(self.text.strip())
def append(self, text: str) -> None:
if text:
"""Add streamed text; a tool call in between starts a new paragraph.
The model ends a sentence, calls a tool, then goes on without a
break - the same text the final message shows as paragraphs.
"""
if not text:
return
if self._paused and not self.text.endswith("\n"):
self.text = self.text.rstrip() + "\n\n" + text.lstrip()
else:
self.text += text
self._paused = False
self._dirty = True
async def stop(self) -> None:
@@ -777,7 +777,7 @@ class TelegramFrontend(Frontend):
return
match kind:
case "turn.start":
if event.get("origin") == "user":
if event.get("origin") in ("user", "task"):
await self._open_draft(key, event, target)
case "stream":
self._feed_draft(key, event)
+12
View File
@@ -86,6 +86,7 @@ class FakeClient:
self.prompts: list[str] = []
self.connected = False
self.session_id = options.resume or "fresh-session"
self.asked = asyncio.Event()
FakeClient.instances.append(self)
async def connect(self) -> None:
@@ -93,6 +94,17 @@ class FakeClient:
async def query(self, prompt: str) -> None:
self.prompts.append(prompt)
self.asked.set()
async def receive_messages(self):
served = 0
while True:
while len(self.prompts) <= served:
self.asked.clear()
await self.asked.wait()
served += 1
async for message in self.receive_response():
yield message
async def interrupt(self) -> None:
self.interrupted = True
+100
View File
@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
import pytest
from claude_agent_sdk import (
TaskNotificationMessage,
AssistantMessage,
InMemorySessionStore,
ResultMessage,
@@ -92,6 +93,8 @@ class ScriptedClient:
self.session_id = options.resume or str(uuid.uuid4())
self.interrupted = False
self.connected = False
self.asked = asyncio.Event()
self.extra: list[Any] = []
ScriptedClient.instances.append(self)
async def connect(self) -> None:
@@ -106,6 +109,26 @@ class ScriptedClient:
b.get("text", "") for b in content if b.get("type") == "text"
)
self.prompts.append(prompt)
self.asked.set()
async def receive_messages(self):
"""One response per prompt, forever; ``extra`` is what the CLI sends
on its own between prompts (a subagent reporting back)."""
served = 0
while True:
while len(self.prompts) <= served:
self.asked.clear()
if self.extra:
for message in self.extra:
if isinstance(message, asyncio.Event):
await message.wait()
continue
yield message
self.extra = []
await self.asked.wait()
served += 1
async for message in self.receive_response():
yield message
async def receive_response(self):
prompt = self.prompts[-1]
@@ -858,3 +881,80 @@ async def test_inject_into_a_closed_master_lands_in_the_open_one(world: World) -
assert item.conversation_id == new.id
assert await world.statuses(old) == []
assert await world.statuses(new) == [("normal", "queued")]
def _self_started(session_id: str, text: str) -> list[Any]:
"""What the CLI sends by itself once a background subagent reports back."""
return [
AssistantMessage(
content=[ToolUseBlock(id="tu_dig", name="Grep", input={"pattern": "au"})],
model="m",
parent_tool_use_id="tu_bg",
),
TaskNotificationMessage(
subtype="task_notification",
data={},
task_id="t1",
status="completed",
output_file="",
summary='Agent "dig" finished',
uuid="n1",
session_id=session_id,
),
AssistantMessage(content=[TextBlock(text=text)], model="m"),
ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id=session_id,
stop_reason="end_turn",
total_cost_usd=0.0,
usage={"input_tokens": 1, "output_tokens": 1},
origin={"kind": "task-notification"},
),
]
async def test_subagent_report_between_turns_is_its_own_turn(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
seen: list[dict[str, Any]] = []
async def collect() -> None:
async for event in world.bus.stream(conversation_id=conv.external_id):
seen.append(event)
task = asyncio.create_task(collect())
await world.conversations.post(conv, "go")
await world.settle(conv, 1)
client = ScriptedClient.instances[0]
client.extra = _self_started(client.session_id, "the agent found gold")
client.asked.set()
await asyncio.sleep(0.3)
task.cancel()
replies = [e for e in seen if e["type"] == "reply"]
assert [r["text"] for r in replies] == ["ok:go", "the agent found gold"]
assert replies[1]["item_origin"] == "task"
starts = [e for e in seen if e["type"] == "turn.start"]
assert [s["origin"] for s in starts] == ["user", "task"]
assert starts[1]["text"] == 'Agent "dig" finished (completed)'
assert len(client.prompts) == 1
first_end = next(i for i, e in enumerate(seen) if e["type"] == "turn.end")
later = [e for e in seen[first_end + 1 :] if e["type"] in ("stream", "tool")]
assert later and all(e["turn_id"] == starts[0]["turn_id"] for e in later[:1])
assert all(e["turn_id"] == starts[1]["turn_id"] for e in later[1:])
assert (await world.conversations.get(conv.external_id)).running_turn is None
async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> None:
conv = await world.conversations.create(kind="master", agent="a", origin="test")
await world.conversations.post(conv, "one")
await world.settle(conv, 1)
client = ScriptedClient.instances[0]
client.extra = _self_started(client.session_id, "late report")
await world.conversations.post(conv, "two")
await world.settle(conv, 2)
assert client.prompts[-1].startswith("two")
rows = await world.conversations.queue.recent(conv.id)
assert [r.status for r in rows] == ["done", "done"]
+18 -1
View File
@@ -4,7 +4,7 @@ import contextlib
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from typing import Any, cast
import pytest
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
@@ -16,6 +16,7 @@ from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.render import (
LIMIT,
chunks,
@@ -719,6 +720,22 @@ def test_chunks_split_blockquote_and_stay_under_limit() -> None:
assert " ".join(parts).split() == long_line.split()
def test_draft_breaks_a_paragraph_after_a_tool_call() -> None:
draft = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="t")
draft.append("Checking the balance.")
draft.set_status("⏳ Bash")
draft.set_status("✍️ writing")
draft.append("It is 20 378.")
assert draft.text == "Checking the balance.\n\nIt is 20 378."
draft.set_status("⏳ Read")
draft.append("\n\nAnd a list:")
assert draft.text.endswith("It is 20 378.\n\nAnd a list:")
fresh = Draft(cast("Any", None), chat_id=1, thread_id=None, turn_id="u")
fresh.set_status("⏳ Bash")
fresh.append("First words")
assert fresh.text == "First words"
def test_to_html_tail_inside_fence() -> None:
full = (
"intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter"
+1 -1
View File
@@ -23,7 +23,7 @@
onMount(() => {
const timer = setInterval(() => {
if (model.running) {
if (model.live.length > 0) {
now = Date.now();
}
}, TICK_MS);
+82 -5
View File
@@ -27,6 +27,9 @@ export interface Turn {
itemOrigin: string | null;
nodes: Record<string, ToolNode>;
origin: string;
// A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it.
paused: boolean;
resultSubtype: string | null;
roots: string[];
says: string[];
@@ -51,6 +54,20 @@ function str(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
export function joinText(head: string, tail: string, paused: boolean): string {
if (paused && head.trim() && !head.endsWith("\n")) {
return `${head.trimEnd()}\n\n${tail.trimStart()}`;
}
return head + tail;
}
export function isLive(turn: Turn): boolean {
return (
turn.status === "running" ||
Object.values(turn.nodes).some((node) => node.status === "running")
);
}
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
if (stop === "end_turn") {
return turn.status === "error" ? "error" : "done";
@@ -65,6 +82,9 @@ export class ActivityModel {
turns = $state<Turn[]>([]);
question = $state<PendingQuestion | null>(null);
// A subagent still working after its turn ended: nothing on this page
// knows the turn, so its tool calls hang under the launching Agent node
// of a turn named after that node.
private readonly handlers: Record<string, (c: Cursor) => void> = {
"conversation.created": (c) => this.onConversation(c),
"conversation.updated": (c) => this.onConversation(c),
@@ -89,6 +109,12 @@ export class ActivityModel {
return this.turns.find((turn) => turn.status === "running") ?? null;
}
// A turn is live while it runs or while a subagent it launched still
// works: the CLI reports subagent tool calls after the turn's own end.
get live(): Turn[] {
return this.turns.filter((turn) => isLive(turn));
}
setConversation(info: ConversationInfo): void {
this.conversation = info;
this.question = info.question;
@@ -151,6 +177,42 @@ export class ActivityModel {
turn.startedAt = ts;
turn.itemOrigin = str(event.item_origin);
turn.userText = str(event.text) ?? turn.userText;
if (turn.origin === "task") {
this.settleAgents(ts);
}
}
// The report is in: whatever Agent node still runs has finished.
private settleAgents(ts: string): void {
for (const turn of this.turns) {
if (turn.origin === "agent" && turn.status === "running") {
turn.status = "done";
turn.endedAt = ts;
}
for (const node of Object.values(turn.nodes)) {
const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?";
if (node.status === "running" && node.parent === null && agent) {
node.status = "done";
node.endedAt = ts;
}
}
}
}
// Subagent traffic belongs to the turn that launched the subagent, whatever
// turn the gateway was in when it arrived.
private turnFor(turnId: string, ts: string, parent: string | null): Turn {
if (parent) {
const owner = this.turns.find((turn) => parent in turn.nodes);
if (owner) {
return owner;
}
const current = this.turns.find((turn) => turn.id === turnId);
if (current?.status !== "running") {
return this.ensureTurn(`agent:${parent}`, ts, "agent");
}
}
return this.ensureTurn(turnId, ts, null);
}
private onStream({ event, ts, turnId, parent }: Cursor): void {
@@ -158,7 +220,7 @@ export class ActivityModel {
if (!(turnId && raw && typeof raw === "object")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const turn = this.turnFor(turnId, ts, parent);
const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined;
@@ -172,7 +234,8 @@ export class ActivityModel {
if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
turn.text += delta.text;
turn.text = joinText(turn.text, delta.text, turn.paused);
turn.paused = false;
}
}
}
@@ -181,7 +244,10 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const turn = this.turnFor(turnId, ts, parent);
if (parent === null) {
turn.paused = true;
}
const node = this.ensureNode(
turn,
event.tool_use_id,
@@ -196,7 +262,7 @@ export class ActivityModel {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
const turn = this.turnFor(turnId, ts, parent);
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
node.status = event.is_error ? "error" : "done";
node.endedAt = ts;
@@ -266,11 +332,13 @@ export class ActivityModel {
};
}
// The turn's own tool calls die with it; a subagent's keep going until
// their own results arrive.
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
turn.status = status;
turn.endedAt = ts;
for (const node of Object.values(turn.nodes)) {
if (node.status === "running") {
if (node.status === "running" && node.parent === null) {
node.status = "aborted";
node.endedAt = ts;
}
@@ -291,6 +359,7 @@ export class ActivityModel {
itemOrigin: null,
nodes: {},
origin: origin ?? "?",
paused: false,
resultSubtype: null,
roots: [],
says: [],
@@ -394,3 +463,11 @@ export function summarizeInput(name: string, input: unknown): string {
export function toolLabel(name: string): string {
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
}
// How a turn was started, as a word for the header.
export const ORIGIN_LABELS: Record<string, string> = {
agent: "subagent at work",
inject: "inject",
task: "subagent report",
user: "user",
};
+6 -4
View File
@@ -7,7 +7,7 @@
import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
import { cacheableHistory, historyKey } from "./history-cache";
import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte";
@@ -48,7 +48,7 @@
const tail = $derived(
model.turns
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
.filter((turn) => isLive(turn) || turn.startedAt > loadedAt)
.reverse()
);
const tailSize = $derived(
@@ -150,14 +150,16 @@
$effect(() => {
const timer = setInterval(() => {
if (model.running) {
if (model.live.length > 0) {
now = Date.now();
}
}, TICK_MS);
return () => clearInterval(timer);
});
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
// Text the gateway or the CLI put in the user's seat: an inject header,
// an envelope, a subagent's report.
const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(new Set());
+1 -1
View File
@@ -53,7 +53,7 @@
<span class={cn("size-2 rounded-full", DOT[node.status])}></span>
<span class="flex min-w-0 items-baseline gap-2">
<span class={cn("shrink-0 font-medium", subagent && "text-kind-deep")}>
{toolLabel(node.name)}
{subagent || node.name === "?" ? "Agent" : toolLabel(node.name)}
</span>
{#if summary}
<span class="truncate text-muted-foreground">{summary}</span>
+19 -8
View File
@@ -9,7 +9,7 @@
fmtTokens,
} from "$lib/format";
import { cn } from "$lib/utils";
import type { Turn } from "./activity.svelte";
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import ToolNodeView from "./tool-node.svelte";
@@ -18,10 +18,14 @@
const duration = $derived(
turn.usage?.duration_ms ?? elapsedMs(turn.startedAt, turn.endedAt, now)
);
const origin = $derived(ORIGIN_LABELS[turn.origin] ?? turn.origin);
const originLabel = $derived(
turn.itemOrigin && turn.itemOrigin !== turn.origin
? `${turn.origin} · ${turn.itemOrigin}`
: turn.origin
? `${origin} · ${turn.itemOrigin}`
: origin
);
const pill = $derived(
turn.status !== "running" && isLive(turn) ? "running" : turn.status
);
const toolCount = $derived(Object.keys(turn.nodes).length);
</script>
@@ -29,15 +33,20 @@
<article
class={cn(
"flex flex-col gap-2 border-b py-3",
turn.status === "running" && "bg-signal/[0.03]"
pill === "running" && "bg-signal/[0.03]"
)}
>
<header class="flex flex-wrap items-center gap-x-3 gap-y-1 px-1 text-xs">
<StatusPill status={turn.status} />
{#if turn.origin !== "agent"}
<StatusPill status={pill} />
{/if}
<span
class={cn(
"font-medium",
turn.origin === "inject" ? "text-note" : "text-foreground"
turn.origin === "inject" && "text-note",
turn.origin === "agent" || turn.origin === "task"
? "text-kind-deep"
: "text-foreground"
)}
>
{originLabel}
@@ -65,7 +74,9 @@
</span>
{/if}
</header>
{#if turn.userText}
{#if turn.userText && turn.origin === "task"}
<p class="mx-1 px-1 text-muted-foreground text-xs">{turn.userText}</p>
{:else if turn.userText}
<Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
text={turn.userText}
@@ -88,7 +99,7 @@
{/each}
{#if turn.text}
<Markdown class="mx-1 px-1" text={turn.text} />
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
{:else if turn.status === "running" && turn.origin !== "agent" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model…
</p>