Skip to main content

zeph_subagent/
transcript.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! JSONL-based transcript persistence for sub-agent conversations.
5//!
6//! Each sub-agent session writes a `<task_id>.jsonl` file of [`TranscriptEntry`] lines
7//! and a companion `<task_id>.meta.json` sidecar with [`TranscriptMeta`].
8//!
9//! Files are created with `0o600` permissions on Unix to prevent other users from
10//! reading conversation history.
11//!
12//! The [`sweep_old_transcripts`] function prunes the oldest `.jsonl` files when a
13//! configurable maximum count is exceeded.
14
15use std::fs::{self, File};
16use std::io::{self, BufRead, BufReader, Write as _};
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20use zeph_llm::provider::Message;
21
22use super::error::SubAgentError;
23use super::state::SubAgentState;
24
25/// A single entry in a JSONL transcript file.
26///
27/// Each line in `<task_id>.jsonl` deserializes to a `TranscriptEntry`.
28/// Entries are written in append order; `seq` is a monotonically increasing counter
29/// within a single session.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TranscriptEntry {
32    /// Zero-based sequence number within the session.
33    pub seq: u32,
34    /// ISO 8601 UTC timestamp at the time of writing (e.g. `"2026-04-09T12:00:00Z"`).
35    pub timestamp: String,
36    /// The LLM message that was appended at this sequence position.
37    pub message: Message,
38}
39
40/// Sidecar metadata for a transcript, written as `<agent_id>.meta.json`.
41///
42/// The sidecar is written twice: once at spawn time with `status: Submitted` and
43/// again at collection time with the final terminal state and `finished_at`.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TranscriptMeta {
46    /// UUID of this sub-agent session.
47    pub agent_id: String,
48    /// Runtime agent name (same as `def_name` for non-resumed sessions).
49    pub agent_name: String,
50    /// Name of the [`SubAgentDef`][crate::SubAgentDef] that was used.
51    pub def_name: String,
52    /// Terminal lifecycle state recorded at collection time.
53    pub status: SubAgentState,
54    /// ISO 8601 UTC timestamp when the session was spawned.
55    pub started_at: String,
56    /// ISO 8601 UTC timestamp when the session finished, if known.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub finished_at: Option<String>,
59    /// ID of the original agent session this was resumed from.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub resumed_from: Option<String>,
62    /// Number of LLM turns consumed by the session.
63    pub turns_used: u32,
64}
65
66/// Appends [`TranscriptEntry`] lines to a JSONL transcript file.
67///
68/// The file handle is kept open for the writer's lifetime to avoid
69/// race conditions from repeated open/close cycles.
70///
71/// # Examples
72///
73/// ```rust,no_run
74/// use std::path::Path;
75/// use zeph_subagent::transcript::TranscriptWriter;
76///
77/// let mut writer = TranscriptWriter::new(Path::new("/tmp/session.jsonl")).unwrap();
78/// // writer.append(seq, &message) to persist each message.
79/// ```
80pub struct TranscriptWriter {
81    file: File,
82}
83
84impl TranscriptWriter {
85    /// Create (or open) a JSONL transcript file in append mode.
86    ///
87    /// Creates parent directories if they do not already exist.
88    ///
89    /// # Errors
90    ///
91    /// Returns `io::Error` if the directory cannot be created or the file cannot be opened.
92    pub fn new(path: &Path) -> io::Result<Self> {
93        if let Some(parent) = path.parent() {
94            fs::create_dir_all(parent)?;
95        }
96        let file = zeph_common::fs_secure::append_private(path)?;
97        Ok(Self { file })
98    }
99
100    /// Append a single message as a JSON line and flush immediately.
101    ///
102    /// # Errors
103    ///
104    /// Returns `io::Error` on serialization or write failure.
105    pub fn append(&mut self, seq: u32, message: &Message) -> io::Result<()> {
106        let entry = TranscriptEntry {
107            seq,
108            timestamp: utc_now(),
109            message: message.clone(),
110        };
111        let line = serde_json::to_string(&entry)
112            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
113        self.file.write_all(line.as_bytes())?;
114        self.file.write_all(b"\n")?;
115        self.file.flush()
116    }
117
118    /// Write the meta sidecar file for an agent.
119    ///
120    /// # Errors
121    ///
122    /// Returns `io::Error` on serialization or write failure.
123    pub fn write_meta(dir: &Path, agent_id: &str, meta: &TranscriptMeta) -> io::Result<()> {
124        let path = dir.join(format!("{agent_id}.meta.json"));
125        let content = serde_json::to_string_pretty(meta)
126            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
127        zeph_common::fs_secure::write_private(&path, content.as_bytes())
128    }
129}
130
131/// Reads and reconstructs message history from JSONL transcript files.
132///
133/// `TranscriptReader` is a zero-size marker type with only associated functions.
134/// Use [`TranscriptReader::load`] to reconstruct a message history from a `.jsonl` file,
135/// [`TranscriptReader::load_meta`] to read the companion `.meta.json` sidecar, and
136/// [`TranscriptReader::find_by_prefix`] to resolve a short ID prefix to a full UUID.
137pub struct TranscriptReader;
138
139impl TranscriptReader {
140    /// Load all messages from a JSONL transcript file.
141    ///
142    /// Malformed lines are skipped with a warning. An empty or missing file
143    /// returns an empty `Vec`. If the file does not exist at all but a matching
144    /// `.meta.json` sidecar exists, returns `SubAgentError::Transcript` with a
145    /// clear message so the caller knows the data is gone rather than silently
146    /// degrading to a fresh start.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`SubAgentError::Transcript`] on unrecoverable I/O failures, or
151    /// when the transcript file is missing but meta exists (data-loss guard).
152    pub fn load(path: &Path) -> Result<Vec<Message>, SubAgentError> {
153        let file = match File::open(path) {
154            Ok(f) => f,
155            Err(e) if e.kind() == io::ErrorKind::NotFound => {
156                // Check if a meta sidecar exists — if so, data has been lost.
157                // Build meta path from the file stem (e.g. "abc" from "abc.jsonl")
158                // so it is consistent with write_meta which uses format!("{agent_id}.meta.json").
159                let meta_path =
160                    if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
161                        parent.join(format!("{}.meta.json", stem.to_string_lossy()))
162                    } else {
163                        path.with_extension("meta.json")
164                    };
165                if meta_path.exists() {
166                    return Err(SubAgentError::Transcript(format!(
167                        "transcript file '{}' is missing but meta sidecar exists — \
168                         transcript data may have been deleted",
169                        path.display()
170                    )));
171                }
172                return Ok(vec![]);
173            }
174            Err(e) => {
175                return Err(SubAgentError::Transcript(format!(
176                    "failed to open transcript '{}': {e}",
177                    path.display()
178                )));
179            }
180        };
181
182        let reader = BufReader::new(file);
183        let mut messages = Vec::new();
184        for (line_no, line_result) in reader.lines().enumerate() {
185            let line = match line_result {
186                Ok(l) => l,
187                Err(e) => {
188                    tracing::warn!(
189                        path = %path.display(),
190                        line = line_no + 1,
191                        error = %e,
192                        "failed to read transcript line — skipping"
193                    );
194                    continue;
195                }
196            };
197            let trimmed = line.trim();
198            if trimmed.is_empty() {
199                continue;
200            }
201            match serde_json::from_str::<TranscriptEntry>(trimmed) {
202                Ok(entry) => messages.push(entry.message),
203                Err(e) => {
204                    tracing::warn!(
205                        path = %path.display(),
206                        line = line_no + 1,
207                        error = %e,
208                        "malformed transcript entry — skipping"
209                    );
210                }
211            }
212        }
213        Ok(messages)
214    }
215
216    /// Load the meta sidecar for an agent.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`SubAgentError::NotFound`] if the file does not exist,
221    /// [`SubAgentError::Transcript`] on parse failure.
222    pub fn load_meta(dir: &Path, agent_id: &str) -> Result<TranscriptMeta, SubAgentError> {
223        let path = dir.join(format!("{agent_id}.meta.json"));
224        let content = fs::read_to_string(&path).map_err(|e| {
225            if e.kind() == io::ErrorKind::NotFound {
226                SubAgentError::NotFound(agent_id.to_owned())
227            } else {
228                SubAgentError::Transcript(format!("failed to read meta '{}': {e}", path.display()))
229            }
230        })?;
231        serde_json::from_str(&content).map_err(|e| {
232            SubAgentError::Transcript(format!("failed to parse meta '{}': {e}", path.display()))
233        })
234    }
235
236    /// Find the full agent ID by scanning `dir` for `.meta.json` files whose names
237    /// start with `prefix`.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`SubAgentError::NotFound`] if no match is found,
242    /// [`SubAgentError::AmbiguousId`] if multiple matches are found,
243    /// [`SubAgentError::Transcript`] on I/O failure.
244    pub fn find_by_prefix(dir: &Path, prefix: &str) -> Result<String, SubAgentError> {
245        let entries = fs::read_dir(dir).map_err(|e| {
246            SubAgentError::Transcript(format!(
247                "failed to read transcript dir '{}': {e}",
248                dir.display()
249            ))
250        })?;
251
252        let mut matches: Vec<String> = Vec::new();
253        for entry in entries {
254            let entry = entry
255                .map_err(|e| SubAgentError::Transcript(format!("failed to read dir entry: {e}")))?;
256            let name = entry.file_name();
257            let name_str = name.to_string_lossy();
258            if let Some(agent_id) = name_str.strip_suffix(".meta.json")
259                && agent_id.starts_with(prefix)
260            {
261                matches.push(agent_id.to_owned());
262            }
263        }
264
265        match matches.len() {
266            0 => Err(SubAgentError::NotFound(prefix.to_owned())),
267            1 => Ok(matches.remove(0)),
268            n => Err(SubAgentError::AmbiguousId(prefix.to_owned(), n)),
269        }
270    }
271}
272
273/// Delete the oldest `.jsonl` files in `dir` when the count exceeds `max_files`.
274///
275/// Files are sorted by modification time (oldest first). Returns the number of
276/// files deleted.
277///
278/// # Errors
279///
280/// Returns `io::Error` if the directory cannot be read or a file cannot be deleted.
281pub fn sweep_old_transcripts(dir: &Path, max_files: usize) -> io::Result<usize> {
282    if max_files == 0 {
283        return Ok(0);
284    }
285
286    // Create the directory if it does not exist yet (first run).
287    if !dir.exists() {
288        fs::create_dir_all(dir)?;
289        return Ok(0);
290    }
291
292    let mut jsonl_files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
293    for entry in fs::read_dir(dir)? {
294        let entry = entry?;
295        let path = entry.path();
296        if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
297            let mtime = entry
298                .metadata()
299                .and_then(|m| m.modified())
300                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
301            jsonl_files.push((path, mtime));
302        }
303    }
304
305    if jsonl_files.len() <= max_files {
306        return Ok(0);
307    }
308
309    // Sort oldest first.
310    jsonl_files.sort_by_key(|(_, mtime)| *mtime);
311
312    let to_delete = jsonl_files.len() - max_files;
313    let mut deleted = 0;
314    for (path, _) in jsonl_files.into_iter().take(to_delete) {
315        // Also remove the companion .meta.json sidecar if present.
316        let meta = path.with_extension("meta.json");
317        if meta.exists() {
318            let _ = fs::remove_file(&meta);
319        }
320        fs::remove_file(&path)?;
321        deleted += 1;
322    }
323    Ok(deleted)
324}
325
326/// Returns the current UTC time as an ISO 8601 string (`"YYYY-MM-DDTHH:MM:SSZ"`).
327///
328/// This is the public companion of the internal `utc_now()` helper, exposed for
329/// use by [`SubAgentManager`][crate::SubAgentManager] when writing transcript sidecars.
330///
331/// # Examples
332///
333/// ```rust
334/// use zeph_subagent::transcript::utc_now_pub;
335///
336/// let ts = utc_now_pub();
337/// assert_eq!(ts.len(), 20, "expected 20-char ISO 8601 timestamp");
338/// assert!(ts.ends_with('Z'));
339/// assert!(ts.contains('T'));
340/// ```
341#[must_use]
342pub fn utc_now_pub() -> String {
343    utc_now()
344}
345
346fn utc_now() -> String {
347    // Use SystemTime for a zero-dependency ISO 8601 timestamp.
348    // Format: 2026-03-05T00:18:16Z
349    let secs = std::time::SystemTime::now()
350        .duration_since(std::time::UNIX_EPOCH)
351        .unwrap_or_default()
352        .as_secs();
353    let (y, mo, d, h, mi, s) = epoch_to_parts(secs);
354    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
355}
356
357/// Convert Unix epoch seconds to (year, month, day, hour, minute, second).
358///
359/// Uses the proleptic Gregorian calendar algorithm (Fliegel-Van Flandern variant).
360/// All values are u64 throughout to avoid truncating casts; the caller knows values
361/// fit in u32 for the ranges used (years 1970–2554, seconds/minutes/hours/days).
362fn epoch_to_parts(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
363    let sec = epoch % 60;
364    let epoch = epoch / 60;
365    let min = epoch % 60;
366    let epoch = epoch / 60;
367    let hour = epoch % 24;
368    let days = epoch / 24;
369
370    // Days since 1970-01-01 → civil calendar (Gregorian).
371    let z = days + 719_468;
372    let era = z / 146_097;
373    let doe = z - era * 146_097;
374    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
375    let year = yoe + era * 400;
376    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
377    let mp = (5 * doy + 2) / 153;
378    let day = doy - (153 * mp + 2) / 5 + 1;
379    let month = if mp < 10 { mp + 3 } else { mp - 9 };
380    let year = if month <= 2 { year + 1 } else { year };
381
382    // All values are in range for u32 for any timestamp in [1970, 2554].
383    #[allow(clippy::cast_possible_truncation)]
384    (
385        year as u32,
386        month as u32,
387        day as u32,
388        hour as u32,
389        min as u32,
390        sec as u32,
391    )
392}
393
394#[cfg(test)]
395mod tests {
396    use zeph_llm::provider::{Message, MessageMetadata, Role};
397
398    use super::*;
399
400    fn test_message(role: Role, content: &str) -> Message {
401        Message {
402            role,
403            content: content.to_owned(),
404            parts: vec![],
405            metadata: MessageMetadata::default(),
406        }
407    }
408
409    fn test_meta(agent_id: &str) -> TranscriptMeta {
410        TranscriptMeta {
411            agent_id: agent_id.to_owned(),
412            agent_name: "bot".to_owned(),
413            def_name: "bot".to_owned(),
414            status: SubAgentState::Completed,
415            started_at: "2026-01-01T00:00:00Z".to_owned(),
416            finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
417            resumed_from: None,
418            turns_used: 2,
419        }
420    }
421
422    #[test]
423    fn writer_reader_roundtrip() {
424        let dir = tempfile::tempdir().unwrap();
425        let path = dir.path().join("test.jsonl");
426
427        let msg1 = test_message(Role::User, "hello");
428        let msg2 = test_message(Role::Assistant, "world");
429
430        let mut writer = TranscriptWriter::new(&path).unwrap();
431        writer.append(0, &msg1).unwrap();
432        writer.append(1, &msg2).unwrap();
433        drop(writer);
434
435        let messages = TranscriptReader::load(&path).unwrap();
436        assert_eq!(messages.len(), 2);
437        assert_eq!(messages[0].content, "hello");
438        assert_eq!(messages[1].content, "world");
439    }
440
441    #[test]
442    fn load_missing_file_no_meta_returns_empty() {
443        let dir = tempfile::tempdir().unwrap();
444        let path = dir.path().join("ghost.jsonl");
445        let messages = TranscriptReader::load(&path).unwrap();
446        assert!(messages.is_empty());
447    }
448
449    #[test]
450    fn load_missing_file_with_meta_returns_error() {
451        let dir = tempfile::tempdir().unwrap();
452        let meta_path = dir.path().join("ghost.meta.json");
453        std::fs::write(&meta_path, "{}").unwrap();
454        let jsonl_path = dir.path().join("ghost.jsonl");
455        let err = TranscriptReader::load(&jsonl_path).unwrap_err();
456        assert!(matches!(err, SubAgentError::Transcript(_)));
457    }
458
459    #[test]
460    fn load_skips_malformed_lines() {
461        let dir = tempfile::tempdir().unwrap();
462        let path = dir.path().join("mixed.jsonl");
463
464        let good = test_message(Role::User, "good");
465        let entry = TranscriptEntry {
466            seq: 0,
467            timestamp: "2026-01-01T00:00:00Z".to_owned(),
468            message: good.clone(),
469        };
470        let good_line = serde_json::to_string(&entry).unwrap();
471        let content = format!("{good_line}\nnot valid json\n{good_line}\n");
472        std::fs::write(&path, &content).unwrap();
473
474        let messages = TranscriptReader::load(&path).unwrap();
475        assert_eq!(messages.len(), 2);
476    }
477
478    #[test]
479    fn meta_roundtrip() {
480        let dir = tempfile::tempdir().unwrap();
481        let meta = test_meta("abc-123");
482        TranscriptWriter::write_meta(dir.path(), "abc-123", &meta).unwrap();
483        let loaded = TranscriptReader::load_meta(dir.path(), "abc-123").unwrap();
484        assert_eq!(loaded.agent_id, "abc-123");
485        assert_eq!(loaded.turns_used, 2);
486    }
487
488    #[test]
489    fn meta_not_found_returns_not_found_error() {
490        let dir = tempfile::tempdir().unwrap();
491        let err = TranscriptReader::load_meta(dir.path(), "ghost").unwrap_err();
492        assert!(matches!(err, SubAgentError::NotFound(_)));
493    }
494
495    #[test]
496    fn find_by_prefix_exact() {
497        let dir = tempfile::tempdir().unwrap();
498        let meta = test_meta("abcdef01-0000-0000-0000-000000000000");
499        TranscriptWriter::write_meta(dir.path(), "abcdef01-0000-0000-0000-000000000000", &meta)
500            .unwrap();
501        let id =
502            TranscriptReader::find_by_prefix(dir.path(), "abcdef01-0000-0000-0000-000000000000")
503                .unwrap();
504        assert_eq!(id, "abcdef01-0000-0000-0000-000000000000");
505    }
506
507    #[test]
508    fn find_by_prefix_short_prefix() {
509        let dir = tempfile::tempdir().unwrap();
510        let meta = test_meta("deadbeef-0000-0000-0000-000000000000");
511        TranscriptWriter::write_meta(dir.path(), "deadbeef-0000-0000-0000-000000000000", &meta)
512            .unwrap();
513        let id = TranscriptReader::find_by_prefix(dir.path(), "deadbeef").unwrap();
514        assert_eq!(id, "deadbeef-0000-0000-0000-000000000000");
515    }
516
517    #[test]
518    fn find_by_prefix_not_found() {
519        let dir = tempfile::tempdir().unwrap();
520        let err = TranscriptReader::find_by_prefix(dir.path(), "xxxxxxxx").unwrap_err();
521        assert!(matches!(err, SubAgentError::NotFound(_)));
522    }
523
524    #[test]
525    fn find_by_prefix_ambiguous() {
526        let dir = tempfile::tempdir().unwrap();
527        TranscriptWriter::write_meta(dir.path(), "aabb0001-x", &test_meta("aabb0001-x")).unwrap();
528        TranscriptWriter::write_meta(dir.path(), "aabb0002-y", &test_meta("aabb0002-y")).unwrap();
529        let err = TranscriptReader::find_by_prefix(dir.path(), "aabb").unwrap_err();
530        assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
531    }
532
533    #[test]
534    fn sweep_old_transcripts_removes_oldest() {
535        let dir = tempfile::tempdir().unwrap();
536
537        for i in 0..5u32 {
538            let path = dir.path().join(format!("file{i:02}.jsonl"));
539            std::fs::write(&path, b"").unwrap();
540            // Vary mtime by touching the file — not reliable without explicit mtime set,
541            // but tempdir files get sequential syscall timestamps in practice.
542            // We set the mtime explicitly via filetime crate... but we have no filetime dep.
543            // Instead we just verify count is correct.
544        }
545
546        let deleted = sweep_old_transcripts(dir.path(), 3).unwrap();
547        assert_eq!(deleted, 2);
548
549        let remaining: Vec<_> = std::fs::read_dir(dir.path())
550            .unwrap()
551            .filter_map(std::result::Result::ok)
552            .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl"))
553            .collect();
554        assert_eq!(remaining.len(), 3);
555    }
556
557    #[test]
558    fn sweep_with_zero_max_does_nothing() {
559        let dir = tempfile::tempdir().unwrap();
560        std::fs::write(dir.path().join("a.jsonl"), b"").unwrap();
561        let deleted = sweep_old_transcripts(dir.path(), 0).unwrap();
562        assert_eq!(deleted, 0);
563    }
564
565    #[test]
566    fn sweep_below_max_does_nothing() {
567        let dir = tempfile::tempdir().unwrap();
568        std::fs::write(dir.path().join("a.jsonl"), b"").unwrap();
569        let deleted = sweep_old_transcripts(dir.path(), 50).unwrap();
570        assert_eq!(deleted, 0);
571    }
572
573    #[test]
574    fn utc_now_format() {
575        let ts = utc_now();
576        // Basic format check: 2026-03-05T00:18:16Z
577        assert_eq!(ts.len(), 20);
578        assert!(ts.ends_with('Z'));
579        assert!(ts.contains('T'));
580    }
581
582    #[test]
583    fn load_empty_file_returns_empty() {
584        let dir = tempfile::tempdir().unwrap();
585        let path = dir.path().join("empty.jsonl");
586        std::fs::write(&path, b"").unwrap();
587        let messages = TranscriptReader::load(&path).unwrap();
588        assert!(messages.is_empty());
589    }
590
591    #[test]
592    fn load_meta_invalid_json_returns_transcript_error() {
593        let dir = tempfile::tempdir().unwrap();
594        std::fs::write(dir.path().join("bad.meta.json"), b"not json at all {{{{").unwrap();
595        let err = TranscriptReader::load_meta(dir.path(), "bad").unwrap_err();
596        assert!(matches!(err, SubAgentError::Transcript(_)));
597    }
598
599    #[test]
600    fn sweep_removes_companion_meta() {
601        let dir = tempfile::tempdir().unwrap();
602        // Create 4 JSONL files each with a companion meta sidecar.
603        for i in 0..4u32 {
604            let stem = format!("file{i:02}");
605            std::fs::write(dir.path().join(format!("{stem}.jsonl")), b"").unwrap();
606            std::fs::write(dir.path().join(format!("{stem}.meta.json")), b"{}").unwrap();
607        }
608        let deleted = sweep_old_transcripts(dir.path(), 2).unwrap();
609        assert_eq!(deleted, 2);
610        // Companion metas for the two deleted files should also be gone.
611        let meta_count = std::fs::read_dir(dir.path())
612            .unwrap()
613            .filter_map(std::result::Result::ok)
614            .filter(|e| e.path().to_string_lossy().ends_with(".meta.json"))
615            .count();
616        assert_eq!(
617            meta_count, 2,
618            "orphaned meta sidecars should have been removed"
619        );
620    }
621
622    #[test]
623    fn data_loss_guard_uses_stem_based_meta_path() {
624        // path.with_extension("meta.json") on "abc.jsonl" should yield "abc.meta.json"
625        // which matches write_meta's format!("{agent_id}.meta.json") when agent_id == stem.
626        let dir = tempfile::tempdir().unwrap();
627        let agent_id = "deadbeef-0000-0000-0000-000000000000";
628        // Write meta sidecar but not the JSONL file.
629        std::fs::write(dir.path().join(format!("{agent_id}.meta.json")), b"{}").unwrap();
630        let jsonl_path = dir.path().join(format!("{agent_id}.jsonl"));
631        let err = TranscriptReader::load(&jsonl_path).unwrap_err();
632        assert!(matches!(err, SubAgentError::Transcript(ref m) if m.contains("missing")));
633    }
634}