Skip to main content

agentmux_srv/backend/history/
record.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What AgentMux's own record says about an agent's conversations.
5//!
6//! Every agent's provider output is mirrored into the global transcript store
7//! (`~/.agentmux/shared/agents/transcripts/filestore.db`): zone
8//! `agent:<id>:current`, moved to `agent:<id>:archive:<ms>` by "New
9//! conversation". For a user agent `<id>` is its UID (`db_agents.id`), the same
10//! in every channel and version and across account switches, so this record is
11//! the one place that holds every session the agent ran — including sessions
12//! whose provider transcript is gone (75 of 190 sessions across the 15 agents
13//! on one host, measured 2026-09-24). A template launch writes into its
14//! template's shared zone; that zone is not the agent's alone and is never read
15//! here.
16//!
17//! Sessions come from the record, not from guessing by folder or account:
18//! every line the provider writes carries its `session_id`. A line without one
19//! — a message a person typed, echoed by AgentMux — belongs to the session of
20//! the next line that has one, the process that received it. The record only
21//! grows until it's archived, so each zone remembers how far it has been read
22//! and a later search reads only what was appended.
23//!
24//! Claude's stream-json only: a line from a provider whose output carries no
25//! `session_id` never starts a session here.
26
27use std::collections::HashMap;
28use std::sync::{Arc, Mutex};
29
30use super::adapter::{HistoryError, HistoryMessage};
31use crate::backend::agent_session::{OUTPUT_FILE, TSIDX_FILE};
32use crate::backend::storage::filestore::FileStore;
33
34/// How much of a zone is read per step.
35const READ_CHUNK: i64 = 4 * 1024 * 1024;
36
37/// A session, as the agent's record holds it.
38#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct RecordSession {
40    pub session_id: String,
41    /// The working directory its `init` line reported.
42    pub cwd: Option<String>,
43    /// `(zone, start, end)` byte ranges of the zone's `output`, in record
44    /// order. Empty for a session only the segment log knows.
45    pub ranges: Vec<(String, i64, i64)>,
46    /// Receive times of its first and last lines (unix ms); 0 = unknown.
47    pub first_ms: i64,
48    pub last_ms: i64,
49}
50
51/// Consecutive lines of one session.
52#[derive(Debug, Clone, PartialEq)]
53struct Run {
54    session_id: String,
55    start: i64,
56    end: i64,
57}
58
59/// How far a zone has been read, and what was found.
60#[derive(Debug, Default, Clone)]
61struct ZoneScan {
62    /// Bytes of complete lines already read.
63    read_to: i64,
64    runs: Vec<Run>,
65    /// Start of trailing lines no session owns yet (a typed message whose
66    /// answer hasn't arrived).
67    unowned_from: Option<i64>,
68    cwd: HashMap<String, String>,
69}
70
71/// Reads agents' records from the global transcript store. No store (a
72/// test, or a srv that couldn't attach it) reads nothing.
73pub(crate) struct AgentRecords {
74    store: StoreSource,
75    scans: Mutex<HashMap<String, ZoneScan>>,
76}
77
78enum StoreSource {
79    /// The global transcript store, looked up when used: bootstrap attaches it.
80    Global,
81    #[cfg(test)]
82    Fixed(Option<Arc<FileStore>>),
83}
84
85impl AgentRecords {
86    pub(crate) fn global() -> Self {
87        AgentRecords { store: StoreSource::Global, scans: Mutex::new(HashMap::new()) }
88    }
89
90    #[cfg(test)]
91    pub(crate) fn new(store: Option<Arc<FileStore>>) -> Self {
92        AgentRecords { store: StoreSource::Fixed(store), scans: Mutex::new(HashMap::new()) }
93    }
94
95    fn store(&self) -> Option<Arc<FileStore>> {
96        match &self.store {
97            StoreSource::Global => crate::backend::agent_session::global_transcript_store().cloned(),
98            #[cfg(test)]
99            StoreSource::Fixed(store) => store.clone(),
100        }
101    }
102
103    /// Every session in `uid`'s record — its current conversation and every
104    /// archived one — plus the segment log's sessions the record doesn't
105    /// have, and what couldn't be read. Newest last activity first.
106    pub(crate) fn sessions_of(&self, uid: &str) -> (Vec<RecordSession>, Vec<String>) {
107        let Some(store) = self.store() else {
108            return (Vec::new(), Vec::new());
109        };
110        let store = &*store;
111        if !crate::backend::agent_session::is_valid_definition_id(uid) {
112            return (Vec::new(), Vec::new());
113        }
114        let mut problems = Vec::new();
115        let zones = match store.get_all_zone_ids() {
116            Ok(all) => all.into_iter().filter(|z| is_agents_own_zone(z, uid)).collect::<Vec<_>>(),
117            Err(e) => return (Vec::new(), vec![format!("agent record: listing zones failed: {e}")]),
118        };
119
120        let mut by_id: HashMap<String, RecordSession> = HashMap::new();
121        let mut order: Vec<String> = Vec::new();
122        for zone in &zones {
123            let scan = {
124                let mut scans = self.scans.lock().unwrap_or_else(|p| p.into_inner());
125                let scan = scans.entry(zone.clone()).or_default();
126                if let Err(e) = scan_zone(store, zone, scan) {
127                    problems.push(format!("agent record {zone}: {e}"));
128                }
129                scan.clone()
130            };
131            // Trailing unowned lines stay unassigned until their answer
132            // arrives, except in a finished (archived) zone, where nothing
133            // will: they belong to its last session.
134            let mut runs = scan.runs.clone();
135            if let (Some(from), Some(last)) = (scan.unowned_from, runs.last_mut()) {
136                if zone.contains(":archive:") {
137                    last.end = last.end.max(scan.read_to.max(from));
138                }
139            }
140            let stamps = zone_stamps(store, zone, &runs);
141            for (i, run) in runs.iter().enumerate() {
142                let (first, last) = stamps.get(i).copied().unwrap_or((0, 0));
143                let session = by_id.entry(run.session_id.clone()).or_insert_with(|| {
144                    order.push(run.session_id.clone());
145                    RecordSession {
146                        session_id: run.session_id.clone(),
147                        cwd: scan.cwd.get(&run.session_id).cloned(),
148                        ranges: Vec::new(),
149                        first_ms: 0,
150                        last_ms: 0,
151                    }
152                });
153                session.ranges.push((zone.clone(), run.start, run.end));
154                if first != 0 && (session.first_ms == 0 || first < session.first_ms) {
155                    session.first_ms = first;
156                }
157                session.last_ms = session.last_ms.max(last);
158                if session.cwd.is_none() {
159                    session.cwd = scan.cwd.get(&run.session_id).cloned();
160                }
161            }
162        }
163
164        // The segment log (#3676) names every session a spawn ran since it
165        // shipped, even one whose output never reached the record.
166        for segment in crate::backend::continuity_segments::segments(store, uid) {
167            let Some(sid) = segment.start.provider_session_id else { continue };
168            by_id.entry(sid.clone()).or_insert_with(|| {
169                order.push(sid.clone());
170                RecordSession {
171                    session_id: sid,
172                    cwd: Some(segment.start.cwd).filter(|c| !c.is_empty()),
173                    ranges: Vec::new(),
174                    first_ms: segment.start.started_at_ms,
175                    last_ms: segment.ended_at_ms.unwrap_or(segment.start.started_at_ms),
176                }
177            });
178        }
179
180        let position: HashMap<&str, usize> = order.iter().enumerate().map(|(i, id)| (id.as_str(), i)).collect();
181        let mut sessions: Vec<RecordSession> = by_id.into_values().collect();
182        sessions.sort_by(|a, b| {
183            b.last_ms
184                .cmp(&a.last_ms)
185                .then(position[b.session_id.as_str()].cmp(&position[a.session_id.as_str()]))
186        });
187        (sessions, problems)
188    }
189
190    /// A session's messages, from the record, with the receive time of each
191    /// line; and how many of its records couldn't be read.
192    pub(crate) fn read(&self, session: &RecordSession) -> Result<(Vec<HistoryMessage>, u32), HistoryError> {
193        let Some(store) = self.store() else {
194            return Err(HistoryError::Other("AgentMux's transcript store isn't available".into()));
195        };
196        let store = &*store;
197        let mut messages = Vec::new();
198        let mut skipped = 0u32;
199        for (zone, start, end) in &session.ranges {
200            let mut offsets: Vec<u64> = Vec::new();
201            let mut zone_messages: Vec<HistoryMessage> = Vec::new();
202            for_each_line(store, zone, *start, *end, |line, offset| {
203                if line.iter().all(u8::is_ascii_whitespace) || is_stream_event(line) {
204                    return;
205                }
206                let Ok(entry) = serde_json::from_slice::<serde_json::Value>(line) else {
207                    skipped += 1;
208                    return;
209                };
210                if entry.get("session_id").and_then(|v| v.as_str()).is_some_and(|sid| sid != session.session_id) {
211                    return;
212                }
213                if let Some(message) = super::claude_adapter::message_from_entry(&entry, 0) {
214                    offsets.push(offset as u64);
215                    zone_messages.push(message);
216                }
217            })
218            .map_err(|e| HistoryError::Other(format!("agent record {zone}: {e}")))?;
219            if let Some(stamps) = line_stamps(store, zone, &offsets) {
220                for (message, ms) in zone_messages.iter_mut().zip(stamps) {
221                    message.timestamp = ms;
222                }
223            }
224            messages.extend(zone_messages);
225        }
226        Ok((messages, skipped))
227    }
228}
229
230/// `agent:<uid>:current` and `agent:<uid>:archive:<ms>` — never another id's
231/// zone, and never a template's shared one (its key isn't this UID).
232fn is_agents_own_zone(zone: &str, uid: &str) -> bool {
233    let Some(rest) = zone.strip_prefix("agent:").and_then(|z| z.strip_prefix(uid)) else {
234        return false;
235    };
236    rest == ":current" || rest.strip_prefix(":archive:").is_some_and(|ms| !ms.is_empty() && ms.bytes().all(|b| b.is_ascii_digit()))
237}
238
239/// Streaming deltas: most of the record's bytes, and never a message.
240fn is_stream_event(line: &[u8]) -> bool {
241    memchr::memmem::find(line.get(..48).unwrap_or(line), b"\"stream_event\"").is_some()
242}
243
244/// The session a line belongs to, if it names one. A streaming delta's shape
245/// is fixed and its payload holds only escaped strings, so its last
246/// `"session_id":"` key is the line's own; anything else is parsed.
247fn line_session_id(line: &[u8]) -> Option<String> {
248    if is_stream_event(line) {
249        const KEY: &[u8] = b"\"session_id\":\"";
250        let at = memchr::memmem::rfind(line, KEY)? + KEY.len();
251        let len = memchr::memchr(b'"', &line[at..])?;
252        return String::from_utf8(line[at..at + len].to_vec()).ok().filter(|s| !s.is_empty());
253    }
254    // Only the top-level key: the rest of the line is skipped, not built.
255    #[derive(serde::Deserialize)]
256    struct Top {
257        #[serde(default)]
258        session_id: Option<String>,
259    }
260    serde_json::from_slice::<Top>(line).ok()?.session_id.filter(|s| !s.is_empty())
261}
262
263/// The working directory an `init` line reports.
264fn init_cwd(line: &[u8]) -> Option<String> {
265    // `init` is a small system line whose subtype comes first.
266    if memchr::memmem::find(line.get(..96).unwrap_or(line), b"\"subtype\":\"init\"").is_none() {
267        return None;
268    }
269    let entry: serde_json::Value = serde_json::from_slice(line).ok()?;
270    (entry.get("subtype").and_then(|v| v.as_str()) == Some("init"))
271        .then(|| entry.get("cwd").and_then(|v| v.as_str()).map(str::to_string))
272        .flatten()
273}
274
275fn scan_line(scan: &mut ZoneScan, line: &[u8], start: i64, end: i64) {
276    if line.iter().all(u8::is_ascii_whitespace) {
277        return;
278    }
279    let Some(sid) = line_session_id(line) else {
280        scan.unowned_from.get_or_insert(start);
281        return;
282    };
283    let from = scan.unowned_from.take().unwrap_or(start);
284    match scan.runs.last_mut() {
285        Some(run) if run.session_id == sid && run.end == from => run.end = end,
286        _ => scan.runs.push(Run { session_id: sid.clone(), start: from, end }),
287    }
288    if let Some(cwd) = init_cwd(line) {
289        scan.cwd.entry(sid).or_insert(cwd);
290    }
291}
292
293/// Read what was appended to `zone` since the last scan. A zone smaller than
294/// what was read (archived and started over) is read again from the start.
295fn scan_zone(store: &FileStore, zone: &str, scan: &mut ZoneScan) -> Result<(), String> {
296    let size = match store.stat(zone, OUTPUT_FILE).map_err(|e| e.to_string())? {
297        Some(file) => file.size,
298        None => {
299            *scan = ZoneScan::default();
300            return Ok(());
301        }
302    };
303    if size < scan.read_to {
304        *scan = ZoneScan::default();
305    }
306    let from = scan.read_to;
307    let mut last_complete = from;
308    for_each_line(store, zone, from, size, |line, offset| {
309        let end = offset + line.len() as i64 + 1;
310        scan_line(scan, line, offset, end);
311        last_complete = end;
312    })?;
313    scan.read_to = last_complete;
314    Ok(())
315}
316
317/// Call `f(line, offset)` for every complete (newline-terminated) line of the
318/// zone's `output` in `[start, end)`. A trailing unterminated line is left for
319/// the next read — it's an append in progress.
320fn for_each_line(
321    store: &FileStore,
322    zone: &str,
323    start: i64,
324    end: i64,
325    mut f: impl FnMut(&[u8], i64),
326) -> Result<(), String> {
327    let mut pos = start;
328    let mut carry: Vec<u8> = Vec::new();
329    let mut carry_at = start;
330    while pos < end {
331        let (_, data) = store
332            .read_at(zone, OUTPUT_FILE, pos, READ_CHUNK.min(end - pos))
333            .map_err(|e| e.to_string())?;
334        if data.is_empty() {
335            break;
336        }
337        pos += data.len() as i64;
338        carry.extend_from_slice(&data);
339        let mut line_start = 0usize;
340        while let Some(nl) = memchr::memchr(b'\n', &carry[line_start..]) {
341            f(&carry[line_start..line_start + nl], carry_at + line_start as i64);
342            line_start += nl + 1;
343        }
344        carry.drain(..line_start);
345        carry_at += line_start as i64;
346    }
347    Ok(())
348}
349
350/// Receive times for line offsets from the zone's `output.tsidx`; `None` if
351/// the zone has none.
352fn line_stamps(store: &FileStore, zone: &str, offsets: &[u64]) -> Option<Vec<i64>> {
353    let size = store.stat(zone, TSIDX_FILE).ok()??.size;
354    let read = |off: i64, len: i64| store.read_at(zone, TSIDX_FILE, off, len).map(|(_, data)| Some(data));
355    crate::backend::blockcontroller::shell::stamps::stamps_for(&read, size, offsets).ok()?
356}
357
358/// `(first, last)` receive time for each run: its first line and the line
359/// holding its last byte.
360fn zone_stamps(store: &FileStore, zone: &str, runs: &[Run]) -> Vec<(i64, i64)> {
361    let mut points: Vec<u64> = runs
362        .iter()
363        .flat_map(|r| [r.start as u64, (r.end - 1).max(r.start) as u64])
364        .collect();
365    let original = points.clone();
366    points.sort_unstable();
367    points.dedup();
368    let Some(stamps) = line_stamps(store, zone, &points) else {
369        return vec![(0, 0); runs.len()];
370    };
371    let at: HashMap<u64, i64> = points.into_iter().zip(stamps).collect();
372    original.chunks(2).map(|p| (at[&p[0]], at[&p[1]])).collect()
373}
374
375/// Record lines and zones for tests here and in the search tests.
376#[cfg(test)]
377pub(crate) mod fixtures {
378    use super::*;
379    use crate::backend::storage::filestore::{FileMeta, FileOpts};
380
381    pub(crate) const UID: &str = "4d2a2b8c-1111-4222-8333-944455556666";
382
383    pub(crate) fn store() -> Arc<FileStore> {
384        Arc::new(FileStore::open_in_memory().unwrap())
385    }
386
387    pub(crate) fn append(fs: &FileStore, zone: &str, lines: &[String]) {
388        if fs.stat(zone, OUTPUT_FILE).unwrap().is_none() {
389            fs.make_file(zone, OUTPUT_FILE, FileMeta::default(), FileOpts::default()).unwrap();
390        }
391        let mut data = lines.join("\n");
392        data.push('\n');
393        fs.append_data(zone, OUTPUT_FILE, data.as_bytes()).unwrap();
394    }
395
396    pub(crate) fn stamp(fs: &FileStore, zone: &str, off: u64, ms: i64) {
397        if fs.stat(zone, TSIDX_FILE).unwrap().is_none() {
398            fs.make_file(zone, TSIDX_FILE, FileMeta::default(), FileOpts::default()).unwrap();
399        }
400        fs.append_data(zone, TSIDX_FILE, format!("{{\"off\":{off},\"ms\":{ms}}}\n").as_bytes()).unwrap();
401    }
402
403    pub(crate) fn init(sid: &str, cwd: &str) -> String {
404        format!(r#"{{"type":"system","subtype":"init","cwd":"{cwd}","session_id":"{sid}","tools":[]}}"#)
405    }
406    pub(crate) fn assistant(sid: &str, text: &str) -> String {
407        format!(
408            r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"{text}"}}]}},"parent_tool_use_id":null,"session_id":"{sid}"}}"#
409        )
410    }
411    pub(crate) fn typed(text: &str) -> String {
412        format!(r#"{{"type":"user","message":{{"role":"user","content":"{text}"}}}}"#)
413    }
414    pub(crate) fn delta(sid: &str) -> String {
415        format!(
416            r#"{{"type":"stream_event","event":{{"type":"content_block_delta","delta":{{"text":"has \"session_id\":\"not-me\" inside"}}}},"session_id":"{sid}","parent_tool_use_id":null}}"#
417        )
418    }
419
420    pub(crate) fn zone() -> String {
421        format!("agent:{UID}:current")
422    }
423
424}
425
426#[cfg(test)]
427mod tests {
428    use super::fixtures::*;
429    use super::*;
430    use crate::backend::storage::filestore::{FileMeta, FileOpts};
431
432    /// Sessions come from the `session_id` each line carries; the message a
433    /// person typed (no id) belongs to the session that answered it.
434    #[test]
435    fn sessions_are_read_from_the_record_not_guessed() {
436        let fs = store();
437        append(&fs, &zone(), &[init("s1", "/work/a"), assistant("s1", "first answer"), delta("s1")]);
438        append(&fs, &zone(), &[typed("second question"), init("s2", "/work/b"), assistant("s2", "second answer")]);
439        let records = AgentRecords::new(Some(fs));
440
441        let (sessions, problems) = records.sessions_of(UID);
442        assert!(problems.is_empty(), "{problems:?}");
443        let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
444        assert_eq!(ids, vec!["s2", "s1"], "newest first");
445        assert_eq!(sessions[0].cwd.as_deref(), Some("/work/b"));
446        assert_eq!(sessions[1].cwd.as_deref(), Some("/work/a"));
447
448        let (s2, skipped) = records.read(&sessions[0]).unwrap();
449        let texts: Vec<&str> = s2.iter().map(|m| m.content.as_str()).collect();
450        assert_eq!(texts, vec!["second question", "second answer"], "the typed message is s2's");
451        assert_eq!(skipped, 0);
452        let (s1, _) = records.read(&sessions[1]).unwrap();
453        assert_eq!(s1.iter().map(|m| m.content.as_str()).collect::<Vec<_>>(), vec!["first answer"]);
454    }
455
456    /// A later search reads only what was appended; a session that continues
457    /// keeps one range.
458    #[test]
459    fn a_rescan_picks_up_what_was_appended() {
460        let fs = store();
461        append(&fs, &zone(), &[init("s1", "/w"), assistant("s1", "one")]);
462        let records = AgentRecords::new(Some(fs.clone()));
463        assert_eq!(records.sessions_of(UID).0.len(), 1);
464
465        append(&fs, &zone(), &[typed("more"), assistant("s1", "two"), init("s3", "/w"), assistant("s3", "three")]);
466        let (sessions, _) = records.sessions_of(UID);
467        let s1 = sessions.iter().find(|s| s.session_id == "s1").unwrap();
468        assert_eq!(s1.ranges.len(), 1, "s1's lines are one run");
469        let texts: Vec<String> = records.read(s1).unwrap().0.into_iter().map(|m| m.content).collect();
470        assert_eq!(texts, vec!["one", "more", "two"]);
471        assert!(sessions.iter().any(|s| s.session_id == "s3"));
472    }
473
474    /// "New conversation" archives the zone and starts it over, smaller: the
475    /// next scan reads it from the start, and the archive is still the
476    /// agent's history.
477    #[test]
478    fn an_archived_conversation_stays_and_the_new_one_is_read_afresh() {
479        let fs = store();
480        append(&fs, &zone(), &[init("old", "/w"), assistant("old", "before the archive, a long answer")]);
481        let records = AgentRecords::new(Some(fs.clone()));
482        assert_eq!(records.sessions_of(UID).0.len(), 1);
483
484        let archive = format!("agent:{UID}:archive:1790000000000");
485        let bytes = fs.read_file(&zone(), OUTPUT_FILE).unwrap().unwrap();
486        fs.make_file(&archive, OUTPUT_FILE, FileMeta::default(), FileOpts::default()).unwrap();
487        fs.append_data(&archive, OUTPUT_FILE, &bytes).unwrap();
488        fs.delete_file(&zone(), OUTPUT_FILE).unwrap();
489        append(&fs, &zone(), &[init("new", "/w")]);
490
491        let (sessions, _) = records.sessions_of(UID);
492        let mut ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
493        ids.sort();
494        assert_eq!(ids, vec!["new", "old"]);
495        let old = sessions.iter().find(|s| s.session_id == "old").unwrap();
496        assert_eq!(old.ranges[0].0, archive);
497    }
498
499    /// Only the agent's own zones: never another agent's, never a template's
500    /// shared one, whatever their names start with.
501    #[test]
502    fn only_the_agents_own_zones_are_read() {
503        assert!(is_agents_own_zone(&format!("agent:{UID}:current"), UID));
504        assert!(is_agents_own_zone(&format!("agent:{UID}:archive:1790000000000"), UID));
505        assert!(!is_agents_own_zone("agent:claude:current", UID));
506        assert!(!is_agents_own_zone(&format!("agent:{UID}0:current"), UID), "a longer id is another agent");
507        assert!(!is_agents_own_zone(&format!("agent:{UID}:archive:"), UID));
508        assert!(!is_agents_own_zone(&format!("agent-uid:{UID}:segments"), UID));
509    }
510
511    /// A line still being written stays unread until it's finished.
512    #[test]
513    fn a_half_written_line_waits_for_the_next_scan() {
514        let fs = store();
515        append(&fs, &zone(), &[init("s1", "/w")]);
516        fs.append_data(&zone(), OUTPUT_FILE, br#"{"type":"assistant","message":{"con"#).unwrap();
517        let records = AgentRecords::new(Some(fs.clone()));
518        let (sessions, _) = records.sessions_of(UID);
519        assert!(records.read(&sessions[0]).unwrap().0.is_empty());
520
521        fs.append_data(
522            &zone(),
523            OUTPUT_FILE,
524            b"tent\":[{\"type\":\"text\",\"text\":\"done\"}]},\"session_id\":\"s1\"}\n",
525        )
526        .unwrap();
527        let (sessions, _) = records.sessions_of(UID);
528        let texts: Vec<String> = records.read(&sessions[0]).unwrap().0.into_iter().map(|m| m.content).collect();
529        assert_eq!(texts, vec!["done"]);
530    }
531
532    /// Messages carry the time AgentMux received their line.
533    #[test]
534    fn messages_are_dated_from_the_timestamp_index() {
535        let fs = store();
536        let first = init("s1", "/w");
537        append(&fs, &zone(), &[first.clone()]);
538        stamp(&fs, &zone(), 0, 1_000);
539        let second_at = first.len() as u64 + 1;
540        append(&fs, &zone(), &[assistant("s1", "answer")]);
541        stamp(&fs, &zone(), second_at, 5_000);
542        let records = AgentRecords::new(Some(fs));
543        let (sessions, _) = records.sessions_of(UID);
544        assert_eq!((sessions[0].first_ms, sessions[0].last_ms), (1_000, 5_000));
545        let (messages, _) = records.read(&sessions[0]).unwrap();
546        assert_eq!(messages[0].timestamp, 5_000);
547    }
548
549    /// A line that can't be parsed is counted, not skipped in silence.
550    #[test]
551    fn an_unreadable_record_line_is_counted() {
552        let fs = store();
553        append(&fs, &zone(), &[init("s1", "/w"), "{\"type\":\"assistant\",\"mess".into(), assistant("s1", "ok")]);
554        let records = AgentRecords::new(Some(fs));
555        let (sessions, _) = records.sessions_of(UID);
556        let (messages, skipped) = records.read(&sessions[0]).unwrap();
557        assert_eq!(messages.len(), 1);
558        assert_eq!(skipped, 1);
559    }
560
561    #[test]
562    fn no_store_reads_nothing() {
563        let records = AgentRecords::new(None);
564        assert_eq!(records.sessions_of(UID).0, Vec::new());
565    }
566}