fix(conversation_store): splice assistant text in place, keeping thinking blocks

This commit is contained in:
hh
2026-07-26 17:57:03 +02:00
parent 1ad8f2a475
commit 99b5c69c1e
@@ -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.