From 0cc2c5291f4f9f464adfbaa229d6932186969e4a Mon Sep 17 00:00:00 2001 From: h Date: Sat, 29 Aug 2026 01:37:52 +0200 Subject: [PATCH] fix(markdown): a rewritten assistant reply reseeds the session instead of resuming it --- src/beaver_gateway/backends/claude_sdk.py | 10 ++++-- src/beaver_gateway/core/conversation_store.py | 33 ++++++++++++------- src/beaver_gateway/core/conversations.py | 1 + .../frontends/markdown/frontend.py | 4 +-- tests/test_routing.py | 27 +++++++++++++++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/beaver_gateway/backends/claude_sdk.py b/src/beaver_gateway/backends/claude_sdk.py index 0f5d992..f3651c0 100644 --- a/src/beaver_gateway/backends/claude_sdk.py +++ b/src/beaver_gateway/backends/claude_sdk.py @@ -296,6 +296,7 @@ class ClaudeSdkBackend: system: str | None = None, # noqa: ARG002 - the agent owns its prompt conversation_id: str | None = None, session_id: str | None = None, + reseed: bool = False, capture: TurnCapture | None = None, kind: str = "deep", pinned: bool = False, @@ -315,7 +316,9 @@ class ClaudeSdkBackend: prior = history[:-1] key = conversation_id or fingerprint(prior) spec = _SessionSpec(kind=kind, pinned=pinned, tools=tools) - live = await self._acquire(key, session_id=session_id, history=prior, spec=spec) + live = await self._acquire( + key, session_id=session_id, history=prior, spec=spec, reseed=reseed + ) message_id = f"msg_{uuid.uuid4().hex}" yield build_message_start(message_id=message_id, model=self._agent.model) turn = _Turn() @@ -484,10 +487,13 @@ class ClaudeSdkBackend: session_id: str | None, history: list[dict[str, Any]], spec: _SessionSpec, + reseed: bool = False, ) -> Session: live = self._pool.get(key) if live is not None: - return live + if not reseed: + return live + await self._pool.close(key) resume = session_id if resume is not None: await self.repair_session(resume) diff --git a/src/beaver_gateway/core/conversation_store.py b/src/beaver_gateway/core/conversation_store.py index abe44c0..850bffa 100644 --- a/src/beaver_gateway/core/conversation_store.py +++ b/src/beaver_gateway/core/conversation_store.py @@ -87,6 +87,12 @@ class ForkOutcome: messages: list[MessageParam] persist_messages: list[dict[str, Any]] divergence_index: int | None + edited: bool = False + """An earlier assistant turn's prose was rewritten in the file.""" + + @property + def reuse_session(self) -> bool: + return self.divergence_index is None and not self.edited # ---- public store API --------------------------------------------------- @@ -407,7 +413,7 @@ def diff_and_fork( new_user_turn = incoming[-1] prior_incoming = incoming[:-1] - spliced_groups, divergence = _walk_prefix(prior_incoming, stored_groups) + spliced_groups, divergence, edited = _walk_prefix(prior_incoming, stored_groups) if divergence is None and len(prior_incoming) < len(stored_groups): if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn): @@ -433,6 +439,7 @@ def diff_and_fork( messages=backend_msgs, persist_messages=persist_msgs, divergence_index=divergence, + edited=edited, ) @@ -470,26 +477,29 @@ def _file_lags_store( def _walk_prefix( prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn] -) -> tuple[list[list[dict[str, Any]]], int | None]: +) -> tuple[list[list[dict[str, Any]]], int | None, bool]: """Walk incoming vs stored side-by-side until first divergence. Returns the spliced/matched group list (one entry per matched display turn, each carrying the raw messages we'll feed to the - backend for that turn) and the divergence index (``None`` if all - of ``prior_incoming`` matched). + backend for that turn), the divergence index (``None`` if all + of ``prior_incoming`` matched) and whether any assistant prose + was spliced in from the file - a rewritten reply keeps the + structure but must not resume the session that said otherwise. """ from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment spliced_groups: list[list[dict[str, Any]]] = [] + edited = False for i, inc in enumerate(prior_incoming): if i >= len(stored_groups): - return spliced_groups, i + return spliced_groups, i, edited st = stored_groups[i] if inc.role != st.role: - return spliced_groups, i + return spliced_groups, i, edited if inc.role == "user": if inc.text != st.spoken_text: - return spliced_groups, i + return spliced_groups, i, edited spliced_groups.append(list(st.messages)) continue inc_skeleton = tuple( @@ -499,17 +509,18 @@ def _walk_prefix( # Files rendered without tool callouts (ยง3.10) carry no skeleton: # prose alone decides whether the turn matched. if inc_skeleton and inc_skeleton != st.skeleton: - return spliced_groups, i + return spliced_groups, i, edited if inc.text == st.spoken_text: spliced_groups.append(list(st.messages)) continue if inc_skeleton and inc_text_count != st.text_segment_count: - return spliced_groups, i + return spliced_groups, i, edited spliced = _splice_assistant_group(stored_group=st, incoming=inc) if spliced is None: - return spliced_groups, i + return spliced_groups, i, edited spliced_groups.append(spliced) - return spliced_groups, None + edited = True + return spliced_groups, None, edited def _assemble_tail( diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index 53034ef..6cc5ec1 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -877,6 +877,7 @@ class Conversations: messages=messages, conversation_id=conv.external_id, session_id=resume if use_session else None, + reseed=not use_session, capture=capture, kind=conv.kind, pinned=conv.kind == "master", diff --git a/src/beaver_gateway/frontends/markdown/frontend.py b/src/beaver_gateway/frontends/markdown/frontend.py index ba3cd94..8dce655 100644 --- a/src/beaver_gateway/frontends/markdown/frontend.py +++ b/src/beaver_gateway/frontends/markdown/frontend.py @@ -400,7 +400,7 @@ class MarkdownFrontend(Frontend): messages=outcome.messages, origin="user", capture=capture, - use_session=outcome.divergence_index is None, + use_session=outcome.reuse_session, ) try: message = await self._stream_to_file( @@ -623,7 +623,7 @@ class MarkdownFrontend(Frontend): messages=outcome.messages, origin="user", capture=capture, - use_session=outcome.divergence_index is None, + use_session=outcome.reuse_session, ) acc = StreamAccumulator() diff --git a/tests/test_routing.py b/tests/test_routing.py index 5c89038..57d0fe9 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -228,3 +228,30 @@ async def test_anthropic_turns_become_one_deep_conversation(stack: Stack) -> Non headers=AUTH, ) assert r.status_code == 400 and "runs on 'd'" in r.json()["detail"] + + +async def test_markdown_edited_reply_reseeds_the_session(stack: Stack) -> None: + async with stack.client(stack.markdown) as c: + r = await c.post( + "/chat", + json={"filename": "e.md", "agent": "d", "content": "### User:\n\nanimal\n"}, + headers=AUTH, + ) + assert r.status_code == 200, r.text + content = r.json()["new_content"] + assert "ok:animal" in content and len(ScriptedClient.instances) == 1 + edited = content.replace("ok:animal", "penguin") + "\nwhich one?\n" + r = await c.post( + "/chat", + json={"filename": "e.md", "agent": "d", "content": edited}, + headers=AUTH, + ) + assert r.status_code == 200, r.text + assert len(ScriptedClient.instances) == 2 + first, second = ScriptedClient.instances + assert not first.connected and second.prompts == ["which one?"] + assert second.options.resume not in (None, first.session_id) + assert "penguin" in repr(vars(stack.world.store)) + async with stack.world.db.session() as session: + stored = await load_messages(session, conversation_id=1) + assert stored[1]["content"] == [{"type": "text", "text": "penguin"}]