use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use supercode::{Agent, ChatMessage, ChatRequest, Config, Provider, Usage};
struct RecordingProvider {
calls: AtomicUsize,
seen_models: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl Provider for RecordingProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.seen_models.lock().unwrap().push(req.model.clone());
Ok((ChatMessage::assistant("ok"), Usage::default()))
}
}
#[tokio::test]
async fn set_model_changes_the_model_on_the_very_next_request() {
let config = Config::builder().model("vendor/model-a").build();
let seen = Arc::new(Mutex::new(Vec::new()));
let provider = Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: seen.clone(),
});
let mut agent = Agent::with_provider(config, provider);
assert_eq!(agent.model(), "vendor/model-a");
agent.send("first").await.unwrap();
agent.set_model("vendor/model-b");
assert_eq!(agent.model(), "vendor/model-b");
agent.send("second").await.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(
seen.as_slice(),
&["vendor/model-a".to_string(), "vendor/model-b".to_string()],
"the second request must carry the switched model, not a stale copy"
);
}
#[tokio::test]
async fn set_model_before_any_request_is_honored_from_the_first_send() {
let config = Config::builder().model("vendor/model-a").build();
let seen = Arc::new(Mutex::new(Vec::new()));
let provider = Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: seen.clone(),
});
let mut agent = Agent::with_provider(config, provider);
agent.set_model("vendor/model-c");
agent.send("hi").await.unwrap();
assert_eq!(
seen.lock().unwrap().as_slice(),
&["vendor/model-c".to_string()]
);
}
use supercode::model_change::ModelChangeRecord;
use supercode::session::Session;
fn session_with_reasoning_artifacts() -> Session {
let mut session = Session::from_claude_code_str("").unwrap();
let mut assistant = ChatMessage::assistant("here's my answer");
assistant.metadata.insert(
"thinking".to_string(),
"model-A's private chain of thought".to_string(),
);
assistant
.metadata
.insert("thinking_signature".to_string(), "sig-abc".to_string());
assistant.content_parts = Some(vec![
serde_json::json!({"type": "text", "text": "here's my answer"}),
serde_json::json!({"type": "thinking", "text": "model-A's private chain of thought"}),
]);
session.messages = vec![ChatMessage::user("question"), assistant];
session
}
#[tokio::test]
async fn switch_model_default_off_is_byte_identical_to_set_model() {
let config = Config::builder().model("vendor/model-a").build();
assert!(!config.model_switch_allow_switch);
let mut agent = Agent::with_provider(
config,
Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: Arc::new(Mutex::new(Vec::new())),
}),
);
agent.load_session(session_with_reasoning_artifacts());
let before = agent.history().to_vec_metadata_snapshot();
agent.switch_model("vendor/model-b");
assert_eq!(agent.model(), "vendor/model-b");
assert!(
agent.model_change_records().is_empty(),
"allow_switch=false must never create a model_change record"
);
assert_eq!(agent.history().to_vec_metadata_snapshot(), before);
}
#[tokio::test]
async fn switch_model_on_filters_reasoning_artifacts_and_records_the_switch() {
let config = Config::builder()
.model("vendor/model-a")
.model_switch_allow_switch(true)
.build();
let seen = Arc::new(Mutex::new(Vec::new()));
let mut agent = Agent::with_provider(
config,
Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: seen.clone(),
}),
);
agent.load_session(session_with_reasoning_artifacts());
let assistant_before = agent
.history()
.iter()
.find(|m| m.content.as_deref() == Some("here's my answer"))
.unwrap();
assert!(assistant_before.metadata.contains_key("thinking"));
assert!(assistant_before
.content_parts
.as_ref()
.unwrap()
.iter()
.any(|p| p["type"] == "thinking"));
agent.switch_model("vendor/model-b");
let records = agent.model_change_records();
assert_eq!(records.len(), 1);
let r = &records[0];
assert_eq!(r.from_model, "vendor/model-a");
assert_eq!(r.to_model, "vendor/model-b");
assert!(r.reasoning_filtered);
assert!(
r.reasoning_artifacts_filtered >= 1,
"the fixture message should have been counted as touched"
);
let assistant_after = agent
.history()
.iter()
.find(|m| {
m.content.as_deref() == Some("here's my answer")
|| m.content_parts
.as_ref()
.map(|p| p.iter().any(|x| x["type"] == "text"))
.unwrap_or(false)
})
.expect("the assistant message with reasoning stripped is still present");
assert!(!assistant_after.metadata.contains_key("thinking"));
assert!(!assistant_after.metadata.contains_key("thinking_signature"));
let parts_after = assistant_after.content_parts.as_ref().unwrap();
assert!(
parts_after.iter().all(|p| p["type"] != "thinking"),
"{parts_after:?}"
);
assert!(parts_after.iter().any(|p| p["type"] == "text"));
agent.send("continue").await.unwrap();
assert_eq!(
seen.lock().unwrap().last(),
Some(&"vendor/model-b".to_string())
);
}
#[tokio::test]
async fn switch_model_to_the_same_model_is_a_no_op_boundary() {
let config = Config::builder()
.model("vendor/model-a")
.model_switch_allow_switch(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: Arc::new(Mutex::new(Vec::new())),
}),
);
agent.switch_model("vendor/model-a");
assert_eq!(agent.model(), "vendor/model-a");
assert!(agent.model_change_records().is_empty());
}
#[tokio::test]
async fn switch_model_records_round_trip_through_the_store() {
let config = Config::builder()
.model("vendor/model-a")
.model_switch_allow_switch(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: Arc::new(Mutex::new(Vec::new())),
}),
);
agent.load_session(session_with_reasoning_artifacts());
agent.switch_model("vendor/model-b");
agent.switch_model("vendor/model-c");
assert_eq!(agent.model_change_records().len(), 2);
let tmp = std::env::temp_dir().join(format!(
"sc-p4c-model-change-store-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let store = supercode::SessionStore::open(&tmp).unwrap();
store.save("sess", "t", "[]").unwrap();
agent.save_model_change_log(&store, "sess").unwrap();
let loaded: Vec<ModelChangeRecord> = store.load_model_change_log("sess").unwrap();
assert_eq!(loaded, agent.model_change_records());
let _ = std::fs::remove_dir_all(&tmp);
}
use supercode::SessionFormat;
const MODEL_A_SECRET_COT: &str = "MODEL-A-SECRET-CHAIN-OF-THOUGHT-7f3a";
const MODEL_A_SECRET_SIGNATURE: &str = "MODEL-A-SECRET-SIGNATURE-9c21";
fn session_with_only_thinking_blocks_reasoning() -> Session {
let mut session = Session::from_claude_code_str("").unwrap();
let mut assistant = ChatMessage::assistant("here's my answer");
assistant.metadata.insert(
"thinking_blocks".to_string(),
serde_json::json!([{
"type": "thinking",
"thinking": MODEL_A_SECRET_COT,
"signature": MODEL_A_SECRET_SIGNATURE,
}])
.to_string(),
);
session.messages = vec![ChatMessage::user("question"), assistant];
session
}
#[tokio::test]
async fn switch_model_then_claude_code_export_never_resurrects_model_a_thinking_blocks() {
let config = Config::builder()
.model("vendor/model-a")
.model_switch_allow_switch(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(RecordingProvider {
calls: AtomicUsize::new(0),
seen_models: Arc::new(Mutex::new(Vec::new())),
}),
);
agent.load_session(session_with_only_thinking_blocks_reasoning());
let before = agent
.history()
.iter()
.find(|m| m.content.as_deref() == Some("here's my answer"))
.unwrap();
assert_eq!(
before
.metadata
.get("thinking_blocks")
.map(|s| s.contains(MODEL_A_SECRET_COT)),
Some(true)
);
agent.switch_model("vendor/model-b");
let r = &agent.model_change_records()[0];
assert!(r.reasoning_filtered);
assert!(r.reasoning_artifacts_filtered >= 1);
let mut exported = Session::from_claude_code_str("").unwrap();
exported.messages = agent.history().to_vec();
let jsonl = exported.to_jsonl(SessionFormat::ClaudeCode).unwrap();
assert!(
!jsonl.contains(MODEL_A_SECRET_COT),
"model-A's reasoning text leaked into the Claude Code export: {jsonl}"
);
assert!(
!jsonl.contains(MODEL_A_SECRET_SIGNATURE),
"model-A's reasoning signature leaked into the Claude Code export: {jsonl}"
);
let mut unfiltered = session_with_only_thinking_blocks_reasoning();
unfiltered.messages.remove(0); let unfiltered_jsonl = unfiltered.to_jsonl(SessionFormat::ClaudeCode).unwrap();
assert!(
unfiltered_jsonl.contains(MODEL_A_SECRET_COT),
"contrast control failed: the CC exporter should surface an unfiltered \
thinking_blocks message's reasoning text — {unfiltered_jsonl}"
);
}
trait MetadataSnapshot {
fn to_vec_metadata_snapshot(&self) -> Vec<std::collections::BTreeMap<String, String>>;
}
impl MetadataSnapshot for [ChatMessage] {
fn to_vec_metadata_snapshot(&self) -> Vec<std::collections::BTreeMap<String, String>> {
self.iter().map(|m| m.metadata.clone()).collect()
}
}