From c88726db9efec54be8de8f2d491fd202c1b03a86 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 28 Aug 2026 19:03:08 +0200 Subject: [PATCH] feat(core): seeds without text wait for the first turn --- src/beaver_gateway/core/conversations.py | 45 ++++++++++++++++++------ tests/test_conversations.py | 6 ++++ tests/test_telegram.py | 3 +- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index c029326..052f6f6 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -38,7 +38,7 @@ from claude_agent_sdk import ( from sqlmodel import col, select from beaver_gateway.core.injects import InjectQueue, inject_header -from beaver_gateway.core.kinds import KINDS, Kind +from beaver_gateway.core.kinds import KINDS, Kind, as_kind from beaver_gateway.core.transcript import ( messages_from_entries, render_messages, @@ -492,7 +492,9 @@ class Conversations: ``binding`` = ``(frontend, external_id)`` puts it into a window that already exists (a topic the user created) instead of asking the home frontend to ``materialize`` one. ``text`` rides with the seed - as the first thing the user said, whatever the seed mode. + as the first thing the user said, whatever the seed mode; without + it the seed waits in ``flags`` and opens the first turn, so a fresh + window stays silent and costs nothing until someone speaks. """ if seed not in SEEDS: msg = f"unknown seed {seed!r}" @@ -526,21 +528,41 @@ class Conversations: await self.bind(conv, frontend=binding[0], external_id=binding[1]) else: await self.materialize(conv) - prompt = await self._seed_text( - SeedContext( - kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title - ), - window=window, + ctx = SeedContext( + kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title ) + if text is None: + return await self.set_flags(conv, {"seed": seed, "seed_window": window}) await self._queue.push( conversation_id=cast("int", conv.id), priority="user", - origin=origin if text and seed != "brief" else f"сид:{seed}", - text=prompt, + origin=f"сид:{seed}" if seed == "brief" else origin, + text=await self._seed_text(ctx, window=window), ) self._ensure_worker(cast("int", conv.id)) return conv + async def _pending_seed(self, conv: Conversation) -> str | None: + """A seed nobody has spoken after yet: rendered now, spent once.""" + seed = conv.flags.get("seed") + if not seed: + return None + parent = await self.get_row(conv.parent_id) if conv.parent_id else None + ctx = SeedContext( + kind=as_kind(conv.kind), + seed=str(seed), + agent=conv.agent_name, + parent=parent, + text=None, + title=conv.title, + ) + window = conv.flags.get("seed_window") + text = await self._seed_text( + ctx, window=window if isinstance(window, int) else None + ) + await self.set_flags(conv, {"seed": None, "seed_window": None}) + return text + async def fork( self, conv: Conversation, @@ -983,8 +1005,6 @@ class Conversations: stamp = datetime.now(UTC).astimezone().strftime("%Y-%m-%d %H:%M") title = f" «{ctx.title}»" if ctx.title else "" head = f"[сид: {ctx.seed}] {ctx.kind}{title}, {stamp}." - if not ctx.text or ctx.seed == "brief": - head += " Это контекст, не обращение: отвечать не нужно." body: str | None = None if self._texts.seed is not None: produced: Any = self._texts.seed(ctx) @@ -1065,6 +1085,9 @@ class Conversations: else: origin = "inject" prompt = "\n\n".join(f"{inject_header(i.origin)}\n{i.text}" for i in batch) + seed = await self._pending_seed(conv) + if seed: + prompt = f"{seed}\n\n{prompt}" try: text, capture = await self.run_text_turn( conv, prompt, origin=origin, turn_id=turn_id, item_origin=head.origin diff --git a/tests/test_conversations.py b/tests/test_conversations.py index dc4667a..95b195a 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -396,8 +396,14 @@ async def test_copy_seed_forks_parent_with_window(world: World) -> None: "q6", [{"type": "text", "text": "a7"}], ] + assert (await world.conversations.get(child.external_id)).flags["seed"] == "copy" + await world.conversations.post(child, "go") await world.settle(child, 1) assert ScriptedClient.instances[0].options.resume == child.session_id + prompt = ScriptedClient.instances[0].prompts[0] + assert prompt.startswith("[сид: copy] branch, ") + assert "последние 1 тёрнов" in prompt and prompt.endswith("\n\ngo") + assert (await world.conversations.get(child.external_id)).flags["seed"] is None async def test_merge_injects_summary_into_parent(world: World) -> None: diff --git a/tests/test_telegram.py b/tests/test_telegram.py index af4a221..1aaf1ec 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -462,13 +462,14 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None: frontend="telegram", external_id=f"{USER}/902" ) assert child is not None and child.title == "отчёт" - await stack.world.settle(child, 1) await asyncio.sleep(0.2) + assert await stack.world.conversations.queue.recent(child.id) == [] assert all(m["thread"] != 902 for m in stack.bot.sent) assert all(d["message_thread_id"] != 902 for d in stack.bot.drafts) stack.bot.message("первое в новый топик", thread=902) reply = await stack.until(lambda: stack.sent_with("первое в новый топик"), what="r") assert reply["thread"] == 902 + assert reply["text"].startswith("ok:[сид: morning] branch «отчёт»") assert await stack.tg.mark_topic(child) assert stack.bot.topics[-1] == "edit:902:✅ отчёт"