fix(backends,conversations,telegram,ui): a background subagent reports back as its own turn
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user