collin/browser-terminal-extension · a17d7d00
Read a session's model from its transcript, not just SessionStart
Collin Richards · 2026-08-18 07:08 UTC · a17d7d007453bc7dc117bbbe40315eb4d09671bc · parent 57d73a66 · browse files
modifieddaemon/src/agents.rs+121 −6
| ⋯ 62 unchanged lines | |||
| 63 | 63 | /// Session title, when Claude has one. | |
| 64 | 64 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 65 | 65 | pub name: Option<String>, | |
| 66 | - | /// The model id, e.g. `claude-opus-4-1-20250805`. Only `SessionStart` | |
| 67 | - | /// carries it, and even there it is not guaranteed, so this is carried | |
| 68 | - | /// forward across every later event the way `mode` and `name` are; it goes | |
| 69 | - | /// stale if the session switches models mid-conversation, but a stale | |
| 70 | - | /// answer beats none. | |
| 66 | + | /// The model id, e.g. `claude-opus-4-1-20250805`. Read from the transcript | |
| 67 | + | /// where there is one — see [`model_from_transcript`] — because the hook | |
| 68 | + | /// event only carries a model on `SessionStart`, and even there not always. | |
| 69 | + | /// Carried forward across events that turn up neither, the way `mode` and | |
| 70 | + | /// `name` are. | |
| 71 | 71 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 72 | 72 | pub model: Option<String>, | |
| 73 | 73 | /// One line of the last thing the user asked, from `UserPromptSubmit`. It | |
| ⋯ 121 unchanged lines | |||
| 195 | 195 | _ => None, | |
| 196 | 196 | }, | |
| 197 | 197 | name: str_field("session_name").or_else(|| carry(|r| r.name.clone())), | |
| 198 | - | model: str_field("model").or_else(|| carry(|r| r.model.clone())), | |
| 198 | + | // The transcript first: it is the only source that keeps up with | |
| 199 | + | // `/model`. The event's own field is the answer for a session whose | |
| 200 | + | // first turn hasn't been written yet, which is exactly `SessionStart`. | |
| 201 | + | model: str_field("transcript_path") | |
| 202 | + | .as_deref() | |
| 203 | + | .and_then(model_from_transcript) | |
| 204 | + | .or_else(|| str_field("model")) | |
| 205 | + | .or_else(|| carry(|r| r.model.clone())), | |
| 199 | 206 | // Only `UserPromptSubmit` carries one; every other event keeps the one | |
| 200 | 207 | // already on disk, so the title stays put for the whole turn instead of | |
| 201 | 208 | // blinking out on the first tool call. | |
| ⋯ 11 unchanged lines | |||
| 213 | 220 | Ok(Some(record)) | |
| 214 | 221 | } | |
| 215 | 222 | ||
| 223 | + | /// How much of the tail of a transcript is read looking for a model. A turn is | |
| 224 | + | /// a few kilobytes at most, so this covers the last several of them; the point | |
| 225 | + | /// is only that a megabyte-long transcript is not read on every hook event. | |
| 226 | + | const TRANSCRIPT_TAIL: u64 = 64 * 1024; | |
| 227 | + | ||
| 228 | + | /// The model a session is actually using, from the end of its transcript. | |
| 229 | + | /// | |
| 230 | + | /// Only `SessionStart` carries a `model` field, so a session that switches with | |
| 231 | + | /// `/model` mid-conversation would otherwise keep reporting the one it started | |
| 232 | + | /// with. Every assistant turn in the transcript names the model that wrote it, | |
| 233 | + | /// so the last one is the live answer. | |
| 234 | + | /// | |
| 235 | + | /// Sidechain turns are skipped: those are subagents, which run whatever model | |
| 236 | + | /// they were spawned with, and the pane belongs to the session. | |
| 237 | + | /// | |
| 238 | + | /// `None` for anything unreadable or not yet written — a transcript with no | |
| 239 | + | /// assistant turn in its tail is the normal state of a session that has only | |
| 240 | + | /// just started. | |
| 241 | + | fn model_from_transcript(path: &str) -> Option<String> { | |
| 242 | + | use std::io::{Read, Seek, SeekFrom}; | |
| 243 | + | ||
| 244 | + | let mut file = fs::File::open(path).ok()?; | |
| 245 | + | let len = file.metadata().ok()?.len(); | |
| 246 | + | let start = len.saturating_sub(TRANSCRIPT_TAIL); | |
| 247 | + | file.seek(SeekFrom::Start(start)).ok()?; | |
| 248 | + | let mut buf = Vec::new(); | |
| 249 | + | file.read_to_end(&mut buf).ok()?; | |
| 250 | + | // The seek can land inside a character as easily as inside a line; both are | |
| 251 | + | // the same problem, and dropping the first partial line solves both. | |
| 252 | + | let text = String::from_utf8_lossy(&buf); | |
| 253 | + | let mut lines: Vec<&str> = text.lines().collect(); | |
| 254 | + | if start > 0 && !lines.is_empty() { | |
| 255 | + | lines.remove(0); | |
| 256 | + | } | |
| 257 | + | for line in lines.iter().rev() { | |
| 258 | + | let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { | |
| 259 | + | continue; | |
| 260 | + | }; | |
| 261 | + | if v.get("type").and_then(|t| t.as_str()) != Some("assistant") { | |
| 262 | + | continue; | |
| 263 | + | } | |
| 264 | + | if v.get("isSidechain").and_then(|s| s.as_bool()) == Some(true) { | |
| 265 | + | continue; | |
| 266 | + | } | |
| 267 | + | if let Some(model) = v | |
| 268 | + | .get("message") | |
| 269 | + | .and_then(|m| m.get("model")) | |
| 270 | + | .and_then(|m| m.as_str()) | |
| 271 | + | { | |
| 272 | + | return Some(model.to_string()); | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | None | |
| 276 | + | } | |
| 277 | + | ||
| 216 | 278 | fn write(path: &std::path::Path, record: &Record) -> io::Result<()> { | |
| 217 | 279 | let dir = path.parent().expect("record path has a parent"); | |
| 218 | 280 | fs::create_dir_all(dir)?; | |
| ⋯ 200 unchanged lines | |||
| 419 | 481 | }); | |
| 420 | 482 | } | |
| 421 | 483 | ||
| 484 | + | /// A session that switched models with `/model` reports the new one, which | |
| 485 | + | /// only the transcript knows about. | |
| 486 | + | #[test] | |
| 487 | + | fn transcript_beats_the_model_the_session_started_with() { | |
| 488 | + | with_temp_dir(|| { | |
| 489 | + | let transcript = dir().join("t.jsonl"); | |
| 490 | + | fs::create_dir_all(dir()).unwrap(); | |
| 491 | + | fs::write( | |
| 492 | + | &transcript, | |
| 493 | + | concat!( | |
| 494 | + | r#"{"type":"user","message":{"role":"user"}}"#, | |
| 495 | + | "\n", | |
| 496 | + | r#"{"type":"assistant","message":{"model":"claude-sonnet-5"}}"#, | |
| 497 | + | "\n", | |
| 498 | + | r#"{"type":"assistant","message":{"model":"claude-opus-5"}}"#, | |
| 499 | + | "\n", | |
| 500 | + | // A subagent's turn, which is not what the pane is running. | |
| 501 | + | r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-haiku-4-5"}}"#, | |
| 502 | + | "\n", | |
| 503 | + | ), | |
| 504 | + | ) | |
| 505 | + | .unwrap(); | |
| 506 | + | event(serde_json::json!({ | |
| 507 | + | "session_id": "a", "hook_event_name": "SessionStart", | |
| 508 | + | "cwd": "/tmp/p", "model": "claude-sonnet-5", | |
| 509 | + | })); | |
| 510 | + | let r = event(serde_json::json!({ | |
| 511 | + | "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash", | |
| 512 | + | "transcript_path": transcript.to_str().unwrap(), | |
| 513 | + | })) | |
| 514 | + | .unwrap(); | |
| 515 | + | assert_eq!(r.model.as_deref(), Some("claude-opus-5")); | |
| 516 | + | }); | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | /// A transcript with no assistant turn yet — every new session — leaves the | |
| 520 | + | /// event's own field to answer. | |
| 521 | + | #[test] | |
| 522 | + | fn empty_transcript_falls_back_to_the_event() { | |
| 523 | + | with_temp_dir(|| { | |
| 524 | + | let transcript = dir().join("t.jsonl"); | |
| 525 | + | fs::create_dir_all(dir()).unwrap(); | |
| 526 | + | fs::write(&transcript, "").unwrap(); | |
| 527 | + | let r = event(serde_json::json!({ | |
| 528 | + | "session_id": "a", "hook_event_name": "SessionStart", | |
| 529 | + | "cwd": "/tmp/p", "model": "claude-opus-5[1m]", | |
| 530 | + | "transcript_path": transcript.to_str().unwrap(), | |
| 531 | + | })) | |
| 532 | + | .unwrap(); | |
| 533 | + | assert_eq!(r.model.as_deref(), Some("claude-opus-5[1m]")); | |
| 534 | + | }); | |
| 535 | + | } | |
| 536 | + | ||
| 422 | 537 | #[test] | |
| 423 | 538 | fn session_end_removes_the_record() { | |
| 424 | 539 | with_temp_dir(|| { | |
| ⋯ 84 unchanged lines | |||