anvilsign in

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
6363 /// Session title, when Claude has one.
6464 #[serde(default, skip_serializing_if = "Option::is_none")]
6565 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.
7171 #[serde(default, skip_serializing_if = "Option::is_none")]
7272 pub model: Option<String>,
7373 /// One line of the last thing the user asked, from `UserPromptSubmit`. It
⋯ 121 unchanged lines
195195 _ => None,
196196 },
197197 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())),
199206 // Only `UserPromptSubmit` carries one; every other event keeps the one
200207 // already on disk, so the title stays put for the whole turn instead of
201208 // blinking out on the first tool call.
⋯ 11 unchanged lines
213220 Ok(Some(record))
214221 }
215222
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+
216278 fn write(path: &std::path::Path, record: &Record) -> io::Result<()> {
217279 let dir = path.parent().expect("record path has a parent");
218280 fs::create_dir_all(dir)?;
⋯ 200 unchanged lines
419481 });
420482 }
421483
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+
422537 #[test]
423538 fn session_end_removes_the_record() {
424539 with_temp_dir(|| {
⋯ 84 unchanged lines