From 99b5c69c1ea2a1121dba217338ba98d5bab96399 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 26 Jul 2026 17:57:03 +0200 Subject: [PATCH] fix(conversation_store): splice assistant text in place, keeping thinking blocks --- src/beaver_gateway/core/conversation_store.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/beaver_gateway/core/conversation_store.py b/src/beaver_gateway/core/conversation_store.py index 40aa824..309464b 100644 --- a/src/beaver_gateway/core/conversation_store.py +++ b/src/beaver_gateway/core/conversation_store.py @@ -526,6 +526,75 @@ def _assemble_tail( def _splice_assistant_group( *, stored_group: _StoredDisplayTurn, incoming: ParsedTurn +) -> list[dict[str, Any]] | None: + """Apply the file's prose to a stored assistant turn. + + Prefers substituting text in place, which keeps everything the + 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) + if preserved is not None: + return preserved + return _splice_by_rebuild(stored_group=stored_group, incoming=incoming) + + +def _splice_in_place( + *, stored_group: _StoredDisplayTurn, incoming: ParsedTurn +) -> list[dict[str, Any]] | None: + """Copy the stored messages, swapping only their text block contents. + + Rebuilding a turn from the file loses everything the markdown never + carried — thinking blocks, and the message boundaries claude chose. + Both matter: an ``assistant[thinking] + assistant[text]`` pair (what + claude emits for a reasoning turn) collapses into a single message, + so the history is one message shorter than the one the backend + pooled its live session under, and the next turn misses the cache + and respawns. Substituting in place keeps the message count and the + invisible blocks exactly as stored. + + Returns ``None`` when stored text blocks and incoming text segments + aren't one-to-one — consecutive text blocks merge into a single + rendered segment, so there'd be no way to know how to split the + edited prose back apart. The caller then rebuilds instead. + """ + # See ``diff_and_fork`` for why the parser-type import is deferred. + from beaver_gateway.frontends.markdown.parser import TextSegment + + new_texts = [ + s.text for s in incoming.structure if isinstance(s, TextSegment) and s.text + ] + positions: list[tuple[int, int]] = [] + for mi, msg in enumerate(stored_group.messages): + content = msg.get("content") + if msg["role"] != "assistant" or not isinstance(content, list): + continue + positions.extend( + (mi, bi) + for bi, blk in enumerate(content) + if isinstance(blk, dict) + and blk.get("type") == "text" + and str(blk.get("text", "")).strip() + ) + if len(positions) != len(new_texts): + return None + + out: list[dict[str, Any]] = [ + { + "role": m["role"], + "content": list(m["content"]) + if isinstance(m.get("content"), list) + else m.get("content"), + } + for m in stored_group.messages + ] + for (mi, bi), text in zip(positions, new_texts, strict=True): + out[mi]["content"][bi] = {**out[mi]["content"][bi], "text": text} + return out + + +def _splice_by_rebuild( + *, stored_group: _StoredDisplayTurn, incoming: ParsedTurn ) -> list[dict[str, Any]] | None: """Rebuild an assistant display turn with new text + stored tool_use blocks.