Watch
1
0
Fork
You've already forked souveraine
0

claude bridge: strip leading turns as pairs

Compaction can drop the opening user message, and the tool_result
answering the first turn orphans on the wire when its call alone is
deleted — a 400. Calls and the results bound to them go together.
This commit is contained in:
Fimeg 2026-08-15 06:10:08 -04:00
commit 7329351f89

View file

@ -18,7 +18,7 @@
//! validates the client fingerprint/salt against the first-party client to gate
//! subscription access. It uses YOUR token, from YOUR login, on YOUR machine.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -1002,9 +1002,31 @@ fn translate_messages(
}
}
// Anthropic requires the first role to be "user".
// Anthropic requires the first role to be "user". A leading assistant
// turn is only dropped as a pair: compaction can erase the opening user
// message, and the tool_results answering that turn would orphan on the
// wire if the calls alone were deleted.
while merged.first().is_some_and(|(role, _)| role == "assistant") {
let dropped: HashSet<String> = merged[0]
.1
.iter()
.filter_map(|b| b.get("id").and_then(Value::as_str))
.map(str::to_owned)
.collect();
merged.remove(0);
if !dropped.is_empty() {
for (_, blocks) in &mut merged {
blocks.retain(|b| {
b.get("type").and_then(Value::as_str) != Some("tool_result")
|| !b.get("tool_use_id")
.and_then(Value::as_str)
.is_some_and(|id| dropped.contains(id))
});
}
// A user turn that only answered the dropped calls is empty now
// and cannot stay on the wire either.
merged.retain(|(role, blocks)| role != "user" || !blocks.is_empty());
}
}
let merged = normalize_system_turns(merged);
@ -1486,6 +1508,41 @@ mod tests {
assert_eq!(first_user, "please read /etc/hostname");
}
/// Compaction can drop the opening user message; history then begins with
/// an assistant turn that called tools. The first role must still be
/// "user", but stripping that turn alone would orphan its tool_result —
/// the call gone, the result unanswered, a 400. The pair goes together.
#[test]
fn leading_assistant_turn_drops_with_the_results_that_answer_it() {
let msgs = vec![
Message::assistant_tool_calls(
"",
vec![MessageToolCall {
id: "call_1".into(),
tool_type: "function".into(),
function: MessageToolCallFunction {
name: "read".into(),
arguments: r#"{"path":"/etc/hostname"}"#.into(),
},
}],
),
Message::tool_result("call_1", "read", "example-host"),
Message::text("user", "thanks"),
];
let (messages, _, _) = translate_messages(&msgs, true);
let roles: Vec<&str> = messages
.iter()
.map(|m| m["role"].as_str().unwrap())
.collect();
assert_eq!(roles, vec!["user"]);
assert_eq!(messages[0]["content"][0]["text"], "thanks");
for m in &messages {
for block in m["content"].as_array().unwrap() {
assert_ne!(block["type"], "tool_result");
}
}
}
#[test]
fn translates_system_message_into_system_field() {
let msgs = vec![