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(
|
def _splice_assistant_group(
|
||||||
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
|
*, 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:
|
) -> list[dict[str, Any]] | None:
|
||||||
"""Rebuild an assistant display turn with new text + stored tool_use blocks.
|
"""Rebuild an assistant display turn with new text + stored tool_use blocks.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user