use std::cmp::Ordering;
use anyhow::{Context, Result};
use serde_json::Value;
pub const SETTINGS_SCHEMA: u32 = 7;
pub const PROFILES_SCHEMA: u32 = 1;
pub const CHAT_SCHEMA: u32 = 6;
pub const DB_SCHEMA: u32 = 1;
pub struct Step {
pub to: u32,
pub summary: &'static str,
pub apply: fn(Value) -> Result<Value>,
}
pub struct JsonArtifact {
pub name: &'static str,
pub current: u32,
pub detect: fn(&Value) -> u32,
pub steps: &'static [Step],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assessment {
UpToDate,
Migrate { from: u32 },
Downgrade { from: u32 },
}
impl JsonArtifact {
pub fn assess(&self, v: &Value) -> Assessment {
let from = (self.detect)(v);
match from.cmp(&self.current) {
Ordering::Equal => Assessment::UpToDate,
Ordering::Less => Assessment::Migrate { from },
Ordering::Greater => Assessment::Downgrade { from },
}
}
pub fn apply_steps(&self, mut v: Value, from: u32) -> Result<Value> {
for step in self.steps.iter().filter(|s| s.to > from) {
tracing::info!(
artifact = self.name,
to = step.to,
"migration step: {}",
step.summary
);
v = (step.apply)(v)
.with_context(|| format!("migration step {} → v{}", self.name, step.to))?;
}
Ok(v)
}
}
fn detect_settings(v: &Value) -> u32 {
v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
}
fn detect_profiles(v: &Value) -> u32 {
if v.is_array() {
1
} else {
v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
}
}
fn detect_chat(v: &Value) -> u32 {
v.get("v").and_then(Value::as_u64).unwrap_or(1) as u32
}
const V1_SUBAGENT_TIMEOUT_SECS: u64 = 60;
const V1_SUBAGENT_MAX_TOKENS: u64 = 1024;
fn settings_to_v2(mut v: Value) -> Result<Value> {
if let Some(tools) = v.get_mut("tools").and_then(Value::as_object_mut) {
if let Some(old) = tools.remove("subagent_timeout_secs")
&& old.as_u64() != Some(V1_SUBAGENT_TIMEOUT_SECS)
{
tools.insert("subagent_run_timeout_secs".into(), old);
}
if tools.get("subagent_max_tokens").and_then(Value::as_u64) == Some(V1_SUBAGENT_MAX_TOKENS)
{
tools.remove("subagent_max_tokens");
}
}
v["schema_version"] = Value::from(2);
Ok(v)
}
const V2_MAX_TOKENS: u64 = 2048;
const V3_MAX_TOKENS: u64 = 16384;
fn settings_to_v3(mut v: Value) -> Result<Value> {
if let Some(sampling) = v.get_mut("default_sampling").and_then(Value::as_object_mut)
&& sampling.get("max_tokens").and_then(Value::as_u64) == Some(V2_MAX_TOKENS)
{
sampling.insert("max_tokens".into(), Value::from(V3_MAX_TOKENS));
}
v["schema_version"] = Value::from(3);
Ok(v)
}
fn settings_to_v4(mut v: Value) -> Result<Value> {
v["schema_version"] = Value::from(4);
Ok(v)
}
fn settings_to_v5(mut v: Value) -> Result<Value> {
v["schema_version"] = Value::from(5);
Ok(v)
}
const V5_PROMPT_CAP: u64 = 1200;
const V6_PROMPT_CAP: u64 = 4000;
fn settings_to_v6(mut v: Value) -> Result<Value> {
if let Some(self_model) = v.get_mut("self_model").and_then(Value::as_object_mut)
&& self_model.get("prompt_cap").and_then(Value::as_u64) == Some(V5_PROMPT_CAP)
{
self_model.insert("prompt_cap".into(), Value::from(V6_PROMPT_CAP));
}
v["schema_version"] = Value::from(6);
Ok(v)
}
fn settings_to_v7(mut v: Value) -> Result<Value> {
v["schema_version"] = Value::from(7);
Ok(v)
}
const SETTINGS_STEPS: &[Step] = &[
Step {
to: 2,
summary: "the sub-agent's one-request timeout becomes a whole-run limit",
apply: settings_to_v2,
},
Step {
to: 3,
summary: "the default reply budget rises to cover a model's reasoning",
apply: settings_to_v3,
},
Step {
to: 4,
summary: "a slot's mode may name the OpenRouter gateway",
apply: settings_to_v4,
},
Step {
to: 5,
summary: "a reasoning effort may read max",
apply: settings_to_v5,
},
Step {
to: 6,
summary: "a self-model cap left at the 0.9.4 default rises to 4000",
apply: settings_to_v6,
},
Step {
to: 7,
summary: "a slot's mode may name DeepSeek's API",
apply: settings_to_v7,
},
];
pub fn settings_artifact() -> JsonArtifact {
JsonArtifact {
name: "settings.json",
current: SETTINGS_SCHEMA,
detect: detect_settings,
steps: SETTINGS_STEPS,
}
}
pub fn profiles_artifact() -> JsonArtifact {
JsonArtifact {
name: "profiles.json",
current: PROFILES_SCHEMA,
detect: detect_profiles,
steps: &[],
}
}
const CHAT_STEPS: &[Step] = &[
Step {
to: 2,
summary: "a transcript is synthesized for every old call_subagent record",
apply: super::chat_steps::chat_to_v2,
},
Step {
to: 3,
summary: "chat files may carry dialogue runs (RunKind::Dialogue)",
apply: super::chat_steps::chat_to_v3,
},
Step {
to: 4,
summary: "a run title the old 100-character cap cut gets its tail back",
apply: super::chat_steps::chat_to_v4,
},
Step {
to: 5,
summary: "a reasoning effort may read max",
apply: super::chat_steps::chat_to_v5,
},
Step {
to: 6,
summary: "a reply's metadata may name the deepseek mode",
apply: super::chat_steps::chat_to_v6,
},
];
pub fn chat_artifact() -> JsonArtifact {
JsonArtifact {
name: "chats/<id>.json",
current: CHAT_SCHEMA,
detect: detect_chat,
steps: CHAT_STEPS,
}
}
#[cfg(test)]
pub(super) fn dropped_paths(fixture: &Value, read_back: &Value) -> Vec<String> {
fn walk(at: &str, fixture: &Value, read_back: &Value, out: &mut Vec<String>) {
match (fixture, read_back) {
(Value::Object(want), Value::Object(got)) => {
for (key, value) in want {
let path = format!("{at}/{key}");
match got.get(key) {
Some(kept) => walk(&path, value, kept, out),
None => out.push(path),
}
}
}
(Value::Array(want), Value::Array(got)) if want.len() == got.len() => {
for (i, (value, kept)) in want.iter().zip(got).enumerate() {
walk(&format!("{at}/{i}"), value, kept, out);
}
}
(want, got) if want == got => {}
_ => out.push(at.to_string()),
}
}
let mut out = Vec::new();
walk("", fixture, read_back, &mut out);
out
}
#[cfg(test)]
mod stored_enums_tests;
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::sampling::ReasoningEffort;
use serde_json::json;
#[test]
fn schema_constants_match_config_default() {
assert_eq!(SETTINGS_SCHEMA, crate::shared::config::SCHEMA_VERSION);
}
#[test]
fn detect_uses_field_or_defaults_to_one() {
assert_eq!(detect_settings(&json!({"schema_version": 3})), 3);
assert_eq!(detect_settings(&json!({})), 1);
assert_eq!(detect_profiles(&json!([])), 1); assert_eq!(detect_profiles(&json!({"schema_version": 2})), 2);
assert_eq!(detect_chat(&json!({"v": 5})), 5);
assert_eq!(detect_chat(&json!({"title": "x"})), 1);
}
fn to_v2(mut v: Value) -> Result<Value> {
v["schema_version"] = json!(2);
Ok(v)
}
const SYNTH_STEPS: &[Step] = &[Step {
to: 2,
summary: "test bump",
apply: to_v2,
}];
fn synth() -> JsonArtifact {
JsonArtifact {
name: "synthetic",
current: 2,
detect: detect_settings,
steps: SYNTH_STEPS,
}
}
#[test]
fn assess_classifies_up_to_date_migrate_downgrade() {
let a = synth();
assert_eq!(
a.assess(&json!({"schema_version": 2})),
Assessment::UpToDate
);
assert_eq!(
a.assess(&json!({"schema_version": 1})),
Assessment::Migrate { from: 1 }
);
assert_eq!(a.assess(&json!({})), Assessment::Migrate { from: 1 });
assert_eq!(
a.assess(&json!({"schema_version": 3})),
Assessment::Downgrade { from: 3 }
);
}
#[test]
fn apply_steps_runs_only_needed_steps() {
let a = synth();
let out = a
.apply_steps(json!({"schema_version": 1, "x": 7}), 1)
.unwrap();
assert_eq!(out["schema_version"], json!(2));
assert_eq!(out["x"], json!(7), "other fields are preserved");
let noop = a.apply_steps(json!({"schema_version": 2}), 2).unwrap();
assert_eq!(noop["schema_version"], json!(2));
}
#[test]
fn apply_step_error_propagates() {
fn boom(_v: Value) -> Result<Value> {
anyhow::bail!("broken")
}
const STEPS: &[Step] = &[Step {
to: 2,
summary: "boom",
apply: boom,
}];
let a = JsonArtifact {
name: "x",
current: 2,
detect: detect_settings,
steps: STEPS,
};
assert!(a.apply_steps(json!({}), 1).is_err());
}
#[test]
fn real_registry_versions_and_steps() {
let settings = settings_artifact();
assert_eq!(settings.current, 7);
let reached: Vec<u32> = settings.steps.iter().map(|s| s.to).collect();
assert_eq!(reached, [2, 3, 4, 5, 6, 7]);
let chats = chat_artifact();
assert_eq!(chats.current, 6);
let reached: Vec<u32> = chats.steps.iter().map(|s| s.to).collect();
assert_eq!(reached, [2, 3, 4, 5, 6]);
let profiles = profiles_artifact();
assert_eq!(profiles.current, 1);
assert!(profiles.steps.is_empty());
}
fn v1_settings(timeout: u64, max_tokens: u64) -> Value {
json!({
"schema_version": 1,
"max_tool_rounds": 8,
"tools": {
"web_enabled": true,
"subagent_max_tokens": max_tokens,
"subagent_timeout_secs": timeout,
"confirm_dangerous": false
}
})
}
#[test]
fn settings_step_drops_old_defaults_so_the_new_ones_apply() {
let out = settings_artifact()
.apply_steps(v1_settings(60, 1024), 1)
.unwrap();
assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
let tools = out["tools"].as_object().unwrap();
assert!(!tools.contains_key("subagent_timeout_secs"));
assert!(!tools.contains_key("subagent_run_timeout_secs"));
assert!(!tools.contains_key("subagent_max_tokens"));
assert_eq!(tools["web_enabled"], json!(true));
assert_eq!(tools["confirm_dangerous"], json!(false));
assert_eq!(out["max_tool_rounds"], json!(8));
let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
assert_eq!(
cfg.tools.subagent_run_timeout_secs,
crate::shared::config::DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS
);
assert_eq!(
cfg.tools.subagent_max_tokens,
crate::shared::config::DEFAULT_SUBAGENT_MAX_TOKENS
);
}
#[test]
fn settings_step_carries_a_changed_value_under_the_new_name() {
let out = settings_artifact()
.apply_steps(v1_settings(120, 2048), 1)
.unwrap();
let tools = out["tools"].as_object().unwrap();
assert!(!tools.contains_key("subagent_timeout_secs"));
assert_eq!(tools["subagent_run_timeout_secs"], json!(120));
assert_eq!(tools["subagent_max_tokens"], json!(2048));
let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
assert_eq!(cfg.tools.subagent_run_timeout_secs, 120);
assert_eq!(cfg.tools.subagent_max_tokens, 2048);
}
#[test]
fn settings_step_tolerates_a_file_without_the_tools_section() {
let out = settings_artifact()
.apply_steps(json!({"schema_version": 1}), 1)
.unwrap();
assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
}
#[test]
fn settings_step_lifts_the_untouched_reply_budget_and_keeps_a_chosen_one() {
let at_the_old_default = settings_artifact()
.apply_steps(
json!({"schema_version": 2, "default_sampling": {"max_tokens": 2048, "thinking": true}}),
2,
)
.unwrap();
assert_eq!(at_the_old_default["schema_version"], json!(SETTINGS_SCHEMA));
let sampling = at_the_old_default["default_sampling"].as_object().unwrap();
assert_eq!(sampling["max_tokens"], json!(16384), "{sampling:?}");
assert_eq!(sampling["thinking"], json!(true));
let cfg: crate::shared::config::AppConfig =
serde_json::from_value(at_the_old_default).unwrap();
assert_eq!(
cfg.default_sampling.max_tokens,
crate::shared::config::AppConfig::default()
.default_sampling
.max_tokens
);
for chosen in [512u64, 4096, 65536] {
let out = settings_artifact()
.apply_steps(
json!({"schema_version": 2, "default_sampling": {"max_tokens": chosen}}),
2,
)
.unwrap();
assert_eq!(
out["default_sampling"]["max_tokens"],
json!(chosen),
"a typed {chosen} must survive"
);
}
let bare = settings_artifact()
.apply_steps(json!({"schema_version": 2}), 2)
.unwrap();
assert_eq!(bare["schema_version"], json!(SETTINGS_SCHEMA));
assert!(bare.get("default_sampling").is_none());
}
const V3_SETTINGS: &str = include_str!("fixtures/settings_v3.json");
#[test]
fn settings_step_to_v4_stamps_the_version_and_touches_nothing_else() {
let before: Value = serde_json::from_str(V3_SETTINGS).unwrap();
let after = settings_to_v4(before.clone()).unwrap();
assert_eq!(after["schema_version"], json!(4));
let mut restamped = after.clone();
restamped["schema_version"] = json!(3);
assert_eq!(restamped, before, "every other value is where it was");
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(cfg.engine.mode, crate::shared::config::ServerMode::External);
assert_eq!(
cfg.engine.external.url.as_deref(),
Some("https://openrouter.ai/api/v1")
);
assert_eq!(cfg.engine.openrouter, Default::default());
assert!(cfg.openrouter.attribution, "on unless switched off");
}
#[test]
fn a_file_naming_the_gateway_is_a_newer_version_to_the_previous_schema() {
let mut cfg = crate::shared::config::AppConfig::default();
cfg.engine.mode = crate::shared::config::ServerMode::OpenRouter;
cfg.impersonation_engine.mode = crate::shared::config::ImpersonationMode::OpenRouter;
let written = serde_json::to_value(&cfg).unwrap();
assert_eq!(written["engine"]["mode"], json!("openrouter"));
assert_eq!(written["impersonation_engine"]["mode"], json!("openrouter"));
assert_eq!(written["schema_version"], json!(SETTINGS_SCHEMA));
let previous = JsonArtifact {
name: "settings.json",
current: 3,
detect: detect_settings,
steps: &SETTINGS_STEPS[..2],
};
assert_eq!(
previous.assess(&written),
Assessment::Downgrade {
from: SETTINGS_SCHEMA
},
"refused as newer, not parsed into an unknown variant"
);
assert_eq!(settings_artifact().assess(&written), Assessment::UpToDate);
let back: crate::shared::config::AppConfig = serde_json::from_value(written).unwrap();
assert_eq!(back, cfg);
}
const V4_SETTINGS: &str = include_str!("fixtures/settings_v4.json");
#[test]
fn the_v4_settings_fixture_holds_nothing_the_config_drops() {
let fixture: Value = serde_json::from_str(V4_SETTINGS).unwrap();
let cfg: crate::shared::config::AppConfig =
serde_json::from_value(fixture.clone()).unwrap();
let read_back = serde_json::to_value(&cfg).unwrap();
assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
}
#[test]
fn settings_step_to_v5_stamps_the_version_and_touches_nothing_else() {
let before: Value = serde_json::from_str(V4_SETTINGS).unwrap();
let after = settings_to_v5(before.clone()).unwrap();
assert_eq!(after["schema_version"], json!(5));
let mut restamped = after.clone();
restamped["schema_version"] = json!(4);
assert_eq!(restamped, before, "every other value is where it was");
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(
cfg.default_sampling.reasoning_effort,
Some(ReasoningEffort::XHigh)
);
assert_eq!(
cfg.impersonation_sampling.reasoning_effort,
Some(ReasoningEffort::Minimal)
);
}
fn reader_at(art: JsonArtifact, current: u32) -> JsonArtifact {
let known = art.steps.iter().filter(|s| s.to <= current).count();
JsonArtifact {
current,
steps: &art.steps[..known],
..art
}
}
#[test]
fn a_file_holding_an_effort_is_a_newer_version_to_a_reader_older_than_it() {
for (effort, since) in super::stored_enums_tests::efforts() {
assert!(since <= SETTINGS_SCHEMA.min(CHAT_SCHEMA), "{effort:?}");
let mut cfg = crate::shared::config::AppConfig::default();
cfg.default_sampling.reasoning_effort = Some(effort);
let settings = serde_json::to_value(&cfg).unwrap();
let profile = crate::entities::profile::Profile::new("A", "s");
let mut chat = crate::entities::chat::Chat::from_profile(&profile, "t");
chat.sampling_override = Some(crate::entities::sampling::SamplingConfig {
reasoning_effort: Some(effort),
..Default::default()
});
let chat = serde_json::to_value(&chat).unwrap();
assert_eq!(settings_artifact().assess(&settings), Assessment::UpToDate);
assert_eq!(chat_artifact().assess(&chat), Assessment::UpToDate);
for older in 1..since {
assert_eq!(
reader_at(settings_artifact(), older).assess(&settings),
Assessment::Downgrade {
from: SETTINGS_SCHEMA
},
"{effort:?}: settings.json read at schema {older}"
);
assert_eq!(
reader_at(chat_artifact(), older).assess(&chat),
Assessment::Downgrade { from: CHAT_SCHEMA },
"{effort:?}: a chat file read at schema {older}"
);
}
}
}
const V5_SETTINGS: &str = include_str!("fixtures/settings_v5.json");
#[test]
fn the_v5_settings_fixture_holds_nothing_the_config_drops() {
let fixture: Value = serde_json::from_str(V5_SETTINGS).unwrap();
let cfg: crate::shared::config::AppConfig =
serde_json::from_value(fixture.clone()).unwrap();
let read_back = serde_json::to_value(&cfg).unwrap();
assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
}
#[test]
fn settings_step_to_v6_raises_a_cap_left_at_the_old_default() {
let before: Value = serde_json::from_str(V5_SETTINGS).unwrap();
assert_eq!(before["self_model"]["prompt_cap"], json!(1200));
let after = settings_to_v6(before.clone()).unwrap();
assert_eq!(after["schema_version"], json!(6));
assert_eq!(after["self_model"]["prompt_cap"], json!(4000));
let mut restored = after.clone();
restored["schema_version"] = json!(5);
restored["self_model"]["prompt_cap"] = json!(1200);
assert_eq!(restored, before, "every other value is where it was");
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(cfg.self_model.prompt_cap, 4000);
assert_eq!(cfg.self_model.narrative_in_prompt, 3);
assert_eq!(cfg.self_model.max_narrative, 50);
assert_eq!(
cfg.default_sampling.reasoning_effort,
Some(ReasoningEffort::Max)
);
}
#[test]
fn settings_step_to_v6_keeps_any_other_cap() {
for chosen in [100usize, 800, 1199, 1201, 1500, 4000, 12000] {
let out = settings_artifact()
.apply_steps(
json!({"schema_version": 5, "self_model": {"prompt_cap": chosen}}),
5,
)
.unwrap();
assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
assert_eq!(
out["self_model"]["prompt_cap"],
json!(chosen),
"a stored {chosen} must survive"
);
let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
assert_eq!(cfg.self_model.prompt_cap, chosen);
}
}
#[test]
fn settings_step_to_v6_leaves_a_file_without_the_cap_alone() {
for before in [
json!({"schema_version": 5}),
json!({"schema_version": 5, "self_model": {"narrative_in_prompt": 5}}),
] {
let after = settings_artifact().apply_steps(before.clone(), 5).unwrap();
let mut restamped = after.clone();
restamped["schema_version"] = json!(5);
assert_eq!(restamped, before);
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(
cfg.self_model.prompt_cap,
crate::shared::config::DEFAULT_SELF_MODEL_PROMPT_CAP
);
}
}
#[test]
fn a_settings_file_from_0_9_4_reaches_the_raised_cap_through_the_whole_chain() {
let out = settings_artifact()
.apply_steps(
json!({"schema_version": 1, "self_model": {"prompt_cap": 1200}}),
1,
)
.unwrap();
assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
assert_eq!(out["self_model"]["prompt_cap"], json!(4000));
}
#[test]
fn a_file_past_the_cap_step_is_a_newer_version_to_the_previous_schema() {
let migrated = settings_artifact()
.apply_steps(serde_json::from_str(V5_SETTINGS).unwrap(), 5)
.unwrap();
assert_eq!(
reader_at(settings_artifact(), 5).assess(&migrated),
Assessment::Downgrade {
from: SETTINGS_SCHEMA
}
);
assert_eq!(settings_artifact().assess(&migrated), Assessment::UpToDate);
}
const V6_SETTINGS: &str = include_str!("fixtures/settings_v6.json");
#[test]
fn the_v6_settings_fixture_holds_nothing_the_config_drops() {
let fixture: Value = serde_json::from_str(V6_SETTINGS).unwrap();
let cfg: crate::shared::config::AppConfig =
serde_json::from_value(fixture.clone()).unwrap();
let read_back = serde_json::to_value(&cfg).unwrap();
assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
}
#[test]
fn settings_step_to_v7_stamps_the_version_and_touches_nothing_else() {
let before: Value = serde_json::from_str(V6_SETTINGS).unwrap();
let after = settings_to_v7(before.clone()).unwrap();
assert_eq!(after["schema_version"], json!(7));
let mut restamped = after.clone();
restamped["schema_version"] = json!(6);
assert_eq!(restamped, before, "every other value is where it was");
let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
assert_eq!(cfg.engine.mode, crate::shared::config::ServerMode::External);
assert_eq!(
cfg.engine.external.url.as_deref(),
Some("https://api.deepseek.com/v1")
);
assert_eq!(
cfg.engine.deepseek,
crate::shared::config::CloudSettings::default(),
"a section 0.20.0 never wrote reads as the default"
);
}
#[test]
fn a_file_holding_the_deepseek_mode_is_a_newer_version_to_the_previous_schema() {
use crate::shared::config::{AppConfig, ImpersonationMode, ServerMode};
let mut cfg = AppConfig::default();
cfg.engine.mode = ServerMode::DeepSeek;
cfg.impersonation_engine.mode = ImpersonationMode::DeepSeek;
let settings = serde_json::to_value(&cfg).unwrap();
assert_eq!(settings["engine"]["mode"], json!("deepseek"));
assert_eq!(settings_artifact().assess(&settings), Assessment::UpToDate);
assert_eq!(
reader_at(settings_artifact(), 6).assess(&settings),
Assessment::Downgrade {
from: SETTINGS_SCHEMA
},
"settings.json read by 0.20.0"
);
let fixture: Value =
serde_json::from_str(include_str!("fixtures/chat_v5_external_deepseek.json")).unwrap();
let mut chat: crate::entities::chat::Chat = serde_json::from_value(fixture).unwrap();
chat.v = CHAT_SCHEMA;
chat.messages[1]
.metadata
.as_mut()
.expect("the reply's metadata")
.mode = ServerMode::DeepSeek;
let chat = serde_json::to_value(&chat).unwrap();
assert_eq!(chat["messages"][1]["metadata"]["mode"], json!("deepseek"));
assert_eq!(chat_artifact().assess(&chat), Assessment::UpToDate);
assert_eq!(
reader_at(chat_artifact(), 5).assess(&chat),
Assessment::Downgrade { from: CHAT_SCHEMA },
"a chat file read by 0.20.0"
);
}
}