fix(anthropic): preserve interleaved thinking/tool_use block order on replay

Interleaved-thinking turns (adaptive thinking, Claude 4.6+/Opus 4.8) emit
content blocks like:

    thinking_1(signed) tool_use_1 thinking_2(signed) tool_use_2

Anthropic signs each thinking block against the turn content preceding it
at its position. normalize_response split the turn into two parallel lists
(reasoning_details + tool_calls), discarding cross-type order, and
_convert_assistant_message rebuilt it as [all thinking][text][all tool_use].
That moved thinking_2 ahead of tool_use_1, invalidating its signature, so
Anthropic rejected the latest assistant message with HTTP 400:

    messages.N.content.M: `thinking` or `redacted_thinking` blocks in the
    latest assistant message cannot be modified.

Observed repeatedly in agent.conversation_loop against api.anthropic.com /
claude-opus-4-8, recurring across sessions on multi-thinking-block turns.

Fix: carry a verbatim, order-preserving copy of the turn's content blocks
(anthropic_content_blocks) end-to-end - capture in normalize_response,
persist/restore through state.db, and replay unchanged for the latest
assistant message. Gated to turns that actually interleave signed thinking
with tool_use, so normal turns are unaffected.

Adds 3 regression tests including a SQLite round-trip covering the
crash-recovery reload path.
This commit is contained in:
RaumfahrerSpiffy
2026-06-10 20:45:16 -07:00
committed by Teknium
parent ad9012097b
commit aaccaada28
7 changed files with 344 additions and 7 deletions
+27 -4
View File
@@ -488,6 +488,7 @@ CREATE TABLE IF NOT EXISTS messages (
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
anthropic_content_blocks TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT,
platform_message_id TEXT,
@@ -2240,6 +2241,7 @@ class SessionDB:
reasoning: str = None,
reasoning_content: str = None,
reasoning_details: Any = None,
anthropic_content_blocks: Any = None,
codex_reasoning_items: Any = None,
codex_message_items: Any = None,
platform_message_id: str = None,
@@ -2262,6 +2264,10 @@ class SessionDB:
json.dumps(reasoning_details)
if reasoning_details else None
)
anthropic_content_blocks_json = (
json.dumps(anthropic_content_blocks)
if anthropic_content_blocks else None
)
codex_items_json = (
json.dumps(codex_reasoning_items)
if codex_reasoning_items else None
@@ -2284,9 +2290,10 @@ class SessionDB:
cursor = conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
reasoning, reasoning_content, reasoning_details, anthropic_content_blocks,
codex_reasoning_items,
codex_message_items, platform_message_id, observed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
@@ -2300,6 +2307,7 @@ class SessionDB:
reasoning,
reasoning_content,
reasoning_details_json,
anthropic_content_blocks_json,
codex_items_json,
codex_message_items_json,
platform_message_id,
@@ -2348,6 +2356,9 @@ class SessionDB:
role = msg.get("role", "unknown")
tool_calls = msg.get("tool_calls")
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
anthropic_content_blocks = (
msg.get("anthropic_content_blocks") if role == "assistant" else None
)
codex_reasoning_items = (
msg.get("codex_reasoning_items") if role == "assistant" else None
)
@@ -2358,6 +2369,9 @@ class SessionDB:
reasoning_details_json = (
json.dumps(reasoning_details) if reasoning_details else None
)
anthropic_content_blocks_json = (
json.dumps(anthropic_content_blocks) if anthropic_content_blocks else None
)
codex_items_json = (
json.dumps(codex_reasoning_items) if codex_reasoning_items else None
)
@@ -2374,9 +2388,10 @@ class SessionDB:
conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
reasoning, reasoning_content, reasoning_details, anthropic_content_blocks,
codex_reasoning_items,
codex_message_items, platform_message_id, observed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
@@ -2390,6 +2405,7 @@ class SessionDB:
msg.get("reasoning") if role == "assistant" else None,
msg.get("reasoning_content") if role == "assistant" else None,
reasoning_details_json,
anthropic_content_blocks_json,
codex_items_json,
codex_message_items_json,
platform_msg_id,
@@ -2732,6 +2748,7 @@ class SessionDB:
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"anthropic_content_blocks, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed "
f"FROM messages WHERE session_id IN ({placeholders})"
f"{active_clause} ORDER BY id",
@@ -2779,6 +2796,12 @@ class SessionDB:
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize reasoning_details, falling back to None")
msg["reasoning_details"] = None
if row["anthropic_content_blocks"]:
try:
msg["anthropic_content_blocks"] = json.loads(row["anthropic_content_blocks"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize anthropic_content_blocks, falling back to None")
msg["anthropic_content_blocks"] = None
if row["codex_reasoning_items"]:
try:
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])