#![allow(clippy::expect_used, clippy::panic, clippy::indexing_slicing)]
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::Value;
const MOVES_CACHE_PREFIX: &[(&str, &str)] = &[
(
"anthropic/request_override/request_overridden_by_hook_blocking.yaml",
"the scenario under test is a hook that rewrites the outbound request \
between turns — moving the prefix is the behavior being recorded, not a \
defect in it",
),
(
"anthropic/request_override/request_overridden_by_hook_streaming.yaml",
"streaming twin of request_overridden_by_hook_blocking; same deliberate \
hook rewrite",
),
(
"openai/streaming_grammar/three_turn_tool_session.yaml",
"turns 2 and 3 are hand-built `completion_request`s that deliberately omit \
`.tool(...)` (the prompt says \"Do not call any tools\"), so the tools \
array disappears from the prefix. This is the test's own construction for \
pinning rs_* id replay, not rig's agent loop — verified that the loop \
re-advertises the full tool set on every turn in \
anthropic/multi_turn_streaming/multi_turn_streaming_tools.yaml",
),
(
"openai/streaming_grammar/tool_then_followup_text.yaml",
"same shape as three_turn_tool_session: a hand-built follow-up request \
that intentionally does not re-advertise the tool",
),
];
#[derive(Deserialize)]
struct RecordedInteraction {
when: RecordedRequest,
}
#[derive(Deserialize)]
struct RecordedRequest {
#[serde(default)]
path: String,
#[serde(default)]
body: Option<String>,
}
type PrefixBlock = (&'static str, String);
fn canonical_prefix_blocks(path: &str, body: &Value) -> Option<Vec<PrefixBlock>> {
let mut blocks: Vec<PrefixBlock> = Vec::new();
let mut add = |level: &'static str, value: Option<&Value>| {
let Some(value) = value else { return };
if value.is_null() {
return;
}
match value.as_array() {
Some(items) => {
for item in items {
blocks.push((level, item.to_string()));
}
}
None => blocks.push((level, value.to_string())),
}
};
if path.ends_with("/v1/messages") {
add("tools", body.get("tools"));
add("system", body.get("system"));
add("messages", body.get("messages"));
} else if path.contains(":generateContent") || path.contains(":streamGenerateContent") {
add("tools", body.get("tools"));
add("systemInstruction", body.get("systemInstruction"));
add("contents", body.get("contents"));
} else if path.contains("/interactions") {
add("tools", body.get("tools"));
add("system_instruction", body.get("system_instruction"));
add("input", body.get("input"));
} else if path.ends_with("/chat/completions") {
add("tools", body.get("tools"));
add("messages", body.get("messages"));
} else if path.ends_with("/responses") {
add("tools", body.get("tools"));
add("instructions", body.get("instructions"));
add("input", body.get("input"));
} else {
return None;
}
Some(blocks)
}
const CONVERSATION_LEVELS: &[&str] = &["messages", "contents", "input"];
fn continues_the_same_conversation(earlier: &[PrefixBlock], later: &[PrefixBlock]) -> bool {
let first_message = |blocks: &[PrefixBlock]| {
blocks
.iter()
.filter(|(level, _)| CONVERSATION_LEVELS.contains(level))
.find(|(_, block)| {
serde_json::from_str::<Value>(block)
.ok()
.and_then(|value| {
value
.get("role")
.and_then(Value::as_str)
.map(|role| !matches!(role, "system" | "developer"))
})
.unwrap_or(true)
})
.map(|(_, block)| block.clone())
};
let message_count = |blocks: &[PrefixBlock]| {
blocks
.iter()
.filter(|(level, _)| CONVERSATION_LEVELS.contains(level))
.count()
};
match (first_message(earlier), first_message(later)) {
(Some(earlier_first), Some(later_first)) => {
earlier_first == later_first && message_count(later) > message_count(earlier)
}
_ => false,
}
}
struct Violation {
scenario: String,
pair: usize,
level: &'static str,
block_index: usize,
earlier: String,
later: String,
}
fn compare(
scenario: &str,
pair: usize,
earlier: &[PrefixBlock],
later: &[PrefixBlock],
) -> Option<Violation> {
for (index, (earlier_level, earlier_block)) in earlier.iter().enumerate() {
let Some((later_level, later_block)) = later.get(index) else {
return Some(Violation {
scenario: scenario.to_owned(),
pair,
level: earlier_level,
block_index: index,
earlier: truncate(earlier_block),
later: "<dropped: the later request is shorter>".to_owned(),
});
};
if earlier_level != later_level || earlier_block != later_block {
return Some(Violation {
scenario: scenario.to_owned(),
pair,
level: earlier_level,
block_index: index,
earlier: truncate(earlier_block),
later: truncate(later_block),
});
}
}
None
}
fn truncate(block: &str) -> String {
const LIMIT: usize = 220;
if block.chars().count() <= LIMIT {
return block.to_owned();
}
let head: String = block.chars().take(LIMIT).collect();
format!("{head}…")
}
fn cassette_files(dir: &Path, found: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries {
let entry = entry.expect("cassette directory entry should be readable");
let path = entry.path();
if path.is_dir() {
cassette_files(&path, found);
} else if path
.extension()
.is_some_and(|extension| extension == "yaml")
{
found.push(path);
}
}
}
fn recorded_requests(contents: &str) -> Vec<(String, Value)> {
serde_yaml::Deserializer::from_str(contents)
.filter_map(|document| RecordedInteraction::deserialize(document).ok())
.filter_map(|interaction| {
let body = interaction.when.body?;
let json = serde_json::from_str::<Value>(&body).ok()?;
Some((interaction.when.path, json))
})
.collect()
}
#[test]
fn recorded_conversations_do_not_move_their_cache_prefix() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cassettes");
assert!(
root.is_dir(),
"cassette root moved or vanished: {}",
root.display()
);
for (path, reason) in MOVES_CACHE_PREFIX {
assert!(
!reason.trim().is_empty(),
"MOVES_CACHE_PREFIX entry `{path}` needs a reason explaining why moving the \
cache prefix is correct for that scenario"
);
}
let mut files = Vec::new();
cassette_files(&root, &mut files);
files.sort();
assert!(
!files.is_empty(),
"no cassettes found under {}",
root.display()
);
let mut violations: Vec<Violation> = Vec::new();
let mut exempted = Vec::new();
let mut compared_pairs = 0usize;
for file in &files {
let scenario = file
.strip_prefix(&root)
.expect("cassette should live under the cassette root")
.to_string_lossy()
.replace('\\', "/");
if let Some((exempt, _)) = MOVES_CACHE_PREFIX
.iter()
.find(|(exempt, _)| scenario.ends_with(exempt))
{
exempted.push((*exempt).to_owned());
continue;
}
let contents = std::fs::read_to_string(file).expect("cassette should be readable");
let requests = recorded_requests(&contents);
let mut previous: Option<(String, Vec<PrefixBlock>)> = None;
for (index, (path, body)) in requests.iter().enumerate() {
let Some(blocks) = canonical_prefix_blocks(path, body) else {
previous = None;
continue;
};
if let Some((previous_path, previous_blocks)) = previous.take()
&& previous_path == *path
&& continues_the_same_conversation(&previous_blocks, &blocks)
{
compared_pairs += 1;
if let Some(violation) = compare(&scenario, index, &previous_blocks, &blocks) {
violations.push(violation);
}
}
previous = Some((path.clone(), blocks));
}
}
assert!(
compared_pairs > 0,
"no consecutive same-endpoint request pairs were compared across {} cassettes — \
the parser or the endpoint table has drifted and this test is now vacuous",
files.len()
);
let stale = MOVES_CACHE_PREFIX
.iter()
.map(|(path, _)| *path)
.filter(|path| !exempted.iter().any(|seen| seen == path))
.collect::<Vec<_>>();
assert!(
stale.is_empty(),
"stale MOVES_CACHE_PREFIX entries (the cassette moved or was deleted; delete the entry): {stale:?}"
);
assert!(
violations.is_empty(),
"a recorded conversation moves its provider-cache wire prefix, which busts the \
prompt cache on every turn. If the behavior is deliberately prefix-moving \
(compaction, history rewriting, dynamic tool disclosure), add the cassette to \
MOVES_CACHE_PREFIX with a reason.\n\n{}",
violations
.iter()
.map(|violation| format!(
"{} [{}] request pair {}, block {}:\n earlier: {}\n later: {}",
violation.scenario,
violation.level,
violation.pair,
violation.block_index,
violation.earlier,
violation.later
))
.collect::<Vec<_>>()
.join("\n\n")
);
}
#[test]
fn prefix_blocks_do_not_iterate_a_single_object_field() {
let body = serde_json::json!({
"systemInstruction": {"parts": [{"text": "be brief"}], "role": "model"},
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
});
let blocks = canonical_prefix_blocks("/v1beta/models/gemini-2.5-flash:generateContent", &body)
.expect("generateContent should be modeled");
assert_eq!(blocks.len(), 2, "{blocks:?}");
assert_eq!(blocks[0].0, "systemInstruction");
assert!(
blocks[0].1.contains("be brief"),
"the whole object must be one block: {blocks:?}"
);
}
#[test]
fn a_moved_block_is_reported_and_an_appended_turn_is_not() {
let turn_one = vec![("messages", "\"a\"".to_owned())];
let appended = vec![
("messages", "\"a\"".to_owned()),
("messages", "\"b\"".to_owned()),
];
assert!(compare("s", 1, &turn_one, &appended).is_none());
let rewritten = vec![("messages", "\"REWRITTEN\"".to_owned())];
let violation =
compare("s", 1, &turn_one, &rewritten).expect("a rewritten earlier block is a violation");
assert_eq!(violation.block_index, 0);
assert_eq!(violation.level, "messages");
}