1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//! P4c (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4" core NEW-significant item,
//! §1.10/§3.1 `core.model_switch.allow_switch`, D9 row): a persisted,
//! TYPED record of a mid-session model switch — pi's `model_change`
//! precedent (design §1.10: "persisted change records … pi's `model_change`
//! is the cleanest precedent"). Deliberately flat/typed (not a formatted
//! string), mirroring [`crate::usage_log::UsageRecord`]'s exact rationale:
//! a translatable, lossless session-data channel (§1.13), not a lossy
//! notice — so it survives a save/load round trip byte-for-byte in the
//! fields that matter, and a future reader (a translator emitting this same
//! session under another harness's format, a `doctor`/`inspect stats`
//! command) can consume it without re-parsing prose.
use serde::{Deserialize, Serialize};
/// One mid-session model switch, as `Agent::switch_model` records it when
/// `Config::model_switch_allow_switch` is on (see that field's doc comment
/// for the exact gate — with the knob off, `switch_model` never creates one
/// of these at all, matching `Agent::set_model`'s pre-existing,
/// record-free mechanics byte-for-byte).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelChangeRecord {
/// 0-based index of the model round-trip THIS switch takes effect
/// before (i.e. `Agent::turn_index` at the moment of the switch) — the
/// same per-turn addressing [`crate::usage_log::UsageRecord::turn`]
/// uses, so the two logs can be correlated.
pub turn: usize,
/// The model id in effect immediately before this switch.
pub from_model: String,
/// The model id in effect immediately after this switch.
pub to_model: String,
/// Whether `reduce::rehydrate::filter_reasoning_artifacts` ran over the
/// live history as part of this switch (dep 8) — always `true` when
/// this record exists at all (a record is only ever created under
/// `allow_switch = true`, which is the same gate that runs the filter),
/// but recorded explicitly rather than implied, so a reader of the
/// persisted log alone (without also knowing the config that produced
/// it) can see the safety guarantee held for this specific switch.
#[serde(default)]
pub reasoning_filtered: bool,
/// Count of `ChatMessage`s the reasoning-artifact filter actually
/// touched (stripped a metadata key from, or removed a `content_parts`
/// reasoning block from) during this switch. `0` is a legitimate,
/// common value (nothing to filter yet), not an error signal.
#[serde(default)]
pub reasoning_artifacts_filtered: usize,
/// Unix-ms wall-clock time the switch happened.
#[serde(default)]
pub timestamp_ms: i64,
}
impl ModelChangeRecord {
/// Build a record — the constructor `Agent::switch_model` calls.
pub fn new(
turn: usize,
from_model: impl Into<String>,
to_model: impl Into<String>,
reasoning_filtered: bool,
reasoning_artifacts_filtered: usize,
timestamp_ms: i64,
) -> ModelChangeRecord {
ModelChangeRecord {
turn,
from_model: from_model.into(),
to_model: to_model.into(),
reasoning_filtered,
reasoning_artifacts_filtered,
timestamp_ms,
}
}
}
/// Serialize `records` as JSONL (one [`ModelChangeRecord`] per line) — the
/// same shape [`crate::usage_log::to_jsonl`] and every other append-log in
/// this crate uses. Never fails on an empty slice (produces an empty
/// string).
pub fn to_jsonl(records: &[ModelChangeRecord]) -> crate::Result<String> {
let mut out = String::new();
for r in records {
out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
out.push('\n');
}
Ok(out)
}
/// Parse a JSONL model-change log back into records — the exact inverse of
/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
/// (like [`crate::usage_log::from_jsonl`] — this is accounting/provenance
/// data, corruption should be visible, not silently dropped).
pub fn from_jsonl(text: &str) -> crate::Result<Vec<ModelChangeRecord>> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_carries_every_field() {
let r = ModelChangeRecord::new(
2,
"anthropic/claude-opus-4-8",
"anthropic/claude-haiku-4-5",
true,
3,
1_700_000_000_000,
);
assert_eq!(r.turn, 2);
assert_eq!(r.from_model, "anthropic/claude-opus-4-8");
assert_eq!(r.to_model, "anthropic/claude-haiku-4-5");
assert!(r.reasoning_filtered);
assert_eq!(r.reasoning_artifacts_filtered, 3);
assert_eq!(r.timestamp_ms, 1_700_000_000_000);
}
/// §1.13 "translatable, lossless — not a lossy channel": every field
/// round-trips through JSONL byte-for-byte, not just "close enough".
#[test]
fn jsonl_round_trip_is_lossless() {
let records = vec![
ModelChangeRecord::new(
0,
"vendor/model-a",
"vendor/model-b",
true,
5,
1_700_000_000_000,
),
ModelChangeRecord::new(
3,
"vendor/model-b",
"vendor/model-c",
true,
0,
1_700_000_010_000,
),
];
let jsonl = to_jsonl(&records).unwrap();
let round_tripped = from_jsonl(&jsonl).unwrap();
assert_eq!(records, round_tripped);
}
#[test]
fn empty_records_round_trip_to_empty() {
assert_eq!(to_jsonl(&[]).unwrap(), "");
assert_eq!(from_jsonl("").unwrap(), Vec::<ModelChangeRecord>::new());
}
#[test]
fn from_jsonl_skips_blank_lines() {
assert_eq!(from_jsonl("\n\n").unwrap(), Vec::<ModelChangeRecord>::new());
}
#[test]
fn from_jsonl_rejects_malformed_lines_rather_than_silently_dropping_them() {
assert!(from_jsonl("{not json}").is_err());
}
/// Old records written before `reasoning_filtered`/
/// `reasoning_artifacts_filtered`/`timestamp_ms` existed (hypothetically
/// — this crate is pre-1.0, but the `#[serde(default)]` discipline
/// matches every other log in this crate, e.g.
/// `crate::store::SessionInfo::reduced`) still parse.
#[test]
fn tolerates_a_record_missing_the_optional_fields() {
let minimal = r#"{"turn":0,"from_model":"a","to_model":"b"}"#;
let parsed = from_jsonl(minimal).unwrap();
assert_eq!(parsed.len(), 1);
assert!(!parsed[0].reasoning_filtered);
assert_eq!(parsed[0].reasoning_artifacts_filtered, 0);
assert_eq!(parsed[0].timestamp_ms, 0);
}
}