fix(messages): replay thinking blocks only for the active tool loop

Root cause of 'Invalid signature in thinking block' (400 at
messages.1.content.0, Claude models): build_messages_request replayed
EVERY stored Reasoning item as a thinking block with no origin check —
history synthesized by other backends (encrypted_content: None → the
mandatory signature field serialized as ""), Responses-API tco_* blobs
(signature bytes, no text), and blocks signed by a DIFFERENT model after
a mid-session /model switch. Anthropic validates every replayed
signature (model-bound), so such histories 400 deterministically. The
platform split was circumstantial: Windows sessions started on the
default model and switched to Claude; macOS sessions were Claude-native
from turn 1. Adversarially verified — no platform-divergent byte path
exists in capture, storage, or replay.

New prune_replayed_thinking pass (Pi/Claude Code replay policy): keep
exactly the final assistant message's thinking when its tool loop is
still open (request ends on the tool results — an open loop can never
span a model switch) and the block is genuinely signed; strip every
other thinking block (the API ignores valid prior-turn thinking and
rejects invalid). Assistant messages emptied by the strip (thinking-only
aborted turns) are removed — empty content arrays are rejected too.

Tests: three unit tests pin strip-outside-loop (unsigned, tco_*, stale
signed), keep-in-active-loop (verbatim text+signature at content.0), and
emptied-message removal; the legacy-upgrade integration test now proves
both wire fidelity in the active loop AND stripping once the loop
closes.

Verified: sampling-types + sampler + chat-state + shell 6072 tests
green, clippy clean.
This commit is contained in:
2026-07-22 23:26:49 -04:00
parent 10149f50dd
commit 6ba24db019
2 changed files with 265 additions and 15 deletions
@@ -535,13 +535,20 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning
/// item that emits a Anthropic Messages `thinking` content block (with `thinking`
/// + `signature`) on the outgoing `/v1/messages` request.
/// item — and when that turn is the ACTIVE tool-use continuation, its
/// `thinking` block (text + signature) must reach the outgoing
/// `/v1/messages` request verbatim.
///
/// Outside an active tool loop the block must be STRIPPED: Anthropic
/// validates every replayed signature (model-bound), so replaying stale
/// thinking is exactly what 400'd with "Invalid `signature` in `thinking`
/// block" after cross-model histories (see `prune_replayed_thinking`).
#[tokio::test]
async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl. Anthropic Messages
// thinking blocks never carried an id (stream/messages.rs sets
// id=""), and the signature lives in `encrypted`.
async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
// assistant turn issued a tool call (thinking blocks never carried an
// id — stream/messages.rs sets id="" and the signature lives in
// `encrypted`). The pending tool_result makes this the active loop.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("chat_history.jsonl"),
@@ -550,7 +557,9 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\n",
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5"}"#,
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5","tool_calls":[{"id":"tc1","name":"read_file","arguments":"{}"}]}"#,
"\n",
r#"{"type":"tool_result","tool_call_id":"tc1","content":"file contents"}"#,
"\n",
),
)
@@ -558,7 +567,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 2. Load + upgrade.
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
let items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
assert!(
items
.iter()
@@ -566,20 +575,18 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
);
// 3. Continue and send over the Messages API, capturing the body.
items.push(ConversationItem::user("q2"));
// 3. Send the tool-loop continuation over the Messages API.
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let client = create_test_client(&server.url(), ApiBackend::Messages);
let _ = client
.conversation_collect(ConversationRequest::from_items(items))
.conversation_collect(ConversationRequest::from_items(items.clone()))
.await
.unwrap();
// 4. The reconstructed reasoning must emit a Anthropic Messages `thinking`
// content block carrying the thinking text + signature.
// 4. The active loop's reconstructed reasoning must emit an Anthropic
// `thinking` content block carrying the thinking text + signature.
let body = server.request_bodies().pop().unwrap();
let messages = body.get("messages").unwrap().as_array().unwrap();
let thinking_block = messages
@@ -593,7 +600,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
})
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
.unwrap_or_else(|| {
panic!("reconstructed reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
panic!("active-loop reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
});
assert_eq!(
thinking_block.get("thinking").and_then(Value::as_str),
@@ -605,6 +612,25 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
Some("SIGNATURE_abc"),
"signature (encrypted) preserved — required to reuse the thought server-side"
);
// 5. A follow-up user turn CLOSES the loop: the same history plus a new
// user message must replay NO thinking block at all.
let mut closed = items;
closed.push(ConversationItem::user("q2"));
let _ = client
.conversation_collect(ConversationRequest::from_items(closed))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
let any_thinking = body["messages"].as_array().unwrap().iter().any(|m| {
m.get("content")
.and_then(Value::as_array)
.is_some_and(|c| c.iter().any(|b| b["type"] == "thinking"))
});
assert!(
!any_thinking,
"stale thinking must be stripped outside the active tool loop; body: {body:#?}"
);
}
// ============================================================================