fix(conversation_store): splice assistant text in place, keeping thinking blocks
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user