//! Reading the current turn's model out of a session transcript. //! //! There is no `model` field on a hook payload and no `CLAUDE_MODEL` in the //! environment, so the only place the harness says which model is acting is //! the transcript it is already writing. Every payload carries //! [`transcript_path`](crate::CommonInput::transcript_path), and that file is //! JSONL whose assistant rows carry `message.model`. The last such row is the //! model in force for the turn that is about to call a tool. `SubagentStop` //! and a subagent's own `PreToolUse` carry the subagent's transcript, so the //! same read answers for a subagent. //! //! # This format is not a contract //! //! The transcript is a harness-internal file. Nothing documents its rows, //! nothing promises they will keep their shape, and a release could rename //! `message.model`, nest it, or stop writing it. So every function here //! returns [`Option`] and every failure — a path that does not exist, a file //! that is empty, a final line cut off mid-write, a row in a shape this code //! has never seen — is the same answer: `None`, and the scrobble goes out //! without a model on it. //! //! # Why it reads from the end //! //! A long session's transcript is tens of megabytes, and this runs inside the //! harness's critical path on the way to a tool call. Only the tail is read, //! and only [`MAX_ROWS_SCANNED`] rows of it are parsed, so the cost is //! bounded by two constants rather than by how long the developer has been //! working. The row nearest the end wins, which is also the row that answers //! the question: a session whose model was switched mid-conversation reports //! the model it is on now. use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; /// How many bytes at the end of a transcript are read. /// /// Generous enough to hold many rows even when recent ones carry large tool /// results, small enough that reading it is one page-cache hit rather than a /// pause the developer notices. pub const TAIL_BYTES: u64 = 128 * 1024; /// How many rows, counting back from the end, are parsed. /// /// A bound on work rather than on correctness: the assistant row this is /// looking for is normally the last row or within a few of it, and a /// transcript whose most recent assistant row is further back than this is one /// where the answer has gone stale anyway. pub const MAX_ROWS_SCANNED: usize = 64; /// The lexicon's `maxLength` for a scrobble's `model`. /// /// Enforced here as well as at the server, because a value this reader cannot /// carry onto a record is a value it should not stamp: a rejected record loses /// the agent's sentence, and the model identifier was the least important /// thing on it. pub const MODEL_MAX_LENGTH: usize = 64; /// The model identifier in force for the turn, read from `transcript_path`. /// /// Returns `None` for every way this can fail; see the module documentation. /// /// ``` /// use didbot_hook::transcript::model_for_turn; /// assert!(model_for_turn(std::path::Path::new("/nonexistent/transcript.jsonl")).is_none()); /// ``` pub fn model_for_turn(transcript_path: &Path) -> Option { model_in_tail(&tail(transcript_path)?) } /// Reads at most [`TAIL_BYTES`] from the end of a file. /// /// Decoded lossily: seeking to a byte offset lands in the middle of a line and /// may land in the middle of a character. The damage is confined to the first /// line of what is returned, which [`model_in_tail`] discards anyway by /// failing to parse it. fn tail(path: &Path) -> Option { let mut file = File::open(path).ok()?; let len = file.metadata().ok()?.len(); file.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES))) .ok()?; let mut bytes = Vec::new(); file.take(TAIL_BYTES).read_to_end(&mut bytes).ok()?; Some(String::from_utf8_lossy(&bytes).into_owned()) } /// The newest model identifier among the last [`MAX_ROWS_SCANNED`] rows. fn model_in_tail(tail: &str) -> Option { tail.lines() .rev() .take(MAX_ROWS_SCANNED) .find_map(model_in_row) } /// The model identifier one transcript row declares, if it is an assistant row. /// /// Every step is a question that may be answered "no" rather than a shape this /// asserts: an unparseable row, a row of another type, a row whose message has /// no model, and a model that is empty or longer than the record can hold all /// return `None` and are all ordinary. fn model_in_row(row: &str) -> Option { let row = row.trim(); if row.is_empty() { return None; } let value: serde_json::Value = serde_json::from_str(row).ok()?; if value.get("type")?.as_str()? != "assistant" { return None; } let model = value.get("message")?.get("model")?.as_str()?.trim(); if model.is_empty() || model.len() > MODEL_MAX_LENGTH { return None; } Some(model.to_string()) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; /// Writes `contents` to a uniquely named file and returns its path. /// /// A hand-rolled temporary file rather than a dependency: this crate has /// two, both of which the library itself needs, and one write in a test is /// not worth a third. fn transcript(name: &str, contents: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "didbot-transcript-{name}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos(), )); std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("transcript.jsonl"); std::fs::write(&path, contents).expect("write transcript"); path } fn assistant(model: &str) -> String { format!(r#"{{"type":"assistant","message":{{"model":"{model}","role":"assistant"}}}}"#) } #[test] fn reads_the_model_off_the_last_assistant_row() { let path = transcript( "plain", &format!( "{}\n{}\n{}\n", assistant("claude-sonnet-4-5"), r#"{"type":"user","message":{"role":"user"}}"#, assistant("claude-opus-5"), ), ); assert_eq!(model_for_turn(&path).as_deref(), Some("claude-opus-5")); } #[test] fn a_missing_file_is_not_an_error() { assert!(model_for_turn(Path::new("/nonexistent/dir/transcript.jsonl")).is_none()); } #[test] fn a_directory_in_place_of_a_transcript_is_not_an_error() { // `File::open` succeeds on a directory on Linux and the read fails. assert!(model_for_turn(&std::env::temp_dir()).is_none()); } #[test] fn an_empty_transcript_is_not_an_error() { assert!(model_for_turn(&transcript("empty", "")).is_none()); assert!(model_for_turn(&transcript("blank", "\n\n \n")).is_none()); } #[test] fn a_transcript_cut_off_mid_line_still_answers_from_the_row_before() { // The harness appends as it goes, so a read can land between the write // of a row and its newline. The truncated row must be skipped, not // allowed to hide the complete one under it. let path = transcript( "truncated", &format!( "{}\n{}", assistant("claude-opus-5"), r#"{"type":"assistant","message":{"mod"#, ), ); assert_eq!(model_for_turn(&path).as_deref(), Some("claude-opus-5")); } #[test] fn a_transcript_of_nothing_but_a_partial_row_is_not_an_error() { assert!(model_for_turn(&transcript("only-partial", r#"{"type":"assis"#)).is_none()); } #[test] fn a_transcript_with_no_assistant_rows_has_no_model() { let path = transcript( "no-assistant", "{\"type\":\"user\",\"message\":{\"role\":\"user\"}}\n\ {\"type\":\"system\",\"subtype\":\"init\"}\n", ); assert!(model_for_turn(&path).is_none()); } #[test] fn rows_in_an_unfamiliar_shape_are_skipped_rather_than_trusted() { // Each of these is a way the format could move under us: no `type`, a // `type` that is not a string, no `message`, a `message` that is not an // object, a model of the wrong type, and an empty model. None of them // may panic and none may produce a value. for row in [ r#"{"message":{"model":"claude-opus-5"}}"#, r#"{"type":7,"message":{"model":"claude-opus-5"}}"#, r#"{"type":"assistant"}"#, r#"{"type":"assistant","message":"claude-opus-5"}"#, r#"{"type":"assistant","message":{"model":42}}"#, r#"{"type":"assistant","message":{"model":" "}}"#, r#"["assistant","claude-opus-5"]"#, "null", "not json at all", ] { assert!( model_in_row(row).is_none(), "{row} should not have produced a model" ); } } #[test] fn a_model_longer_than_the_record_can_hold_is_dropped() { let long = "m".repeat(MODEL_MAX_LENGTH + 1); assert!(model_in_row(&assistant(&long)).is_none()); let at_limit = "m".repeat(MODEL_MAX_LENGTH); assert_eq!( model_in_row(&assistant(&at_limit)).as_deref(), Some(&*at_limit) ); } #[test] fn only_the_tail_of_a_long_transcript_is_read() { // The model is written once at the very top and then buried under more // than TAIL_BYTES of other rows. Answering `None` is the point: this // must never grow into a whole-file parse. let filler = format!("{}\n", r#"{"type":"user","message":{"role":"user"}}"#); let mut contents = format!("{}\n", assistant("claude-opus-5")); while contents.len() < usize::try_from(TAIL_BYTES).expect("fits") * 2 { contents.push_str(&filler); } assert!(model_for_turn(&transcript("long", &contents)).is_none()); } #[test] fn an_assistant_row_older_than_the_scan_bound_is_not_reached() { let mut contents = format!("{}\n", assistant("claude-opus-5")); for _ in 0..MAX_ROWS_SCANNED { contents.push_str(r#"{"type":"user","message":{"role":"user"}}"#); contents.push('\n'); } assert!(model_for_turn(&transcript("deep", &contents)).is_none()); } }