Skip to main content

forge_agent/userdata/catalog/
scan.rs

1//! Offline session scanners - stateless filesystem helpers that read
2//! transcripts from `<config_dir>/projects/<project_key>/*.jsonl`.
3//!
4//! - [`list_sessions`] - lists sessions, either for one project or all.
5//! - [`get_session_messages`] - reads the full transcript for one session.
6//!
7//! Session metadata ([`list_sessions`]) comes from an internal head +
8//! tail lite read, so the fields it parses cost two 64 KiB reads
9//! whatever the transcript's size. The worker tag is the exception and
10//! is read in full: it can sit anywhere in the file and the last one
11//! wins, so there is no window that settles it.
12
13use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
16use std::path::{Path, PathBuf};
17
18use serde_json::Value;
19use unicode_normalization::UnicodeNormalization;
20use uuid::Uuid;
21
22use forge_primitives::{
23    FORGE_WORKER_TAG_PREFIX, SDKSessionInfo, SessionHistory, SessionMessage, SessionMessageKind,
24    worker_tag,
25};
26use forge_sdk::projects_dir_for;
27
28/// True if `s` is a canonical 8-4-4-4-12 hyphenated UUID. The length
29/// guard rejects the hyphenless / braced / URN forms that
30/// `Uuid::try_parse` otherwise accepts - session ids on disk are always
31/// the hyphenated form the CLI emits.
32pub(crate) fn is_valid_uuid(s: &str) -> bool {
33    s.len() == 36 && Uuid::try_parse(s).is_ok()
34}
35
36const MAX_SANITIZED_LENGTH: usize = 200;
37
38/// Open `dir` for directory iteration. NotFound is the expected case
39/// for the catalog's projects/ tree on a fresh forge install and is
40/// silent; real I/O failures (perm denied, broken FS) log at warn
41/// so the user gets a triage signal rather than silently empty
42/// catalog reads.
43fn try_read_dir(dir: &Path) -> Option<fs::ReadDir> {
44    match fs::read_dir(dir) {
45        Ok(iter) => Some(iter),
46        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
47        Err(e) => {
48            tracing::warn!(
49                target: "forge_agent::userdata::catalog",
50                path = %dir.display(),
51                error = %e,
52                "failed to read catalog directory"
53            );
54            None
55        }
56    }
57}
58
59/// Size of the head / tail byte buffer for lite metadata reads.
60/// The CLI constant - match exactly so
61/// the two implementations slice transcripts at the same boundary.
62const LITE_READ_BUF_SIZE: u64 = 65_536;
63
64/// Crate-internal re-export of the path sanitiser - other modules need
65/// it to derive the same on-disk project-key layout the CLI uses. Not
66/// part of the public API; downstream consumers should call
67/// [`project_key_for_directory`] instead.
68pub(crate) fn sanitize_path_public(name: &str) -> String {
69    sanitize_path(name)
70}
71
72/// Map a directory path to the CLI's on-disk project key. Canonicalises
73/// the path first and then applies the CLI's JS-style sanitisation
74/// hash. `None` defaults to `"."` (the process's current working
75/// directory).
76pub fn project_key_for_directory(path: Option<&str>) -> String {
77    sanitize_path(&canonicalize_path(path.unwrap_or(".")))
78}
79
80/// Resolve a directory to its realpath and apply NFC normalisation.
81/// Wraps the CLI's `_canonicalize_path` (falls back to the input,
82/// NFC-normalised, when the path can't be canonicalised: most
83/// commonly because it doesn't exist). NFC is essential on
84/// filesystems that don't auto-normalise (Linux ext4, Windows NTFS)
85/// so decomposed inputs still hash to the CLI's on-disk
86/// project-key layout.
87fn canonicalize_path(path: &str) -> String {
88    let resolved = match fs::canonicalize(path) {
89        Ok(p) => p.to_string_lossy().into_owned(),
90        Err(_) => path.to_string(),
91    };
92    resolved.nfc().collect()
93}
94
95/// Whether this read has already kept a row under `uuid`.
96///
97/// **The key a re-appended block is deduped by.** A transcript can carry a
98/// block of records twice: the copies share `uuid`, `type` and the message
99/// content, and may differ in metadata fields (`slug`, `promptId`, a zeroed
100/// `usage`). `uuid` is what names the pair, and a row with no `uuid` names
101/// nothing to dedupe on and is never a repeat.
102fn already_kept(seen: &mut std::collections::HashSet<String>, uuid: &str) -> bool {
103    !uuid.is_empty() && !seen.insert(uuid.to_owned())
104}
105
106fn parse_session_messages<R: std::io::Read>(reader: R) -> SessionHistory {
107    let mut out = Vec::new();
108    let mut compaction_count = 0_u32;
109    let mut seen = std::collections::HashSet::new();
110    // Queued prompts held until the row that says the CLI took one. The
111    // attachment names the press; the remove row names the start, and that is
112    // where the live path drew the words.
113    let mut queued = Vec::new();
114    for (idx, line_res) in BufReader::new(reader).lines().enumerate() {
115        let line = match line_res {
116            Ok(l) => l,
117            Err(e) => {
118                // The compaction count is also truncated here, and it is
119                // rendered hidden at zero, so without naming it a torn
120                // read is indistinguishable from a session that never
121                // compacted. The transcript dir is file-sync replicated,
122                // so a torn read is a live case.
123                tracing::warn!(
124                    line_no = idx,
125                    error = %e,
126                    compactions_counted_before_truncation = compaction_count,
127                    "session scan: read failed; truncating both the message list and the compaction count"
128                );
129                break;
130            }
131        };
132        if line.is_empty() {
133            continue;
134        }
135        let value = match serde_json::from_str::<Value>(&line) {
136            Ok(v) => v,
137            Err(e) => {
138                tracing::debug!(
139                    line_no = idx,
140                    error = %e,
141                    "session scan: skipping unparseable line"
142                );
143                continue;
144            }
145        };
146        if let Some(remove) = queue_remove_row(&value) {
147            let row = queued_start_row(&mut queued, &value, remove);
148            if !already_kept(&mut seen, &row.uuid) {
149                out.push(row);
150            }
151            continue;
152        }
153        let Some((message, boundary)) = transcript_row(&value) else {
154            continue;
155        };
156        if is_queued_attachment_row(&value) {
157            queued.push(message);
158            continue;
159        }
160        if already_kept(&mut seen, &message.uuid) {
161            continue;
162        }
163        if boundary {
164            compaction_count = compaction_count.saturating_add(1);
165        }
166        out.push(message);
167    }
168    // A prompt with no remove row never started: the live path drew no row
169    // for it, and neither does the read.
170    SessionHistory { messages: out, compaction_count }
171}
172
173/// Whether a transcript line is the attachment the CLI writes for a prompt
174/// queued mid-turn.
175///
176/// **The harness's own completion notices are not these.** A background
177/// task's notice is pressed into the turn that was already running - it was
178/// never waiting in the queue - so its attachment row is its row, where the
179/// run ended. Only a prompt the CLI took moves to its remove row, at the
180/// turn's end or absorbed into the running turn.
181fn is_queued_attachment_row(value: &Value) -> bool {
182    if value.get("type").and_then(Value::as_str) != Some("attachment") {
183        return false;
184    }
185    let Some(attachment) = value.get("attachment") else { return false };
186    attachment.get("type").and_then(Value::as_str) == Some("queued_command")
187        && attachment.get("commandMode").and_then(Value::as_str) != Some("task-notification")
188}
189
190/// The removal reasons that mean the CLI TOOK the prompt, read off this
191/// machine's transcripts: `absorbed_mid_turn` (pressed into the running
192/// turn), `delivered_to_agent` (handed over at its boundary), and the empty
193/// reason on rows written before the field existed. Any other reason did not
194/// start the prompt and draws no row.
195const START_REASONS: [&str; 3] = ["absorbed_mid_turn", "delivered_to_agent", ""];
196
197/// The `queue-operation remove` line as the queued prompt's row, or `None`
198/// for any other line.
199///
200/// **The remove is the start.** The CLI writes it when it takes the prompt
201/// out of its queue - at the end of the turn that was running, or absorbed
202/// into it - so the row's clock is that removal, not the press. Only a
203/// reason in [`START_REASONS`] takes the prompt.
204fn queue_remove_row(value: &Value) -> Option<SessionMessage> {
205    if value.get("type").and_then(Value::as_str) != Some("queue-operation")
206        || value.get("operation").and_then(Value::as_str) != Some("remove")
207    {
208        return None;
209    }
210    let reason = value.get("reason").and_then(Value::as_str).unwrap_or_default();
211    if !START_REASONS.contains(&reason) {
212        return None;
213    }
214    let content = value.get("content").and_then(Value::as_str)?;
215    // A background task's notice was absorbed into the running turn, so its
216    // attachment already drew the row where the task ended; the removal is
217    // not a prompt start and draws nothing.
218    if content.trim_start().starts_with("<task-notification>") {
219        return None;
220    }
221    let attachment = serde_json::json!({ "prompt": content });
222    Some(SessionMessage {
223        kind: SessionMessageKind::User,
224        uuid: queue_row_id(value),
225        session_id: value.get("sessionId").and_then(Value::as_str).unwrap_or_default().to_owned(),
226        message: synthesize_queued_command_message(&attachment),
227        parent_tool_use_id: None,
228        timestamp: value.get("timestamp").and_then(Value::as_str).map(str::to_owned),
229        tool_use_result: None,
230        synthetic: false,
231    })
232}
233
234/// The row's id, minted from the one line an anchored read can reproduce.
235fn queue_row_id(value: &Value) -> String {
236    use std::hash::{Hash as _, Hasher as _};
237    let timestamp = value.get("timestamp").and_then(Value::as_str).unwrap_or_default();
238    let content = value.get("content").and_then(Value::as_str).unwrap_or_default();
239    let mut hasher = std::collections::hash_map::DefaultHasher::new();
240    content.hash(&mut hasher);
241    format!("queue-remove-{timestamp}-{:x}", hasher.finish())
242}
243
244/// The prompt words an attachment row's synthesised message carries.
245fn queued_prompt_text(row: &SessionMessage) -> Option<&str> {
246    row.message.get("content").and_then(Value::as_array)?.iter().find_map(|block| {
247        (block.get("type")?.as_str()? == "queued_command")
248            .then(|| block.get("prompt")?.as_str())
249            .flatten()
250    })
251}
252
253/// The row a remove line draws, taking the attachment that named the press if
254/// one is waiting.
255///
256/// The attachment is what carries a multi-modal prompt's blocks, its
257/// `commandMode` and the `source_uuid` the live row pairs with; the remove
258/// line contributes the position, the clock and the id.
259fn queued_start_row(
260    queued: &mut Vec<SessionMessage>,
261    remove: &Value,
262    mut row: SessionMessage,
263) -> SessionMessage {
264    let content = remove.get("content").and_then(Value::as_str).unwrap_or_default();
265    // **Exact words only.** A single pending attachment is not a safe guess:
266    // two prompts can be in play, and pairing by position would draw one
267    // prompt's words under another prompt's start.
268    let at = queued.iter().position(|held| queued_prompt_text(held) == Some(content));
269    if let Some(at) = at {
270        let attachment = queued.remove(at);
271        row.message = attachment.message;
272        row.parent_tool_use_id = attachment.parent_tool_use_id;
273        row.synthetic = attachment.synthetic;
274        if row.session_id.is_empty() {
275            row.session_id = attachment.session_id;
276        }
277    }
278    row
279}
280
281/// Whether a transcript row carries this frame.
282///
283/// **The row rule's other half, and the one place it is stated.** A frame
284/// whose kind no row round-trips - a `Result`, a thinking-token frame, a
285/// `system` subtype whose row belongs to another surface - is one the live
286/// stream emits and the CLI never writes, so a copy holding it counts a frame
287/// the file counts no row for. That difference is what a page below the floor
288/// has to be numbered through, by whoever still holds the frames the drops
289/// took.
290///
291/// **A live hook frame has no row, though the read now serves hook rows.** The
292/// CLI mints three ids for one run - the attachment row's uuid, the live
293/// `hook_id` and the live frame's uuid - and none of them equals another, so a
294/// live hook frame names no row a walk could resolve; the row the read
295/// synthesizes is a copy of the run, not the frame. The answer stays false,
296/// which is what keeps a stop off a frame the file cannot be asked for.
297pub fn has_a_transcript_row(message: &forge_primitives::Message) -> bool {
298    use forge_primitives::Message;
299    // The row rule drops a sub-agent's frame, so a frame naming one has no
300    // row either, whatever its kind: a numbering that counted it as having
301    // one would be off for every page below it.
302    let parent = match message {
303        Message::User { parent_tool_use_id, .. }
304        | Message::Assistant { parent_tool_use_id, .. }
305        | Message::StopHookSummary { parent_tool_use_id, .. } => parent_tool_use_id.as_deref(),
306        // The session task stamps a sub-agent's refusal with the dispatch it
307        // belongs under, and the row rule drops it like any parented frame.
308        Message::PermissionDenied { extras, .. } => {
309            extras.get("parent_tool_use_id").and_then(Value::as_str)
310        }
311        _ => None,
312    };
313    if forge_primitives::names_a_dispatch(parent) {
314        return false;
315    }
316    match message {
317        Message::User { .. }
318        | Message::Assistant { .. }
319        | Message::StopHookSummary { .. }
320        | Message::CompactBoundary { .. }
321        | Message::PermissionDenied { .. }
322        | Message::Notification { .. }
323        // Typed system subtypes whose rows the read keeps: the conversation
324        // draws a turn duration, and a dispatch's progress is read off the
325        // row the scan keeps, so the numbering must count it.
326        | Message::TurnDuration { .. }
327        | Message::TaskProgress { .. } => true,
328        // A generic `system` row has a row unless its subtype is one whose
329        // row is drawn elsewhere: the read keeps the set the client draws.
330        Message::System { subtype, .. } => {
331            !SYSTEM_ROWS_DRAWN_ELSEWHERE.contains(&subtype.as_str())
332        }
333        // An unknown top-level frame has a row unless its type is one of the
334        // transcript's own bookkeeping kinds, which no live stream emits.
335        Message::Unknown { type_str, .. } => !UNSERVED_ROWS.contains(&type_str.as_str()),
336        _ => false,
337    }
338}
339
340/// The `system` subtypes whose row is not the conversation's: the task
341/// lifecycle lands on its call's row, the background-task and command lists
342/// feed their own surfaces, the handshake is not content, and a hook run
343/// draws from its attachment row. **The client filters the same list** (its
344/// own `SYSTEM_DRAWN_ELSEWHERE`), because the read and the live path are one
345/// obligation - a subtype added here belongs there, and the other way round.
346const SYSTEM_ROWS_DRAWN_ELSEWHERE: [&str; 11] = [
347    "task_started",
348    "task_updated",
349    "task_notification",
350    "background_tasks_changed",
351    "commands_changed",
352    "init",
353    "status",
354    "thinking_tokens",
355    "hook_started",
356    "hook_progress",
357    "hook_response",
358];
359
360/// Top-level rows the read does not serve, of two kinds: the transcript's
361/// own bookkeeping, which the CLI writes to disk and never streams, and the
362/// wire types that live only on the stream and have no durable row.
363///
364/// **A row not named here is an unknown type, and draws as itself.** These
365/// are the types the read has always dropped, so keeping one would change
366/// what a reopened conversation holds rather than restore what a live one
367/// drew.
368const UNSERVED_ROWS: [&str; 18] = [
369    // Bookkeeping the CLI writes and never streams.
370    "last-prompt",
371    "atis-latch",
372    "queue-operation",
373    "worktree-state",
374    "mode",
375    "pr-link",
376    "tag",
377    "permission-mode",
378    "cost-state",
379    "ai-title",
380    "file-history-snapshot",
381    "summary",
382    // Wire-only kinds: a row of one of these is a fixture's or an artefact's,
383    // not something the CLI persists.
384    "result",
385    "error",
386    "rate_limit_event",
387    "command_lifecycle",
388    "stream_event",
389    "tool_progress",
390];
391
392/// One transcript row as the session's message, or `None` for a row that is
393/// not one. The flag says the row is a compaction boundary, which the read
394/// counts.
395///
396/// **The one row rule, shared by every read of a transcript.** A resume walks
397/// the whole file and the paging span read walks a window of it, and a second
398/// copy of this is how the two would come to type the same row differently.
399fn transcript_row(value: &Value) -> Option<(SessionMessage, bool)> {
400    let row_type = value.get("type").and_then(Value::as_str);
401    let mut boundary = false;
402    let (kind, message) = match row_type {
403        Some("user") => (SessionMessageKind::User, value.get("message").cloned()),
404        Some("assistant") => (SessionMessageKind::Assistant, value.get("message").cloned()),
405        // Attachment rows hold claude's persisted record of mid-turn
406        // queued inputs - `{"type":"attachment", "attachment":{"type":
407        // "queued_command", "prompt":"...", "commandMode":"prompt"}}`.
408        // On replay we hoist them into a synthetic user envelope
409        // whose single content block is the `queued_command`, so the
410        // downstream walker reconstructs the user bubble that was
411        // never on the wire as a regular user message.
412        Some("attachment") => match value.get("attachment") {
413            Some(att) if att.get("type").and_then(Value::as_str) == Some("queued_command") => {
414                (SessionMessageKind::User, Some(synthesize_queued_command_message(att)))
415            }
416            // **A hook's own record is the row its live frames drew.** The
417            // live stream sends a run as `system/hook_*` frames and the
418            // transcript persists it as an attachment; dropping the row left a
419            // page without rows the live conversation drew (rule 25), and
420            // every later landing reconciled against a copy that had none of
421            // them.
422            //
423            // **The served set is the live wire's**: the SessionStart and
424            // Setup runs it gates live hook frames on, in their success,
425            // cancelled and non-blocking-error records. Every other event's
426            // rows stay the CLI's own context, and `hook_additional_context`
427            // is not a run at all - the live path drew none of them as a row,
428            // so a read that did would show a reader rows they never had.
429            Some(att) if is_hook_attachment(att) => {
430                (SessionMessageKind::System, Some(synthesize_hook_frame(value, att)))
431            }
432            _ => return None,
433        },
434        // The only durable record of how often this session has compacted
435        // and of where each cut fell - nothing else survives a resume.
436        // Counted by the caller, and the row is kept so a resumed conversation
437        // draws its boundaries the way a live one does.
438        Some("system")
439            if value.get("subtype").and_then(Value::as_str) == Some("compact_boundary") =>
440        {
441            boundary = true;
442            // Keyed on what the row yielded rather than on the metadata
443            // object being there: the plausible drift is a rename inside it
444            // (`preTokens`, per the primitives test), which leaves the
445            // object present and one field unread - a row drawn without
446            // that fact, and nothing else saying why. The live arm warns on
447            // the same degradation. Every modelled field is read here, so a
448            // rename of any one of them lands in this record.
449            let missing = missing_boundary_facts(value);
450            if !missing.is_empty() {
451                let uuid = value.get("uuid").and_then(Value::as_str).unwrap_or_default();
452                let session = session_in_row(value);
453                tracing::warn!(
454                    target: "forge_agent::userdata::catalog",
455                    event_name = "compact_boundary_missing_facts",
456                    uuid,
457                    session,
458                    missing = %missing.join(", "),
459                    "compact_boundary row did not yield every fact; counted, but the kept frame arrives untyped",
460                );
461            }
462            (SessionMessageKind::System, Some(compact_boundary_frame(value)))
463        }
464        // What the turn's hooks did. A system row is the frame the wire
465        // sends, so it is kept whole; the one field the frame's decoder
466        // spells differently is the session.
467        Some("system")
468            if value.get("subtype").and_then(Value::as_str) == Some("stop_hook_summary") =>
469        {
470            (SessionMessageKind::System, Some(wire_session_frame(value)))
471        }
472        // **The system rows the live conversation draws.** A refusal, a
473        // failed API call, an informational line, a notification and any
474        // other subtype whose row is the conversation's are each a line the
475        // session was told and the CLI persists, and the scan dropped every
476        // system row but the two above - so a resumed seat or a reopened page
477        // lost frames the live view had (rule 25). Kept whole, in the shape
478        // the wire sends, with the wire's own spelling of the session stamped
479        // on. A row whose subtype draws elsewhere keeps dropping here, the
480        // same list the client filters.
481        Some("system")
482            if value
483                .get("subtype")
484                .and_then(Value::as_str)
485                .is_none_or(|subtype| !SYSTEM_ROWS_DRAWN_ELSEWHERE.contains(&subtype)) =>
486        {
487            (SessionMessageKind::System, Some(wire_session_frame(value)))
488        }
489        // **A top-level row this build has no kind for draws as itself.** The
490        // row is kept whole and decoded by the same decoder the wire feeds -
491        // `messages_from_rows` presents it under the `system` arm, the one
492        // that hands a frame straight to `Message`. The rows the read has
493        // never served stay out (see [`UNSERVED_ROWS`]).
494        Some(other) if other != "system" && !UNSERVED_ROWS.contains(&other) => {
495            (SessionMessageKind::System, Some(value.clone()))
496        }
497        _ => return None,
498    };
499    // The same rule the fold reads a frame by: a parent id that names a
500    // dispatch, which on the wire is never empty and never null.
501    if value
502        .get("parent_tool_use_id")
503        .and_then(Value::as_str)
504        .is_some_and(|parent| !parent.trim().is_empty())
505    {
506        return None;
507    }
508    let uuid = value.get("uuid").and_then(Value::as_str).unwrap_or_default().to_string();
509    let sess = session_in_row(value);
510    let timestamp = value.get("timestamp").and_then(Value::as_str).map(str::to_owned);
511    // The CLI's own stamps that nobody typed this row. They do not
512    // co-occur - the reminder carries `isMeta` and `turnCompanion`, a
513    // compaction summary carries `isCompactSummary` and
514    // `isVisibleInTranscriptOnly` and no `isMeta` - and the wire writes
515    // the whole set as one `isSynthetic`, so reading a subset here is
516    // what let a summary be the reader's turn on resume and the harness's
517    // line live.
518    let synthetic = ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly", "turnCompanion"]
519        .iter()
520        .any(|flag| value.get(flag).and_then(Value::as_bool).unwrap_or(false));
521    Some((
522        SessionMessage {
523            kind,
524            uuid,
525            session_id: sess,
526            message: message.unwrap_or(Value::Null),
527            parent_tool_use_id: None,
528            timestamp,
529            tool_use_result: value.get("toolUseResult").filter(|result| !result.is_null()).cloned(),
530            synthetic,
531        },
532        boundary,
533    ))
534}
535
536/// The session a transcript row belongs to. The wire spells it
537/// `session_id`; a row the CLI wrote spells it `sessionId`.
538fn session_in_row(value: &Value) -> String {
539    value
540        .get("session_id")
541        .or_else(|| value.get("sessionId"))
542        .and_then(Value::as_str)
543        .unwrap_or_default()
544        .to_owned()
545}
546
547/// A kept row carrying the session under the spelling the live frame sends.
548///
549/// **The CLI writes `sessionId` and the wire sends `session_id`.** Stamping
550/// the wire's spelling onto a clone that still held the CLI's put both in the
551/// frame, so the raw a reader opens showed the session twice (Ved, 2026-10-28).
552fn wire_session_frame(value: &Value) -> Value {
553    let mut frame = value.clone();
554    if let Some(record) = frame.as_object_mut() {
555        record.remove("sessionId");
556        record.insert("session_id".into(), Value::String(session_in_row(value)));
557    }
558    frame
559}
560
561/// Which of the facts the kept boundary frame reads the row did not yield -
562/// the trigger, and the counts either side of the cut.
563///
564/// Read off the same three fields `compact_boundary_frame` builds from, and
565/// named rather than counted because the record built from this is the only
566/// thing that would say which disk key moved.
567fn missing_boundary_facts(value: &Value) -> Vec<&'static str> {
568    let metadata = value.get("compactMetadata");
569    let read = |key: &str| metadata.and_then(|metadata| metadata.get(key));
570    [
571        ("trigger", read("trigger").and_then(Value::as_str).is_none()),
572        ("preTokens", read("preTokens").and_then(Value::as_u64).is_none()),
573        ("postTokens", read("postTokens").and_then(Value::as_u64).is_none()),
574    ]
575    .into_iter()
576    .filter_map(|(fact, absent)| absent.then_some(fact))
577    .collect()
578}
579
580/// The boundary row in the shape the wire sends.
581///
582/// A transcript spells its metadata flat and camelCase (`compactMetadata`,
583/// `preTokens`), while the decoder keys on the wire's nesting - so a row handed
584/// on as read decodes as a generic system frame, and draws bare or half-filled
585/// where a live boundary draws its counts. The row's own stamp rides with it:
586/// the frame is built here rather than copied, so this is where a page's clock
587/// would lose the one fact only the file has.
588fn compact_boundary_frame(value: &Value) -> Value {
589    let metadata = value.get("compactMetadata");
590    let field =
591        |key: &str| metadata.and_then(|metadata| metadata.get(key)).cloned().unwrap_or(Value::Null);
592    let mut frame = serde_json::json!({
593        "type": "system",
594        "subtype": "compact_boundary",
595        "session_id": session_in_row(value),
596        "uuid": value.get("uuid").and_then(Value::as_str).unwrap_or_default(),
597        "compact_metadata": {
598            "trigger": field("trigger"),
599            "pre_tokens": field("preTokens"),
600            "post_tokens": field("postTokens"),
601        },
602    });
603    if let Some(timestamp) = value.get("timestamp") {
604        frame["timestamp"] = timestamp.clone();
605    }
606    frame
607}
608
609/// Build a `{"role":"user","content":[{queued_command}]}` envelope from
610/// a JSONL attachment row's `attachment` field. Used during session
611/// replay to surface mid-turn queued inputs that claude persisted as
612/// `type:"attachment"` rows (which the scanner would otherwise skip).
613fn synthesize_queued_command_message(attachment: &Value) -> Value {
614    let mut block = serde_json::Map::new();
615    block.insert("type".into(), Value::String("queued_command".into()));
616    if let Some(prompt) = attachment.get("prompt") {
617        block.insert("prompt".into(), prompt.clone());
618    }
619    if let Some(mode) = attachment.get("commandMode") {
620        block.insert("commandMode".into(), mode.clone());
621    }
622    if let Some(src) = attachment.get("source_uuid") {
623        block.insert("source_uuid".into(), src.clone());
624    }
625    serde_json::json!({
626        "role": "user",
627        "content": [Value::Object(block)],
628    })
629}
630
631/// The hook events whose runs the live stream forwards, and therefore the only
632/// ones a read serves as rows.
633///
634/// **The CLI gates live hook frames on these outside remote.** A page that
635/// drew any other event would show rows the live view never had - a divergence
636/// the read would introduce rather than repair - so the rest of the file's
637/// hook rows stay context the CLI keeps for itself.
638fn live_hook_event(attachment: &Value) -> bool {
639    matches!(attachment.get("hookEvent").and_then(Value::as_str), Some("SessionStart" | "Setup"))
640}
641
642/// Whether an attachment row records a hook run the live stream forwards.
643///
644/// The persisted run records are the success, the cancelled run and the
645/// non-blocking error; `hook_additional_context` is aggregate context rather
646/// than a run, and the live path drew it as none.
647fn is_hook_attachment(attachment: &Value) -> bool {
648    live_hook_event(attachment)
649        && matches!(
650            attachment.get("type").and_then(Value::as_str),
651            Some("hook_success" | "hook_cancelled" | "hook_non_blocking_error")
652        )
653}
654
655/// One persisted hook run as the live frame the chat folds.
656///
657/// **The two carriers' ids never meet.** The CLI stamps the row a uuid of its
658/// own, the live run a `hook_id` and the live frame another uuid - three
659/// distinct ids for one run - so a read copy that borrowed any of them would
660/// still name nothing the held frame names. The row keeps its own uuid, and
661/// `transcript` marks it as the read's copy: the client pairs it with a held
662/// live run of the same event and name, and keeps one row.
663fn synthesize_hook_frame(row: &Value, attachment: &Value) -> Value {
664    let mut frame = serde_json::Map::new();
665    frame.insert("type".into(), Value::String("system".into()));
666    frame.insert("subtype".into(), Value::String("hook_response".into()));
667    if let Some(id) = row.get("uuid") {
668        frame.insert("hook_id".into(), id.clone());
669        frame.insert("uuid".into(), id.clone());
670    }
671    for (field, from) in [("hook_name", "hookName"), ("hook_event", "hookEvent")] {
672        if let Some(held) = attachment.get(from) {
673            frame.insert(field.into(), held.clone());
674        }
675    }
676    // **The live frame's own fields, all of them.** `HookResponse` in the
677    // primitives requires every one, and a cancelled row carries none of the
678    // stream fields at all: a frame that omitted them would decode as the
679    // generic system shape rather than the typed response the live wire sends.
680    // Absent ones are the empty value the live frame's own type carries.
681    frame.insert("output".into(), Value::String(hook_output(attachment).unwrap_or_default()));
682    for field in ["stdout", "stderr"] {
683        let held = attachment.get(field).and_then(Value::as_str).unwrap_or_default();
684        frame.insert(field.into(), Value::String(held.to_owned()));
685    }
686    let code = attachment.get("exitCode").and_then(Value::as_i64).unwrap_or(0);
687    frame.insert("exit_code".into(), Value::Number(code.into()));
688    frame.insert("outcome".into(), hook_outcome(attachment));
689    frame.insert("session_id".into(), Value::String(session_in_row(row)));
690    frame.insert("transcript".into(), Value::Bool(true));
691    Value::Object(frame)
692}
693
694/// The outcome the live frame reports for one persisted run record.
695///
696/// A timeout is persisted as a cancellation with `timedOut`, and draws as a
697/// cancelled run; a non-blocking error is the error outcome the live path
698/// sends for it.
699fn hook_outcome(attachment: &Value) -> Value {
700    let outcome = match attachment.get("type").and_then(Value::as_str) {
701        Some("hook_non_blocking_error") => "error",
702        Some("hook_cancelled") => "cancelled",
703        _ => "success",
704    };
705    Value::String(outcome.to_owned())
706}
707
708/// What one persisted hook run printed, as a `hook_response` frame carries it.
709///
710/// `content` is the combined output, a string or the lines of one; the raw
711/// streams are the fallback, `stdout` first, so a row whose combined field is
712/// empty still draws what the hook wrote. Both branches read the same way: a
713/// value that is only whitespace is nothing to draw.
714fn hook_output(attachment: &Value) -> Option<String> {
715    let held = match attachment.get("content") {
716        Some(Value::String(text)) => Some(text.clone()),
717        Some(Value::Array(lines)) => {
718            Some(lines.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("\n"))
719        }
720        _ => None,
721    };
722    held.into_iter()
723        .chain(
724            ["stdout", "stderr"].iter().filter_map(|field| {
725                attachment.get(field).and_then(Value::as_str).map(str::to_owned)
726            }),
727        )
728        .find(|text| !text.trim().is_empty())
729}
730
731/// Sanitise a path the same way the `claude` CLI does -
732/// non-alphanumerics become hyphens, and overlong paths are
733/// truncated with a base-36 hash suffix (matching JS's
734/// `String.prototype.hashCode` trick).
735fn sanitize_path(name: &str) -> String {
736    let sanitized: String =
737        name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
738    if sanitized.len() <= MAX_SANITIZED_LENGTH {
739        return sanitized;
740    }
741    let hash = simple_hash(name);
742    let truncated: String = sanitized.chars().take(MAX_SANITIZED_LENGTH).collect();
743    format!("{truncated}-{hash}")
744}
745
746/// 32-bit integer hash to base-36, matching the CLI's directory naming.
747fn simple_hash(s: &str) -> String {
748    let mut h: i64 = 0;
749    for ch in s.chars() {
750        let c = ch as i64;
751        h = (h << 5).wrapping_sub(h).wrapping_add(c);
752        // Emulate JS `hash |= 0` (coerce to 32-bit signed int)
753        h &= 0xFFFF_FFFF;
754        if h >= 0x8000_0000 {
755            h -= 0x1_0000_0000;
756        }
757    }
758    let mut n = h.unsigned_abs();
759    if n == 0 {
760        return "0".into();
761    }
762    let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
763    let mut out = Vec::new();
764    while n > 0 {
765        out.push(digits[(n % 36) as usize]);
766        n /= 36;
767    }
768    out.reverse();
769    String::from_utf8(out).unwrap_or_default()
770}
771
772fn project_dir_for(config_dir: &Path, project_path: &str) -> PathBuf {
773    projects_dir_for(config_dir).join(sanitize_path(&canonicalize_path(project_path)))
774}
775
776/// Bounded-concurrency cap for [`list_sessions`] per-file lite reads.
777///
778/// Each transcript costs an open, a head read, a tail seek, and a tail
779/// read, plus UTF-8 validation. On a project-rich install (~50 projects,
780/// ~10 sessions each) the serial walk dominates connect time; capping
781/// at 16 keeps fd pressure low while still saturating an SSD.
782const LIST_SESSIONS_MAX_CONCURRENT: usize = 16;
783
784/// True when `info` represents a worker session: one whose transcript
785/// carries a `forge:worker:<label>` tag.
786pub fn should_exclude_worker_tag(info: &SDKSessionInfo) -> bool {
787    info.tag.as_deref().is_some_and(|t| t.starts_with(FORGE_WORKER_TAG_PREFIX))
788}
789
790/// Which worker sessions a listing may carry.
791///
792/// The seat decides, not the directory: a non-git worker runs in the
793/// project root, so its own sessions sit in the same directory as its
794/// lead's.
795#[derive(Debug, Clone, PartialEq, Eq)]
796pub enum Workers {
797    /// None. A lead's listing is the project's own sessions and never
798    /// its workers', and the boot catalog wants the same.
799    Hidden,
800    /// Only the transcripts tagged for `label`: what a worker ran under
801    /// its own label is what it can resume.
802    Only(String),
803}
804
805/// List sessions. When `directory` is `Some`, scans that project dir;
806/// when `None`, scans every project directory under `config_dir`'s
807/// `projects/` tree. A capped listing stats each candidate for its mtime,
808/// reads newest first on the tokio blocking pool (at most 16 at once), and
809/// stops once the rows the cap serves are in hand - a short batch is filled
810/// by the next, so the cap bounds the reads without shortening the listing.
811/// An uncapped listing (the boot scan) reads every candidate. `workers`
812/// decides which worker-tagged rows may appear, and it lands before the
813/// cap: a listing is never shortened by rows it would not have carried.
814///
815/// # Panics
816///
817/// Never - filesystem errors fall through and produce an empty Vec.
818///
819/// `tag_cache` carries the previous run's tag scans so an unchanged
820/// transcript is not re-read end to end. `None` reads every byte of
821/// every file, which is what a caller with no store must do. A worker's
822/// listing also skips the READ of a transcript that cache tags for another
823/// seat: its tag cannot match, and the head-and-tail read is the cost a
824/// non-git worker on a large project paid on every seat open (#2567).
825pub async fn list_sessions(
826    config_dir: &Path,
827    directory: Option<&str>,
828    limit: Option<usize>,
829    offset: usize,
830    workers: Workers,
831    tag_cache: Option<&std::sync::Arc<SessionTagCache>>,
832) -> Vec<SDKSessionInfo> {
833    let search_dirs: Vec<PathBuf> = if let Some(dir) = directory {
834        vec![project_dir_for(config_dir, dir)]
835    } else {
836        try_read_dir(&projects_dir_for(config_dir))
837            .map(|iter| {
838                iter.flatten()
839                    .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
840                    .map(|e| e.path())
841                    .collect()
842            })
843            .unwrap_or_default()
844    };
845
846    // Cheap directory walks first - collect every candidate path
847    // synchronously. The expensive part is the per-file lite read,
848    // which we hand off to spawn_blocking below.
849    let mut candidates: Vec<PathBuf> = Vec::new();
850    for project_dir in search_dirs {
851        let Some(iter) = try_read_dir(&project_dir) else {
852            continue;
853        };
854        for entry in iter.flatten() {
855            let path = entry.path();
856            if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
857                candidates.push(path);
858            }
859        }
860    }
861
862    // **A worker's listing does not read what its own tag already rules out**
863    // (#2567). The head-and-tail read of every transcript in the directory is
864    // the whole cost of a `/resume` list on a non-git worker, which shares
865    // the project root with its lead; an unchanged file the cache tags for
866    // another seat (or for none) is dropped here instead. The answer is the
867    // same rows either way, so the skip names itself on the log.
868    if let (Workers::Only(label), Some(cache)) = (&workers, tag_cache) {
869        let wanted = worker_tag(label);
870        let before = candidates.len();
871        // `None` is a tag this cache cannot vouch for, and that file is read.
872        candidates.retain(|path| cache.carries_tag(path, &wanted).unwrap_or(true));
873        if before > candidates.len() {
874            // **No explicit target: the shipped directive names this module's
875            // own path**, and a record under another target is filtered out
876            // before it ever reaches `forge.log` (#2567).
877            tracing::debug!(
878                event_name = "session_scan_narrowed",
879                dir = directory.unwrap_or("<every project>"),
880                label = %label,
881                skipped = before - candidates.len(),
882                to_read = candidates.len(),
883                "transcripts whose cached tag rules them out were not read",
884            );
885        }
886    }
887
888    // **A capped listing reads newest first and stops when it is full**
889    // (#2567). Reading every candidate and cutting afterwards made a
890    // long-lived worker label pay a head-and-tail read per file however few
891    // rows it served; the stat here is the mtime the final order already
892    // reads, so nothing is paid for an order the answer did not need.
893    let mut ordered: Vec<(PathBuf, u64)> = candidates
894        .into_iter()
895        .map(|path| {
896            let mtime = std::fs::metadata(&path)
897                .and_then(|meta| meta.modified())
898                .ok()
899                .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
900                .map_or(0, |age| u64::try_from(age.as_millis()).unwrap_or(u64::MAX));
901            (path, mtime)
902        })
903        .collect();
904    ordered.sort_by_key(|(path, mtime)| (std::cmp::Reverse(*mtime), path.clone()));
905
906    let wanted_tag = match &workers {
907        Workers::Only(label) => Some(worker_tag(label)),
908        Workers::Hidden => None,
909    };
910    let matches = |info: &SDKSessionInfo| match &wanted_tag {
911        Some(wanted) => info.tag.as_deref() == Some(wanted.as_str()),
912        None => !should_exclude_worker_tag(info),
913    };
914    // `None` is no cap: the boot scan reads the whole tree, which is what
915    // its catalog is.
916    let wanted_rows = limit.map(|l| offset.saturating_add(l));
917
918    let mut entries: Vec<SDKSessionInfo> = Vec::new();
919    let mut paths = ordered.into_iter();
920    let mut set: tokio::task::JoinSet<Option<SDKSessionInfo>> = tokio::task::JoinSet::new();
921    let spawn_read = |set: &mut tokio::task::JoinSet<Option<SDKSessionInfo>>, path: PathBuf| {
922        let cache = tag_cache.cloned();
923        set.spawn_blocking(move || read_session_info(&path, cache.as_deref()));
924    };
925    // **The reads stay pipelined, and the cap is counted rather than
926    // re-counted.** The boot scan is uncapped and reads the whole tree, so
927    // recounting the kept rows on every pass would multiply its cost by the
928    // number of batches, and draining the set before refilling would leave
929    // the pool idle between them.
930    let mut kept = 0usize;
931    loop {
932        let owed = wanted_rows.map_or(usize::MAX, |wanted| wanted.saturating_sub(kept));
933        if owed == 0 {
934            break;
935        }
936        // **A batch is no wider than the rows still owed**, so a cap reads
937        // the cap and not a pool's width past it. A short batch - a
938        // transcript gone, or one the seat's own tag rules out - is filled by
939        // the next, so the read is bounded without the listing coming up
940        // short.
941        let width = owed.min(LIST_SESSIONS_MAX_CONCURRENT);
942        while set.len() < width {
943            let Some((path, _)) = paths.next() else { break };
944            spawn_read(&mut set, path);
945        }
946        let Some(res) = set.join_next().await else { break };
947        match res {
948            Ok(Some(info)) => {
949                if matches(&info) {
950                    kept += 1;
951                }
952                entries.push(info);
953            }
954            Ok(None) => {}
955            Err(err) => {
956                tracing::debug!(
957                    target: "forge_agent::catalog::scan",
958                    error = %err,
959                    "list_sessions: per-file read task failed",
960                );
961            }
962        }
963    }
964    if let Some(wanted) = wanted_rows {
965        tracing::debug!(
966            event_name = "session_scan_read",
967            dir = directory.unwrap_or("<every project>"),
968            read = entries.len(),
969            wanted,
970            "the listing read the rows its cap serves, newest first",
971        );
972    }
973
974    entries.sort_by_key(|e| std::cmp::Reverse(e.last_modified));
975    let entries: Vec<SDKSessionInfo> = entries.into_iter().filter(|info| matches(info)).collect();
976    let end = limit.map_or(entries.len(), |l| offset.saturating_add(l));
977    entries.into_iter().skip(offset).take(end.saturating_sub(offset)).collect()
978}
979
980/// Tail shared by every `get_session_messages` bail. The compaction count
981/// these paths return is unknown rather than zero, and the Projects-pane
982/// row hides at zero, so "count says 0 on a session I know compacted"
983/// has no triage path unless each bail is greppable.
984const UNREAD_TRANSCRIPT_COUNT_NOTE: &str =
985    "transcript unread, so the compaction count is unknown rather than zero";
986
987/// Read the full transcript for one session.
988///
989/// Returns a default [`SessionHistory`] when the file can't be found or
990/// opened - no messages and a **zero** compaction count, which is
991/// indistinguishable from a session that never compacted. Each of those
992/// paths logs `UNREAD_TRANSCRIPT_COUNT_NOTE` so the difference is
993/// recoverable from the log even though it is not from the return value.
994pub fn get_session_messages(
995    config_dir: &Path,
996    session_id: &str,
997    directory: Option<&str>,
998) -> SessionHistory {
999    read_session_messages(config_dir, session_id, directory, true)
1000}
1001
1002/// [`get_session_messages`] over the session's own rows only: no sub-agent
1003/// sidecars are opened or folded. The handoff brief takes this one, so it
1004/// neither pays for the sidecar parse nor carries frames it does not read. A
1005/// page read does not: a dispatch row draws its sidecar frames (#1763).
1006pub(crate) fn read_session_messages_main_thread(
1007    config_dir: &Path,
1008    session_id: &str,
1009    directory: Option<&str>,
1010) -> SessionHistory {
1011    read_session_messages(config_dir, session_id, directory, false)
1012}
1013
1014fn read_session_messages(
1015    config_dir: &Path,
1016    session_id: &str,
1017    directory: Option<&str>,
1018    fold_sidecars: bool,
1019) -> SessionHistory {
1020    if !is_valid_uuid(session_id) {
1021        tracing::warn!(
1022            %session_id,
1023            "get_session_messages: not a session uuid; {UNREAD_TRANSCRIPT_COUNT_NOTE}",
1024        );
1025        return SessionHistory::default();
1026    }
1027    let file_name = format!("{session_id}.jsonl");
1028    let candidate = if let Some(dir) = directory {
1029        Some(project_dir_for(config_dir, dir).join(&file_name))
1030    } else {
1031        try_read_dir(&projects_dir_for(config_dir)).and_then(|iter| {
1032            iter.flatten().map(|e| e.path().join(&file_name)).find(|p| p.is_file())
1033        })
1034    };
1035    let Some(path) = candidate else {
1036        tracing::debug!(
1037            %session_id,
1038            "get_session_messages: no on-disk file for session; {UNREAD_TRANSCRIPT_COUNT_NOTE}",
1039        );
1040        return SessionHistory::default();
1041    };
1042    let file = match fs::File::open(&path) {
1043        Ok(file) => file,
1044        Err(err) => {
1045            tracing::warn!(
1046                %session_id,
1047                path = %path.display(),
1048                %err,
1049                "get_session_messages: open failed; backfill renders empty and {UNREAD_TRANSCRIPT_COUNT_NOTE}",
1050            );
1051            return SessionHistory::default();
1052        }
1053    };
1054    let mut history = parse_session_messages(file);
1055    if fold_sidecars {
1056        let (sidecars, links) = subagent_sidecars(session_id, &path);
1057        fold_subagent_sidecars(&mut history.messages, sidecars, &links, session_id);
1058    }
1059    history
1060}
1061
1062/// Insert each dispatch's sidecar frames under the first row its chain
1063/// answers. Appending them at the end would put them at the newest edge of
1064/// the resumed window, ahead of the main rows.
1065///
1066/// A nested dispatch is named by a call inside its parent's sidecar, never by
1067/// the transcript, so its chain is walked to the first dispatch the transcript
1068/// answers and every row is stamped with that card. Resolution happens before
1069/// any splice, which is what makes the child independent of the parent's read
1070/// order; a block whose parent body was read then emits inside the row that
1071/// fired it, and one whose parent was not read follows the owner's anchor.
1072fn fold_subagent_sidecars(
1073    messages: &mut Vec<SessionMessage>,
1074    sidecars: Vec<(String, Vec<SessionMessage>)>,
1075    links: &HashMap<String, SidecarLink>,
1076    session_id: &str,
1077) {
1078    // Every call an agent's sidecar holds was made under that sidecar's own
1079    // dispatch: the CLI writes no parent into a sidecar row. The metas carry
1080    // the same link without the parent's body, which is what keeps a nested
1081    // sidecar resolvable when its parent fell past the read budget.
1082    let mut made_under: HashMap<String, String> = HashMap::new();
1083    for (dispatch, rows) in &sidecars {
1084        for row in rows {
1085            for id in tool_use_ids(row) {
1086                made_under.insert(id.to_owned(), dispatch.clone());
1087            }
1088        }
1089    }
1090    for link in links.values() {
1091        let Some(parent) = link.parent_agent.as_deref() else {
1092            continue;
1093        };
1094        let Some(parent_link) = links.get(parent) else {
1095            continue;
1096        };
1097        made_under.insert(link.dispatch.clone(), parent_link.dispatch.clone());
1098    }
1099    // Only a transcript row anchors a block; a sidecar folded into another
1100    // sidecar's rows would nest an expansion inside an expansion. Tool-use
1101    // ids are unique per call, so a spliced row cannot answer another
1102    // sidecar's dispatch.
1103    let answered: HashSet<String> =
1104        messages.iter().flat_map(answered_dispatches).map(str::to_owned).collect();
1105    // Group by owner, then splice each owner's tree in the order it ran: a
1106    // nested block emits at the row carrying its dispatch, so a depth band
1107    // cannot flatten it. Nesting rides the tool_use ids; only roots follow
1108    // arrival order.
1109    let mut folded: HashMap<String, Vec<Block>> = HashMap::new();
1110    for (dispatch, mut rows) in sidecars {
1111        let Some(owner) = owning_dispatch(&dispatch, &answered, &made_under) else {
1112            // No row of the transcript answers the dispatch or any dispatch
1113            // it was nested under: there is no expansion for these frames.
1114            tracing::debug!(
1115                event_name = "subagent_sidecar_unresolved",
1116                session = %session_id,
1117                dispatch = %dispatch,
1118                "subagent sidecar skipped: no dispatch in its chain is answered by the transcript",
1119            );
1120            continue;
1121        };
1122        for row in &mut rows {
1123            row.parent_tool_use_id = Some(owner.to_owned());
1124        }
1125        folded.entry(owner.to_owned()).or_default().push(Block { dispatch, rows });
1126    }
1127    for (owner, mut blocks) in folded {
1128        // The sidecars arrive newest-first; roots follow arrival, so reverse
1129        // them. Nesting itself rides the tool_use ids, not the mtimes.
1130        blocks.reverse();
1131        // `owning_dispatch` only answers for a dispatch `answered` holds, and
1132        // `messages` only grows below, so this lookup cannot miss; the guard
1133        // keeps the splice total.
1134        let Some(index) = messages.iter().position(|row| answers_dispatch(row, &owner)) else {
1135            continue;
1136        };
1137        let rows = emit_sidecar_tree(blocks);
1138        messages.splice((index + 1)..=index, rows);
1139    }
1140}
1141
1142/// One sidecar's frames and the dispatch it ran as.
1143struct Block {
1144    dispatch: String,
1145    rows: Vec<SessionMessage>,
1146}
1147
1148/// One owner's blocks, flattened in the order the work ran: a block emits at
1149/// the row naming its dispatch, and a block no other names - its parent body
1150/// was past the budget - follows the roots.
1151fn emit_sidecar_tree(mut blocks: Vec<Block>) -> Vec<SessionMessage> {
1152    let by_dispatch: HashMap<String, usize> =
1153        blocks.iter().enumerate().map(|(at, block)| (block.dispatch.clone(), at)).collect();
1154    // A block whose dispatch names another block's row is a child; the rest
1155    // are roots.
1156    let mut inline = vec![false; blocks.len()];
1157    for (at, block) in blocks.iter().enumerate() {
1158        for row in &block.rows {
1159            for id in tool_use_ids(row) {
1160                if let Some(&kid) = by_dispatch.get(id)
1161                    && kid != at
1162                {
1163                    inline[kid] = true;
1164                }
1165            }
1166        }
1167    }
1168    let mut out = Vec::new();
1169    let mut emitted = vec![false; blocks.len()];
1170    for at in (0..blocks.len()).filter(|at| !inline[*at]) {
1171        emit_block(&mut blocks, at, &by_dispatch, &mut emitted, &mut out);
1172    }
1173    // A malformed link pair can make a block its own ancestor; it still
1174    // draws, once, after the roots rather than vanishing.
1175    for at in 0..blocks.len() {
1176        emit_block(&mut blocks, at, &by_dispatch, &mut emitted, &mut out);
1177    }
1178    out
1179}
1180
1181/// Append one block, with each child block inserted after the row that
1182/// carries its dispatch.
1183fn emit_block(
1184    blocks: &mut [Block],
1185    at: usize,
1186    by_dispatch: &HashMap<String, usize>,
1187    emitted: &mut [bool],
1188    out: &mut Vec<SessionMessage>,
1189) {
1190    if emitted[at] {
1191        return;
1192    }
1193    emitted[at] = true;
1194    for row in std::mem::take(&mut blocks[at].rows) {
1195        let mut kids = Vec::new();
1196        for id in tool_use_ids(&row) {
1197            if let Some(&kid) = by_dispatch.get(id)
1198                && kid != at
1199            {
1200                kids.push(kid);
1201            }
1202        }
1203        out.push(row);
1204        for kid in kids {
1205            emit_block(blocks, kid, by_dispatch, emitted, out);
1206        }
1207    }
1208}
1209
1210/// The dispatch whose row a sidecar's frames belong under: the first dispatch
1211/// in its chain the transcript answers.
1212fn owning_dispatch<'a>(
1213    dispatch: &'a str,
1214    answered: &HashSet<String>,
1215    made_under: &'a HashMap<String, String>,
1216) -> Option<&'a str> {
1217    let mut cursor = dispatch;
1218    // Bounded, so a pair of sidecars naming each other cannot spin: these
1219    // links are read off disk rather than built here.
1220    for _ in 0..=made_under.len() {
1221        if answered.contains(cursor) {
1222            return Some(cursor);
1223        }
1224        cursor = made_under.get(cursor)?;
1225    }
1226    None
1227}
1228
1229/// The calls made in `row`.
1230fn tool_use_ids(row: &SessionMessage) -> impl Iterator<Item = &str> {
1231    block_field(row, "tool_use", "id")
1232}
1233
1234/// The calls `row` answers.
1235fn answered_dispatches(row: &SessionMessage) -> impl Iterator<Item = &str> {
1236    block_field(row, "tool_result", "tool_use_id")
1237}
1238
1239/// The `field` of every content block of `kind` in `row`.
1240fn block_field<'a>(
1241    row: &'a SessionMessage,
1242    kind: &'static str,
1243    field: &'static str,
1244) -> impl Iterator<Item = &'a str> {
1245    row.message.get("content").and_then(Value::as_array).into_iter().flatten().filter_map(
1246        move |block| {
1247            (block.get("type").and_then(Value::as_str) == Some(kind))
1248                .then(|| block.get(field).and_then(Value::as_str))
1249                .flatten()
1250        },
1251    )
1252}
1253
1254/// Whether `row` is the tool result answering `dispatch`.
1255fn answers_dispatch(row: &SessionMessage, dispatch: &str) -> bool {
1256    answered_dispatches(row).any(|answered| answered == dispatch)
1257}
1258
1259/// How many bytes of sidecar frames one resume reads in total. A session can
1260/// hold hundreds of sidecars and most of them are older than the window the
1261/// conversation is cut to, so the read is bounded rather than whole
1262/// (#1763).
1263const SIDECAR_BUDGET_BYTES: u64 = 8 * 1024 * 1024;
1264
1265/// A sidecar's own meta: the dispatch its agent ran as, and the agent whose
1266/// sidecar fired that dispatch.
1267struct SidecarLink {
1268    dispatch: String,
1269    parent_agent: Option<String>,
1270}
1271
1272/// The sidecar bodies the budget admitted, and every meta's chain link.
1273type SidecarRead = (Vec<(String, Vec<SessionMessage>)>, HashMap<String, SidecarLink>);
1274
1275/// The frames a session's sub-agent sidecars hold, with each sidecar's own
1276/// dispatch and every meta's chain link. The links are read for all sidecars,
1277/// not only the ones inside the budget; the fold stamps the rows themselves.
1278///
1279/// The CLI writes a sub-agent's own frames to
1280/// `<session>/subagents/agent-<id>.jsonl` and names the dispatch and its
1281/// parent agent in the sibling `.meta.json`; neither is in the session
1282/// transcript, so a resumed conversation drew every card as its dispatch
1283/// alone until this read folded them in (#1763). Newest sidecars first, up
1284/// to [`SIDECAR_BUDGET_BYTES`]; a sidecar whose body cannot be read, or
1285/// falls past the budget, keeps the dispatch alone, while its meta still
1286/// links a nested child whose body is inside the budget. A meta that cannot
1287/// be parsed records no link.
1288fn subagent_sidecars(session_id: &str, transcript: &Path) -> SidecarRead {
1289    subagent_sidecars_with_budget(session_id, transcript, SIDECAR_BUDGET_BYTES)
1290}
1291
1292/// [`subagent_sidecars`] with the read budget given, so the cap is drivable
1293/// at a size a test can build.
1294fn subagent_sidecars_with_budget(session_id: &str, transcript: &Path, budget: u64) -> SidecarRead {
1295    let Some(dir) = transcript.parent() else {
1296        return (Vec::new(), HashMap::new());
1297    };
1298    let sidecar_dir = dir.join(session_id).join("subagents");
1299    let entries = match fs::read_dir(&sidecar_dir) {
1300        Ok(entries) => entries,
1301        // No directory is the ordinary case: the session never dispatched.
1302        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1303            return (Vec::new(), HashMap::new());
1304        }
1305        Err(error) => {
1306            tracing::debug!(
1307                event_name = "subagent_sidecar_dir_unreadable",
1308                session = %session_id,
1309                dir = %sidecar_dir.display(),
1310                %error,
1311                "subagent sidecars skipped: the directory could not be read",
1312            );
1313            return (Vec::new(), HashMap::new());
1314        }
1315    };
1316    let mut metas: Vec<(PathBuf, std::time::SystemTime)> = entries
1317        .filter_map(|entry| match entry {
1318            Ok(entry) => Some(entry),
1319            Err(error) => {
1320                tracing::debug!(
1321                    event_name = "subagent_sidecar_dir_entry_unreadable",
1322                    session = %session_id,
1323                    dir = %sidecar_dir.display(),
1324                    %error,
1325                    "a subagent sidecar entry was skipped: it could not be read",
1326                );
1327                None
1328            }
1329        })
1330        .filter(|entry| entry.file_name().to_str().is_some_and(|name| name.ends_with(".meta.json")))
1331        .filter_map(|entry| {
1332            let modified = match entry.metadata().and_then(|meta| meta.modified()) {
1333                Ok(modified) => modified,
1334                Err(error) => {
1335                    tracing::debug!(
1336                        event_name = "subagent_sidecar_meta_unreadable",
1337                        session = %session_id,
1338                        meta = %entry.path().display(),
1339                        %error,
1340                        "subagent sidecar meta skipped: its timestamp could not be read",
1341                    );
1342                    return None;
1343                }
1344            };
1345            Some((entry.path(), modified))
1346        })
1347        .collect();
1348    metas.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1349    let mut budget = budget;
1350    let mut sidecars = Vec::new();
1351    let mut links = HashMap::new();
1352    for (meta, _) in metas {
1353        let Ok(body) = fs::read(&meta) else {
1354            tracing::debug!(
1355                event_name = "subagent_sidecar_meta_unreadable",
1356                session = %session_id,
1357                meta = %meta.display(),
1358                "subagent sidecar meta skipped: it could not be read",
1359            );
1360            continue;
1361        };
1362        let Ok(value) = serde_json::from_slice::<Value>(&body) else {
1363            tracing::debug!(
1364                event_name = "subagent_sidecar_meta_unreadable",
1365                session = %session_id,
1366                meta = %meta.display(),
1367                "subagent sidecar meta skipped: it is not JSON",
1368            );
1369            continue;
1370        };
1371        let Some(dispatch) =
1372            value.get("toolUseId").and_then(Value::as_str).filter(|id| !id.trim().is_empty())
1373        else {
1374            tracing::debug!(
1375                event_name = "subagent_sidecar_meta_unreadable",
1376                session = %session_id,
1377                meta = %meta.display(),
1378                "subagent sidecar meta skipped: it names no dispatch",
1379            );
1380            continue;
1381        };
1382        let Some(stem) = meta
1383            .file_name()
1384            .and_then(|name| name.to_str())
1385            .and_then(|name| name.strip_suffix(".meta.json"))
1386        else {
1387            tracing::debug!(
1388                event_name = "subagent_sidecar_meta_unreadable",
1389                session = %session_id,
1390                meta = %meta.display(),
1391                "subagent sidecar meta skipped: no sidecar name derives from it",
1392            );
1393            continue;
1394        };
1395        let agent = stem.strip_prefix("agent-").unwrap_or(stem);
1396        links.insert(
1397            agent.to_owned(),
1398            SidecarLink {
1399                dispatch: dispatch.to_owned(),
1400                parent_agent: value.get("parentAgentId").and_then(Value::as_str).map(str::to_owned),
1401            },
1402        );
1403        let sidecar = meta.with_file_name(format!("{stem}.jsonl"));
1404        let Ok(size) = fs::metadata(&sidecar).map(|info| info.len()) else {
1405            tracing::debug!(
1406                event_name = "subagent_sidecar_body_missing",
1407                session = %session_id,
1408                sidecar = %sidecar.display(),
1409                "subagent sidecar skipped: its body is missing",
1410            );
1411            continue;
1412        };
1413        if size > budget {
1414            tracing::debug!(
1415                event_name = "subagent_sidecar_budget_skipped",
1416                session = %session_id,
1417                sidecar = %sidecar.display(),
1418                "subagent sidecar skipped: the resume read's budget is spent",
1419            );
1420            continue;
1421        }
1422        budget -= size;
1423        let Ok(file) = fs::File::open(&sidecar) else {
1424            tracing::debug!(
1425                event_name = "subagent_sidecar_body_unreadable",
1426                session = %session_id,
1427                sidecar = %sidecar.display(),
1428                "subagent sidecar skipped: its body could not be opened",
1429            );
1430            continue;
1431        };
1432        // The fold stamps every row with its owner; the parse leaves the
1433        // wire's own value alone.
1434        let rows = parse_session_messages(file).messages;
1435        sidecars.push((dispatch.to_owned(), rows));
1436    }
1437    (sidecars, links)
1438}
1439
1440/// The rows a transcript's messages convert to, typed as the wire sends them
1441/// and stamped with `session_id`.
1442///
1443/// **One conversion for every read of a transcript.** A resume hands these
1444/// messages over as history and a paging span read hands them over as a page
1445/// below the window; a second copy of the conversion is how the two would
1446/// come to type the same row differently.
1447pub(crate) fn messages_from_rows(
1448    rows: Vec<SessionMessage>,
1449    session_id: &str,
1450) -> Vec<forge_primitives::Message> {
1451    let raw: Vec<Value> = rows
1452        .into_iter()
1453        .map(|m| {
1454            let kind = match m.kind {
1455                forge_primitives::SessionMessageKind::User => "user",
1456                forge_primitives::SessionMessageKind::Assistant => "assistant",
1457                forge_primitives::SessionMessageKind::System => "system",
1458            };
1459            serde_json::json!({
1460                "type": kind,
1461                "uuid": m.uuid,
1462                "message": m.message,
1463                "parent_tool_use_id": m.parent_tool_use_id,
1464                "timestamp": m.timestamp,
1465                "tool_use_result": m.tool_use_result,
1466                "synthetic": m.synthetic,
1467            })
1468        })
1469        .collect();
1470    let mut messages = crate::replay::synthesize_replay_messages(&raw);
1471    // The synthesizer leaves the session empty so the caller picks the right
1472    // value: the resumed session for a resume, the transcript's own for a page.
1473    for message in &mut messages {
1474        match message {
1475            forge_primitives::Message::Assistant { session_id: s, .. }
1476            | forge_primitives::Message::User { session_id: s, .. }
1477            | forge_primitives::Message::StopHookSummary { session_id: s, .. }
1478            | forge_primitives::Message::CompactBoundary { session_id: s, .. }
1479            | forge_primitives::Message::PermissionDenied { session_id: s, .. }
1480            | forge_primitives::Message::Notification { session_id: s, .. } => {
1481                session_id.clone_into(s);
1482            }
1483            // The generic system rows the read keeps carry the same field,
1484            // optional because the CLI omits it on some subtypes.
1485            forge_primitives::Message::System { session_id: s, .. } => {
1486                *s = Some(session_id.to_owned());
1487            }
1488            _ => {}
1489        }
1490    }
1491    messages
1492}
1493
1494/// The file a session id reads from: its project's directory when the caller
1495/// places it, the first copy named after it otherwise.
1496fn session_transcript(
1497    config_dir: &Path,
1498    session_id: &str,
1499    directory: Option<&str>,
1500) -> Option<PathBuf> {
1501    let file_name = format!("{session_id}.jsonl");
1502    if let Some(dir) = directory {
1503        let candidate = project_dir_for(config_dir, dir).join(&file_name);
1504        return candidate.is_file().then_some(candidate);
1505    }
1506    try_read_dir(&projects_dir_for(config_dir))?
1507        .flatten()
1508        .map(|entry| entry.path().join(&file_name))
1509        .find(|path| path.is_file())
1510}
1511
1512/// How many bytes of a transcript one paging pass reads before growing, and
1513/// how wide a single window it takes may be.
1514///
1515/// **A page below the window's floor lies near the file's END**: the floor is
1516/// the oldest frame the window still holds, so the turns above it are the
1517/// newest rows of a file the session is still writing - some 4,000 rows, a
1518/// few hundred KB at the rows a transcript writes. The step is what a cold
1519/// session pays for its first paging request.
1520///
1521/// **The cap bounds one WINDOW, not the pass.** A read that comes up short
1522/// doubles and reads again, so a walk that reaches the cap has read the
1523/// windows under it as well - up to about twice this, and the anchor search
1524/// pays the same. What is capped is therefore how far a single window reaches,
1525/// and past it the transcript is treated as unreadable for paging rather than
1526/// read whole.
1527pub const SPAN_STEP_BYTES: u64 = 1024 * 1024;
1528pub const SPAN_CAP_BYTES: u64 = 64 * 1024 * 1024;
1529
1530/// How many rows a page's own turn count may ask for.
1531///
1532/// Sized from the turns a page is cut into: the heaviest twenty-turn run
1533/// measured across the eight largest transcripts on the author's machine is
1534/// 2,799 rows, so 150 a turn asks for 3,000 at the twenty a subscribe carries,
1535/// which covers every page these transcripts would cut. A page whose turns
1536/// run heavier comes back short with a cursor to ask below with, rather than
1537/// converting a window's rows for turns nothing reads.
1538///
1539/// **The served hook rows ride inside that figure.** The SessionStart and
1540/// Setup runs a read now serves are about a seventh of a transcript's hook
1541/// rows and a few rows a session, so they do not move the 2,799 measurement;
1542/// a session that fired an unusual number of them is the "comes back short
1543/// with a cursor" case above, not a lost row.
1544pub const SPAN_ROWS_PER_TURN: usize = 150;
1545
1546/// How many rows past a page's own count a cut may step back over, so that it
1547/// does not open on a result without its call.
1548///
1549/// A call and its results are adjacent rows in every transcript measured, so
1550/// one step is the ordinary case; the room is a short run of them - parallel
1551/// calls written as several result frames - and a run longer than this ends
1552/// the step rather than growing the page without bound.
1553const ROWS_PAST_CUT: usize = 8;
1554
1555/// Whether a row carries a tool result: the kind of row a cut may not land on,
1556/// because the call it answers sits just above it in the page below.
1557fn carries_a_result(row: &SessionMessage) -> bool {
1558    row.kind == forge_primitives::SessionMessageKind::User
1559        && row.message.get("content").and_then(Value::as_array).is_some_and(|blocks| {
1560            blocks
1561                .iter()
1562                .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
1563        })
1564}
1565
1566/// Read the rows of `session_id`'s transcript that sit strictly before
1567/// `below`, newest first, up to `rows_wanted` of them.
1568///
1569/// **Row-level, on the file's own coordinates.** A page below a window's floor
1570/// is a running turn's older rows, and the conversation's frame numbering
1571/// cannot say where they are: a window whose front is a count cut inside a
1572/// running turn holds frames the CLI streamed and never wrote - thinking-token
1573/// frames among them - so a numbering that counted those frames cannot be
1574/// lined up against the file's rows with arithmetic. A row's own byte needs no
1575/// numbering, so the read works in bytes from an anchor: the row the page
1576/// above handed back as its cursor, or the file's own end when the walk's
1577/// first ask names none.
1578///
1579/// **Bounded by construction.** The read takes a window of the file, grows it
1580/// only while the rows it needs are not inside the window it took, and never
1581/// reads past [`SPAN_CAP_BYTES`]. `None` when the transcript cannot answer for
1582/// this session: no file, an anchor the file does not carry, an anchor
1583/// further from the file's end than the cap reaches, or a window that could
1584/// not be read. The caller answers all of them as the end of the history,
1585/// which is what a page this read cannot vouch for deserves.
1586pub fn read_rows(
1587    config_dir: &Path,
1588    session_id: &str,
1589    directory: Option<&str>,
1590    below: Option<&forge_primitives::TranscriptAnchor>,
1591    rows_wanted: usize,
1592) -> Option<forge_primitives::TranscriptSpan> {
1593    read_rows_with(
1594        config_dir,
1595        session_id,
1596        directory,
1597        below,
1598        rows_wanted,
1599        SPAN_STEP_BYTES,
1600        SPAN_CAP_BYTES,
1601    )
1602}
1603
1604/// [`read_rows`] with the page's own budget in the caller's hands, so the
1605/// growth, the cap and the search bound can be driven by a test without a
1606/// transcript of the size the production budget is written for.
1607fn read_rows_with(
1608    config_dir: &Path,
1609    session_id: &str,
1610    directory: Option<&str>,
1611    below: Option<&forge_primitives::TranscriptAnchor>,
1612    rows_wanted: usize,
1613    step: u64,
1614    cap: u64,
1615) -> Option<forge_primitives::TranscriptSpan> {
1616    // The id names the file, so anything but a uuid is a path this refuses
1617    // rather than resolves.
1618    if !is_valid_uuid(session_id) {
1619        return None;
1620    }
1621    let path = session_transcript(config_dir, session_id, directory)?;
1622    let mut file = match fs::File::open(&path) {
1623        Ok(file) => file,
1624        Err(err) => {
1625            tracing::warn!(
1626                event_name = "span_read_open_failed",
1627                %session_id,
1628                path = %path.display(),
1629                %err,
1630                "the transcript could not be opened; the page is answered empty",
1631            );
1632            return None;
1633        }
1634    };
1635    let len = file.metadata().ok()?.len();
1636    if len == 0 {
1637        return None;
1638    }
1639    // **Where the read ends.** A named anchor places the window's end at its
1640    // own row, so the rows read are the ones strictly older than it; no
1641    // anchor means the file's end, whose rows are the newest the session
1642    // wrote - the walk's first ask on a window that holds no row to name.
1643    let end = match below {
1644        None => len,
1645        Some(anchor) => resolve_anchor(&mut file, session_id, len, anchor, step, cap)?,
1646    };
1647
1648    // **The rows, walked backward from the end.** A window holds the rows just
1649    // below the byte it ends at; one that comes up short grows rather than
1650    // walking the file from its start, which on a long transcript is the whole
1651    // file. The windows meet on row boundaries, so no row is read twice.
1652    let mut kept: Vec<(SessionMessage, u64)> = Vec::new();
1653    let mut take = step.min(len).max(1);
1654    let mut at = end;
1655    let mut reached_start = false;
1656    let mut file_first: Option<u64> = None;
1657    loop {
1658        let window = match window_ending(&mut file, at, take) {
1659            Ok(window) => window,
1660            Err(err) => {
1661                tracing::warn!(
1662                    event_name = "span_read_failed",
1663                    %session_id,
1664                    from = at.saturating_sub(take),
1665                    %err,
1666                    "the transcript window could not be read; the page is answered empty",
1667                );
1668                return None;
1669            }
1670        };
1671        let rows: Vec<(SessionMessage, u64)> =
1672            window_rows(&window).into_iter().filter(|(_, offset)| *offset < at).collect();
1673        let next_at = rows.first().map(|(_, offset)| *offset);
1674        if window.from == 0 {
1675            reached_start = true;
1676            file_first = next_at;
1677        }
1678        // **The count, plus the room a cut steps over.** A cut may land among
1679        // result rows, and the rows it steps past are deferred to the page
1680        // below rather than dropped, so a little room past the count is read.
1681        let wanted = rows_wanted.saturating_add(ROWS_PAST_CUT);
1682        let room = wanted.saturating_sub(kept.len());
1683        let mut fresh = rows;
1684        if fresh.len() > room {
1685            // The ones nearest the boundary: a page is the newest of the older
1686            // rows, so what does not fit is left for the page below.
1687            fresh = fresh.split_off(fresh.len() - room);
1688        }
1689        let mut merged = fresh;
1690        merged.append(&mut kept);
1691        kept = merged;
1692        if kept.len() >= rows_wanted || window.from == 0 || take >= cap {
1693            break;
1694        }
1695        take = (take * 2).min(cap);
1696        at = next_at.unwrap_or(window.from);
1697        if at == 0 {
1698            break;
1699        }
1700    }
1701
1702    // The page's own count: the room read past it was the cut's, and what the
1703    // cut did not need is not served.
1704    let over = kept.len().saturating_sub(rows_wanted);
1705    kept.drain(..over);
1706    // **A page never opens on a result without its call.** The span's oldest
1707    // row is where the page below stops, so a cut landing among result rows
1708    // would hand the fold a result whose call it does not carry - drawn with
1709    // its output nowhere. Those leading result rows are deferred instead: the
1710    // page below is cut from the call that produced them, which is older, and
1711    // serves them with it. Nothing is dropped - the row already read is simply
1712    // the page below's to carry - and a run longer than the room ends the step
1713    // rather than shortening the page without bound.
1714    let deferred = kept
1715        .iter()
1716        .position(|(row, _)| !carries_a_result(row))
1717        .map_or(0, |at| at.min(ROWS_PAST_CUT));
1718    kept.drain(..deferred);
1719    // The re-appended block, deduped the same way the whole-file read does it.
1720    // `kept` is oldest-first, so the original copy survives and the later one
1721    // is dropped.
1722    let mut seen = std::collections::HashSet::new();
1723    kept.retain(|(row, _)| !already_kept(&mut seen, &row.uuid));
1724    // The span runs to the history's start when the read reached the file's
1725    // own beginning AND the span's oldest row is the first row it found there:
1726    // a window that reached the start and held no row older than the boundary
1727    // has nothing left to serve either.
1728    let exhausted = reached_start
1729        && file_first.is_none_or(|first| kept.first().is_none_or(|(_, offset)| *offset == first));
1730
1731    let mut messages = Vec::with_capacity(kept.len());
1732    let mut offsets = Vec::with_capacity(kept.len());
1733    for (row, offset) in kept {
1734        // **One row at a time**, so a row the synthesizer cannot decode is
1735        // dropped with its own byte rather than shifting every byte after it.
1736        if let Some(message) = messages_from_rows(vec![row], session_id).into_iter().next() {
1737            messages.push(message);
1738            offsets.push(offset);
1739        }
1740    }
1741    Some(forge_primitives::TranscriptSpan { messages, offsets, exhausted })
1742}
1743
1744/// Where a read's window ends for a named anchor: the byte its row starts at,
1745/// seeking when the cursor carries a byte and searching from the file's end
1746/// when it does not.
1747///
1748/// `None` when the anchor cannot be placed - a row the file does not carry, or
1749/// one further from the end than [`SPAN_CAP_BYTES`] reaches - which the caller
1750/// answers as the end of the history.
1751fn resolve_anchor(
1752    file: &mut fs::File,
1753    session_id: &str,
1754    len: u64,
1755    anchor: &forge_primitives::TranscriptAnchor,
1756    step: u64,
1757    cap: u64,
1758) -> Option<u64> {
1759    if let Some(offset) = anchor.offset.filter(|offset| *offset <= len) {
1760        // The verify: a resumed or rewritten transcript carries something else
1761        // at the byte, and the search below is what answers that.
1762        if anchor.row.is_empty() || row_at(file, offset).is_some_and(|row| row.uuid == anchor.row) {
1763            return Some(offset);
1764        }
1765    }
1766    if anchor.row.is_empty() {
1767        return None;
1768    }
1769    let mut take = step.min(len).max(1);
1770    loop {
1771        let window = window_ending(file, len, take).ok()?;
1772        if let Some((_, offset)) =
1773            window_rows(&window).into_iter().find(|(row, _)| row.uuid == anchor.row)
1774        {
1775            return Some(offset);
1776        }
1777        if take >= cap || take >= len {
1778            // **Routine, not forge's own problem.** The frames the CLI
1779            // streams and never writes carry ids no row holds (a mid-turn
1780            // queued prompt reaches the file only as an attachment naming
1781            // it), so a miss is the caller's to walk past rather than a
1782            // failure here.
1783            tracing::debug!(
1784                event_name = "span_read_anchor_unreached",
1785                %session_id,
1786                row = %anchor.row,
1787                len,
1788                cap,
1789                "no row in the file names this anchor; the read answers the caller empty",
1790            );
1791            return None;
1792        }
1793        take = (take * 2).min(cap);
1794    }
1795}
1796
1797/// One window of a transcript file: the bytes read and the byte they start
1798/// at.
1799struct Window {
1800    from: u64,
1801    bytes: Vec<u8>,
1802}
1803
1804/// Read up to `take` bytes ending at `end`, and the byte before the window as
1805/// well.
1806///
1807/// **That one byte is what says whether the window starts on a row.** A window
1808/// beginning exactly at a row's first byte holds that whole row; one beginning
1809/// anywhere else holds a fragment of the row before it, and the two are
1810/// indistinguishable from inside the window.
1811fn window_ending(file: &mut fs::File, end: u64, take: u64) -> std::io::Result<Window> {
1812    let from = end.saturating_sub(take).saturating_sub(1);
1813    let mut bytes = vec![0_u8; usize::try_from(end - from).unwrap_or(0)];
1814    file.seek(SeekFrom::Start(from))?;
1815    file.read_exact(&mut bytes)?;
1816    Ok(Window { from, bytes })
1817}
1818
1819/// The rows a window holds, each with the byte it starts at.
1820///
1821/// A window whose first byte follows anything but a newline begins inside a
1822/// row: everything before its next newline is that row's fragment - not a row,
1823/// and not counted. Every other line is whole.
1824fn window_rows(window: &Window) -> Vec<(SessionMessage, u64)> {
1825    // The window's first byte is the one before the window: a newline means
1826    // the window begins on a row, anything else means it begins inside one.
1827    let fragment = window.from > 0 && window.bytes.first() != Some(&b'\n');
1828    let mut rows = Vec::new();
1829    // A queued prompt is held until its remove row, whose byte is where the
1830    // row is served: the attachment's own byte is inside the turn the press
1831    // interrupted. A held row whose remove lies past the window never started
1832    // in this stretch, so it is dropped rather than served at the press.
1833    let mut queued = Vec::new();
1834    let mut at = window.from;
1835    for line in window.bytes.split(|byte| *byte == b'\n') {
1836        let start = at;
1837        at = at.saturating_add(line.len() as u64 + 1);
1838        if fragment && start == window.from {
1839            continue;
1840        }
1841        let Ok(text) = std::str::from_utf8(line) else { continue };
1842        let Ok(value) = serde_json::from_str::<Value>(text) else { continue };
1843        if let Some(remove) = queue_remove_row(&value) {
1844            rows.push((queued_start_row(&mut queued, &value, remove), start));
1845            continue;
1846        }
1847        let Some((row, _)) = transcript_row(&value) else { continue };
1848        if is_queued_attachment_row(&value) {
1849            queued.push(row);
1850            continue;
1851        }
1852        rows.push((row, start));
1853    }
1854    rows
1855}
1856
1857/// The row that starts at `at`, for the anchored read's verify.
1858fn row_at(file: &mut fs::File, at: u64) -> Option<SessionMessage> {
1859    // One line, and a cap so a torn file cannot hand back the rest of itself.
1860    const LINE_CAP: u64 = 1024 * 1024;
1861    file.seek(SeekFrom::Start(at)).ok()?;
1862    let mut bytes = Vec::new();
1863    std::io::Read::take(&mut *file, LINE_CAP).read_to_end(&mut bytes).ok()?;
1864    let text = String::from_utf8_lossy(&bytes);
1865    session_row(text.lines().next().unwrap_or_default())
1866}
1867
1868/// One line of a transcript as its row, or `None` for a line that is not one.
1869fn session_row(line: &str) -> Option<SessionMessage> {
1870    let line = line.trim_end_matches('\r');
1871    if line.is_empty() {
1872        return None;
1873    }
1874    let value = serde_json::from_str::<Value>(line).ok()?;
1875    // The queued prompt's row is served at this byte, so an anchor that names
1876    // it verifies here as well as in a window's row list.
1877    if let Some(row) = queue_remove_row(&value) {
1878        return Some(row);
1879    }
1880    transcript_row(&value).map(|(row, _)| row)
1881}
1882
1883// ---------------------------------------------------------------------------
1884// Lite read - head + tail metadata extraction without full-file scan.
1885// ---------------------------------------------------------------------------
1886
1887/// Head / tail snapshot of a session file - enough to recover all
1888/// [`SDKSessionInfo`] fields without a full scan. The `tag` field is
1889/// populated by a separate streaming line-scan because tag rows can
1890/// appear anywhere in the file (start-of-file at spawn, mid-file via
1891/// `/new` re-tag) and the head + tail windows miss both cases on
1892/// large transcripts.
1893struct LiteSessionFile {
1894    mtime: u64,
1895    size: u64,
1896    head: String,
1897    tail: String,
1898    tag: Option<String>,
1899}
1900
1901/// Open a session file, stat it, read at most [`LITE_READ_BUF_SIZE`]
1902/// bytes from the head and the same from the tail. For files smaller
1903/// than the buffer, `tail == head` (single read). Returns `None` on
1904/// any I/O error or for empty files.
1905///
1906/// The tag scan that follows is NOT bounded by that buffer - it reads
1907/// the file end to end, so this is a lite read of the metadata and a
1908/// full read of the bytes. See [`scan_tag_from`].
1909///
1910/// Each `.ok()?` early return logs at debug level naming the step
1911/// that failed - without these, the session picker silently drops
1912/// sessions whose files have permission errors / are mid-truncation
1913/// / have a bad fd, which presents to the user as missing sessions
1914/// with no triage signal.
1915fn read_session_lite(
1916    path: &Path,
1917    cache: Option<&SessionTagCache>,
1918    scan_tag: bool,
1919) -> Option<LiteSessionFile> {
1920    let mut file = match fs::File::open(path) {
1921        Ok(f) => f,
1922        Err(e) => {
1923            tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "open", "lite-read failed");
1924            return None;
1925        }
1926    };
1927    let meta = match file.metadata() {
1928        Ok(m) => m,
1929        Err(e) => {
1930            tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "metadata", "lite-read failed");
1931            return None;
1932        }
1933    };
1934    let size = meta.len();
1935    if size == 0 {
1936        return None;
1937    }
1938    let mtime = meta
1939        .modified()
1940        .ok()
1941        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1942        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
1943
1944    let head_len = usize::try_from(LITE_READ_BUF_SIZE.min(size)).unwrap_or(usize::MAX);
1945    let mut head_bytes = vec![0u8; head_len];
1946    let read = match file.read(&mut head_bytes) {
1947        Ok(n) => n,
1948        Err(e) => {
1949            tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "read_head", "lite-read failed");
1950            return None;
1951        }
1952    };
1953    head_bytes.truncate(read);
1954    if head_bytes.is_empty() {
1955        return None;
1956    }
1957    let head = String::from_utf8_lossy(&head_bytes).into_owned();
1958
1959    let tail = if size <= LITE_READ_BUF_SIZE {
1960        head.clone()
1961    } else {
1962        let tail_offset = size - LITE_READ_BUF_SIZE;
1963        if let Err(e) = file.seek(SeekFrom::Start(tail_offset)) {
1964            tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "seek_tail", "lite-read failed");
1965            return None;
1966        }
1967        let mut tail_bytes = vec![0u8; usize::try_from(LITE_READ_BUF_SIZE).unwrap_or(usize::MAX)];
1968        let read = match file.read(&mut tail_bytes) {
1969            Ok(n) => n,
1970            Err(e) => {
1971                tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "read_tail", "lite-read failed");
1972                return None;
1973            }
1974        };
1975        tail_bytes.truncate(read);
1976        String::from_utf8_lossy(&tail_bytes).into_owned()
1977    };
1978
1979    // The tag scan is the whole file. A caller that wants only the title
1980    // passes `false` and is not billed for the bytes that scan would read.
1981    let tag = if scan_tag {
1982        match tag_of(path, &mut file, size, cache) {
1983            Ok(tag) => tag,
1984            Err(e) => {
1985                tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, path = %path.display(), error = %e, step = "seek_tag_scan", "lite-read tag-scan failed; resume will treat as untagged");
1986                None
1987            }
1988        }
1989    } else {
1990        None
1991    };
1992
1993    Some(LiteSessionFile { mtime, size, head, tail, tag })
1994}
1995
1996/// The last tag `path`'s transcript carries, with the read error
1997/// [`read_session_lite`] turns into "untagged" handed back as one: a
1998/// caller that has to tell an unreadable transcript from an untagged one
1999/// cannot use the lite read.
2000///
2001/// `size` is the file's length, `file` is positioned by this call, and
2002/// `cache` resumes the scan where the previous one stopped - a shrunk
2003/// file was truncated or replaced, so the whole thing is read again.
2004pub(crate) fn tag_of(
2005    path: &Path,
2006    file: &mut fs::File,
2007    size: u64,
2008    cache: Option<&SessionTagCache>,
2009) -> std::io::Result<Option<String>> {
2010    Ok(tag_and_project_of(path, file, size, cache)?.0)
2011}
2012
2013/// The last tag AND the project its row names, with the same cache and
2014/// read-error behaviour as [`tag_of`]. A worker resume uses the project
2015/// to tell this project's session from another project's in a directory
2016/// both compose to.
2017pub(crate) fn tag_and_project_of(
2018    path: &Path,
2019    file: &mut fs::File,
2020    size: u64,
2021    cache: Option<&SessionTagCache>,
2022) -> std::io::Result<(Option<String>, Option<String>)> {
2023    let carried = cache
2024        .and_then(|c| c.get(path))
2025        .filter(|prior| prior.scanned_len <= size)
2026        .unwrap_or_default();
2027    file.seek(SeekFrom::Start(carried.scanned_len))?;
2028    let (tag, project, state) = scan_tag_from(BufReader::new(&mut *file), Some(carried));
2029    if let Some(cache) = cache {
2030        cache.put(path, state);
2031    }
2032    Ok((tag, project))
2033}
2034
2035/// What a tag scan learned about one transcript, and how far into it the
2036/// answer is known to hold.
2037///
2038/// `scanned_len` is the offset just past the last newline the scan
2039/// consumed, NOT the file's length. Any trailing bytes with no newline
2040/// are a line the writer had not finished, so they are deliberately not
2041/// credited: the next scan re-reads that line from its true start. That
2042/// is what makes `scanned_len` a line boundary by construction, and a
2043/// line therefore cannot straddle it.
2044#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
2045pub struct SessionTagScan {
2046    /// The last tag among the complete lines below `scanned_len`.
2047    pub tag: Option<String>,
2048    /// The project the last tag row names, when it carries one. Absent on
2049    /// a row written before the field existed, and on a cache entry
2050    /// persisted before it did.
2051    #[serde(default)]
2052    pub project: Option<String>,
2053    pub scanned_len: u64,
2054}
2055
2056/// Carries the previous run's tag scans into this one and collects what
2057/// changed, so the whole catalog costs one store read up front and one
2058/// store write at the end rather than a transaction per transcript.
2059///
2060/// Owned by the caller because this crate has no store of its own: whoever
2061/// has the database fills it, passes it to [`list_sessions`], and persists
2062/// [`SessionTagCache::updates`] afterwards.
2063#[derive(Debug, Default)]
2064pub struct SessionTagCache {
2065    prior: std::collections::HashMap<String, SessionTagScan>,
2066    updated: std::sync::Mutex<Vec<(String, SessionTagScan)>>,
2067}
2068
2069impl SessionTagCache {
2070    pub fn new(prior: std::collections::HashMap<String, SessionTagScan>) -> Self {
2071        Self { prior, updated: std::sync::Mutex::new(Vec::new()) }
2072    }
2073
2074    fn get(&self, path: &Path) -> Option<SessionTagScan> {
2075        self.prior.get(path.to_str()?).cloned()
2076    }
2077
2078    /// Whether this transcript carried `wanted` at its last scan, when the
2079    /// file has not changed since. `None` when the tag is not known - nothing
2080    /// is cached, or the entry is behind the file on disk, where a `/new`
2081    /// re-tag can land - so the caller reads the file rather than deciding.
2082    fn carries_tag(&self, path: &Path, wanted: &str) -> Option<bool> {
2083        // The cache first: a path it has never seen costs no metadata call on
2084        // a cold listing.
2085        let held = self.get(path)?;
2086        let size = std::fs::metadata(path).ok()?.len();
2087        (held.scanned_len == size).then(|| held.tag.as_deref() == Some(wanted))
2088    }
2089
2090    fn put(&self, path: &Path, scan: SessionTagScan) {
2091        let Some(key) = path.to_str() else { return };
2092        // Unchanged files re-derive the entry they already had; writing it
2093        // back would rewrite the whole table on every boot.
2094        if self.prior.get(key) == Some(&scan) {
2095            return;
2096        }
2097        if let Ok(mut updated) = self.updated.lock() {
2098            updated.push((key.to_owned(), scan));
2099        }
2100    }
2101
2102    /// Entries whose scan moved this run, for the caller to persist.
2103    pub fn updates(&self) -> Vec<(String, SessionTagScan)> {
2104        self.updated.lock().map(|u| u.clone()).unwrap_or_default()
2105    }
2106}
2107
2108/// Return the value of the LAST `{"type":"tag"}` row's `"tag"` field.
2109/// Empty strings are filtered. `None` when no tag row is present or any
2110/// read errors out.
2111///
2112/// Last-wins semantics: a `/new` re-tag appended later in the
2113/// transcript supersedes the original spawn tag.
2114///
2115/// Reads into one reused buffer rather than over `BufRead::lines`,
2116/// whose per-line `String` is pure overhead here: this runs over every
2117/// byte of every transcript in the catalog on the boot path, and the
2118/// tag rows it is looking for are a handful of lines in gigabytes.
2119///
2120/// `carried` is the answer a previous scan reached
2121/// at its own `scanned_len`, which stands unless these bytes hold a
2122/// later tag - last-wins makes that sound, since every line here follows
2123/// every line the carried answer was computed over.
2124///
2125/// Returns the tag INCLUDING any trailing unterminated line, which is
2126/// what a whole-file scan has always reported, alongside the state to
2127/// cache, which excludes it.
2128fn scan_tag_from<R: BufRead>(
2129    mut reader: R,
2130    carried: Option<SessionTagScan>,
2131) -> (Option<String>, Option<String>, SessionTagScan) {
2132    let mut state = carried.unwrap_or_default();
2133    // Tracked apart from `state.tag` so a tag on a half-written line is
2134    // reported now but not cached: the line is re-read next time.
2135    let mut last_tag = state.tag.clone();
2136    let mut last_project = state.project.clone();
2137    let mut line = Vec::new();
2138    loop {
2139        line.clear();
2140        match reader.read_until(b'\n', &mut line) {
2141            Ok(0) => break,
2142            Ok(_) => {}
2143            Err(e) => {
2144                tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, error = %e, step = "tag_scan_line", "lite-read tag-scan line read failed; ending scan with last seen tag");
2145                break;
2146            }
2147        }
2148        let complete = line.last() == Some(&b'\n');
2149        let raw_len = line.len() as u64;
2150        // `lines()` yields neither terminator, and both are stripped
2151        // before the prefix test so a CRLF transcript compares the same
2152        // bytes it always did.
2153        if complete {
2154            line.pop();
2155        }
2156        if line.last() == Some(&b'\r') {
2157            line.pop();
2158        }
2159        // Every line is still validated, not only the candidates: a
2160        // transcript that is not UTF-8 ended the scan before, and where
2161        // it ends decides which tag wins. Breaking before the line is
2162        // credited leaves it to be retried rather than skipped for good.
2163        let text = match std::str::from_utf8(&line) {
2164            Ok(text) => text,
2165            Err(e) => {
2166                tracing::debug!(target: crate::logging::targets::CATALOG_SCAN, error = %e, step = "tag_scan_line", "lite-read tag-scan line read failed; ending scan with last seen tag");
2167                break;
2168            }
2169        };
2170        if text.starts_with("{\"type\":\"tag\"")
2171            && let Some(tag) = extract_last_json_string_field(text, "tag")
2172            && !tag.is_empty()
2173        {
2174            last_tag = Some(tag);
2175            last_project = extract_json_string_field(text, "project");
2176            if complete {
2177                state.tag.clone_from(&last_tag);
2178                state.project.clone_from(&last_project);
2179            }
2180        }
2181        if complete {
2182            state.scanned_len += raw_len;
2183        }
2184    }
2185    (last_tag, last_project, state)
2186}
2187
2188/// Find the first byte offset where `needle` begins in `haystack`.
2189fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
2190    if needle.is_empty() || needle.len() > haystack.len() {
2191        return None;
2192    }
2193    haystack.windows(needle.len()).position(|w| w == needle)
2194}
2195
2196/// Extract the first occurrence of a JSON string field (`"key":"value"`
2197/// or `"key": "value"`). Scans bytes directly to survive partial tail
2198/// reads; unescapes via `serde_json` only when the value contains a
2199/// backslash. Returns `None` when the field is absent or unterminated.
2200fn extract_json_string_field(text: &str, key: &str) -> Option<String> {
2201    let bytes = text.as_bytes();
2202    let compact = format!("\"{key}\":\"");
2203    let spaced = format!("\"{key}\": \"");
2204    for pattern in [compact.as_bytes(), spaced.as_bytes()] {
2205        if let Some(idx) = find_bytes(bytes, pattern) {
2206            let value_start = idx + pattern.len();
2207            let mut i = value_start;
2208            while i < bytes.len() {
2209                if bytes[i] == b'\\' {
2210                    i += 2;
2211                    continue;
2212                }
2213                if bytes[i] == b'"' {
2214                    let raw = std::str::from_utf8(&bytes[value_start..i]).ok()?;
2215                    return Some(unescape_json_string(raw));
2216                }
2217                i += 1;
2218            }
2219        }
2220    }
2221    None
2222}
2223
2224/// Like [`extract_json_string_field`] but returns the LAST occurrence.
2225fn extract_last_json_string_field(text: &str, key: &str) -> Option<String> {
2226    let bytes = text.as_bytes();
2227    let compact = format!("\"{key}\":\"");
2228    let spaced = format!("\"{key}\": \"");
2229    let mut last: Option<String> = None;
2230    // Track the byte offset of the winning match: the compact and
2231    // spaced patterns are scanned separately, so without comparing
2232    // positions the spaced scan would clobber a later compact match.
2233    let mut last_pos: Option<usize> = None;
2234    for pattern in [compact.as_bytes(), spaced.as_bytes()] {
2235        let mut search_from = 0usize;
2236        while search_from < bytes.len() {
2237            let remaining = &bytes[search_from..];
2238            let Some(rel_idx) = find_bytes(remaining, pattern) else {
2239                break;
2240            };
2241            let idx = search_from + rel_idx;
2242            let value_start = idx + pattern.len();
2243            let mut i = value_start;
2244            while i < bytes.len() {
2245                if bytes[i] == b'\\' {
2246                    i += 2;
2247                    continue;
2248                }
2249                if bytes[i] == b'"' {
2250                    if last_pos.is_none_or(|p| idx >= p)
2251                        && let Ok(raw) = std::str::from_utf8(&bytes[value_start..i])
2252                    {
2253                        last = Some(unescape_json_string(raw));
2254                        last_pos = Some(idx);
2255                    }
2256                    break;
2257                }
2258                i += 1;
2259            }
2260            search_from = i + 1;
2261        }
2262    }
2263    last
2264}
2265
2266/// Unescape a JSON string value. No-op when there are no backslashes.
2267fn unescape_json_string(raw: &str) -> String {
2268    if !raw.contains('\\') {
2269        return raw.to_string();
2270    }
2271    let wrapped = format!("\"{raw}\"");
2272    serde_json::from_str::<String>(&wrapped).unwrap_or_else(|_| raw.to_string())
2273}
2274
2275/// Extract the first meaningful user prompt from a JSONL head chunk.
2276/// Skips `tool_result`, `isMeta`, `isCompactSummary`, slash-command
2277/// messages (with command-name fallback), and the fixed-prefix skip
2278/// patterns the CLI's `_SKIP_FIRST_PROMPT_PATTERN` matches. Truncates to
2279/// 200 chars with an ellipsis.
2280fn extract_first_prompt_from_head(head: &str) -> Option<String> {
2281    let mut command_fallback: Option<String> = None;
2282    for line in head.split('\n') {
2283        if !line.contains("\"type\":\"user\"") && !line.contains("\"type\": \"user\"") {
2284            continue;
2285        }
2286        if line.contains("\"tool_result\"") {
2287            continue;
2288        }
2289        if line.contains("\"isMeta\":true") || line.contains("\"isMeta\": true") {
2290            continue;
2291        }
2292        if line.contains("\"isCompactSummary\":true") || line.contains("\"isCompactSummary\": true")
2293        {
2294            continue;
2295        }
2296        let Ok(entry) = serde_json::from_str::<Value>(line) else {
2297            continue;
2298        };
2299        if entry.get("type").and_then(Value::as_str) != Some("user") {
2300            continue;
2301        }
2302        let Some(message) = entry.get("message") else {
2303            continue;
2304        };
2305        let Some(content) = message.get("content") else {
2306            continue;
2307        };
2308        let texts: Vec<String> = if let Some(s) = content.as_str() {
2309            vec![s.to_string()]
2310        } else if let Some(arr) = content.as_array() {
2311            arr.iter()
2312                .filter_map(|b| {
2313                    (b.get("type").and_then(Value::as_str) == Some("text"))
2314                        .then(|| b.get("text").and_then(Value::as_str).map(str::to_string))
2315                        .flatten()
2316                })
2317                .collect()
2318        } else {
2319            Vec::new()
2320        };
2321        for raw in texts {
2322            let result = raw.replace('\n', " ");
2323            let result = result.trim();
2324            if result.is_empty() {
2325                continue;
2326            }
2327            if let Some(cmd) = extract_command_name(result) {
2328                if command_fallback.is_none() {
2329                    command_fallback = Some(cmd);
2330                }
2331                continue;
2332            }
2333            if should_skip_first_prompt(result) {
2334                continue;
2335            }
2336            let truncated = if result.chars().count() > 200 {
2337                let mut buf: String = result.chars().take(200).collect();
2338                while buf.ends_with(char::is_whitespace) {
2339                    buf.pop();
2340                }
2341                buf.push('\u{2026}');
2342                buf
2343            } else {
2344                result.to_string()
2345            };
2346            return Some(truncated);
2347        }
2348    }
2349    command_fallback
2350}
2351
2352/// Extract `<command-name>CMD</command-name>` when present.
2353pub(crate) fn extract_command_name(s: &str) -> Option<String> {
2354    const OPEN: &str = "<command-name>";
2355    const CLOSE: &str = "</command-name>";
2356    let open = s.find(OPEN)?;
2357    let after = &s[open + OPEN.len()..];
2358    let close = after.find(CLOSE)?;
2359    Some(after[..close].to_string())
2360}
2361
2362/// Fixed-prefix counterpart to the CLI's `_SKIP_FIRST_PROMPT_PATTERN`.
2363pub(crate) fn should_skip_first_prompt(s: &str) -> bool {
2364    const PREFIXES: [&str; 4] =
2365        ["<local-command-stdout>", "<session-start-hook>", "<tick>", "<goal>"];
2366    if PREFIXES.iter().any(|p| s.starts_with(p)) {
2367        return true;
2368    }
2369    if s.starts_with("[Request interrupted by user") && s.contains(']') {
2370        return true;
2371    }
2372    let trimmed = s.trim();
2373    for (open, close) in
2374        [("<ide_opened_file>", "</ide_opened_file>"), ("<ide_selection>", "</ide_selection>")]
2375    {
2376        if trimmed.starts_with(open) && trimmed.ends_with(close) {
2377            return true;
2378        }
2379    }
2380    false
2381}
2382
2383/// The one transcript at `path`, read for its title and clock.
2384///
2385/// A row the catalog mirrored at `Connected` carries a placeholder title
2386/// until the next scan reads the transcript; this reads that one file for a
2387/// caller that needs the title now, and `None` when there is no readable
2388/// transcript there.
2389///
2390/// **The tag is not read**, and is `None` on the answer: a tag scan covers
2391/// the whole file, and a caller that only needs the title must not pay for
2392/// bytes it does not use.
2393pub fn read_session_info_at(path: &Path) -> Option<SDKSessionInfo> {
2394    read_session_info_with(path, None, false)
2395}
2396
2397fn read_session_info(path: &Path, cache: Option<&SessionTagCache>) -> Option<SDKSessionInfo> {
2398    read_session_info_with(path, cache, true)
2399}
2400
2401fn read_session_info_with(
2402    path: &Path,
2403    cache: Option<&SessionTagCache>,
2404    scan_tag: bool,
2405) -> Option<SDKSessionInfo> {
2406    let session_id = path.file_stem().and_then(|s| s.to_str())?.to_string();
2407    let lite = read_session_lite(path, cache, scan_tag)?;
2408    let storage_key = path
2409        .parent()
2410        .and_then(Path::file_name)
2411        .and_then(|name| name.to_str())
2412        .unwrap_or_default()
2413        .to_owned();
2414    let mut info = parse_session_info_from_lite(&session_id, &lite, None)?;
2415    info.storage_key = storage_key;
2416    Some(info)
2417}
2418
2419/// Build an [`SDKSessionInfo`] from a lite head/tail read. Skips
2420/// sidechain transcripts and metadata-only sessions (no summary
2421/// after all fallbacks).
2422fn parse_session_info_from_lite(
2423    session_id: &str,
2424    lite: &LiteSessionFile,
2425    project_path: Option<&str>,
2426) -> Option<SDKSessionInfo> {
2427    let head = lite.head.as_str();
2428    let tail = lite.tail.as_str();
2429
2430    let first_line = head.find('\n').map_or(head, |idx| &head[..idx]);
2431    if first_line.contains("\"isSidechain\":true") || first_line.contains("\"isSidechain\": true") {
2432        return None;
2433    }
2434
2435    let custom_title = extract_last_json_string_field(tail, "customTitle")
2436        .or_else(|| extract_last_json_string_field(head, "customTitle"))
2437        .or_else(|| extract_last_json_string_field(tail, "aiTitle"))
2438        .or_else(|| extract_last_json_string_field(head, "aiTitle"));
2439    let first_prompt = extract_first_prompt_from_head(head);
2440    // Bias toward labels that identify what the session is about, not
2441    // what was last said in it. customTitle / aiTitle (claude-written
2442    // 4-6 word title) is best; first_prompt (the user's opening
2443    // prompt) is the next-best identifier; lastPrompt (a mid-session
2444    // follow-up that needs context to interpret) is the fallback
2445    // because it produces unreadable labels at narrow widths.
2446    let summary = custom_title
2447        .clone()
2448        .or_else(|| first_prompt.clone())
2449        .or_else(|| extract_last_json_string_field(tail, "lastPrompt"))
2450        .or_else(|| extract_last_json_string_field(tail, "summary"))?;
2451
2452    let git_branch = extract_last_json_string_field(tail, "gitBranch")
2453        .or_else(|| extract_json_string_field(head, "gitBranch"));
2454    let cwd = extract_json_string_field(head, "cwd").or_else(|| project_path.map(str::to_string));
2455    // Tag is pre-extracted by `read_session_lite` via a full-file
2456    // line-scan; neither head nor tail alone covers every position
2457    // a `{"type":"tag"}` row can land at.
2458    let tag = lite.tag.clone();
2459    let created_at = extract_json_string_field(first_line, "timestamp")
2460        .and_then(|ts| parse_rfc3339_ms(&ts).ok());
2461
2462    Some(SDKSessionInfo {
2463        session_id: session_id.to_string(),
2464        summary,
2465        last_modified: lite.mtime,
2466        file_size: Some(lite.size),
2467        custom_title,
2468        first_prompt,
2469        git_branch,
2470        cwd,
2471        storage_key: String::new(),
2472        tag,
2473        created_at,
2474    })
2475}
2476
2477/// Parse the CLI's RFC-3339 timestamp (e.g. `2026-04-22T04:15:27.123Z`)
2478/// into Unix epoch milliseconds. Sub-millisecond precision is truncated.
2479pub(crate) fn parse_rfc3339_ms(ts: &str) -> Result<u64, time::error::Parse> {
2480    let dt = time::OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339)?;
2481    let nanos = dt.unix_timestamp_nanos();
2482    Ok(u64::try_from(nanos / 1_000_000).unwrap_or(0))
2483}
2484
2485#[cfg(test)]
2486mod tests {
2487
2488    use super::*;
2489
2490    /// Buffer tracing output so an emitted record can be read back.
2491    #[derive(Clone, Default)]
2492    struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
2493
2494    impl std::io::Write for LogCapture {
2495        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2496            self.0.lock().extend_from_slice(buf);
2497            Ok(buf.len())
2498        }
2499        fn flush(&mut self) -> std::io::Result<()> {
2500            Ok(())
2501        }
2502    }
2503
2504    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
2505        type Writer = Self;
2506        fn make_writer(&'a self) -> Self::Writer {
2507            self.clone()
2508        }
2509    }
2510
2511    fn capture_logs(f: impl FnOnce()) -> String {
2512        let capture = LogCapture::default();
2513        let subscriber = tracing_subscriber::fmt().with_writer(capture.clone()).finish();
2514        tracing::subscriber::with_default(subscriber, f);
2515        let bytes = capture.0.lock().clone();
2516        String::from_utf8_lossy(&bytes).into_owned()
2517    }
2518
2519    /// A renamed disk field is the one drift this scan cannot otherwise see:
2520    /// the count stays right and the row still draws, bare or half-filled. The
2521    /// live arm warns on the same degradation, so the read path has to as well
2522    /// or the only trace of it is a row with no count and nothing saying why.
2523    ///
2524    /// **The shapes are the same failure and are keyed together**: the outer
2525    /// key renamed, any one of the three fields inside it renamed - the
2526    /// pre-count's is the one the primitives test calls plausible, and the
2527    /// trigger's and the post-count's are the symmetric cases - and no metadata
2528    /// at all. Each names what it left out, so the record points at the key
2529    /// that moved rather than at the row.
2530    #[test]
2531    fn a_boundary_row_whose_facts_did_not_survive_is_logged() {
2532        for (shape, row, missing) in [
2533            (
2534                "outer key renamed",
2535                r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadataMoved":{"trigger":"auto","preTokens":41207}}"#,
2536                "trigger, preTokens, postTokens",
2537            ),
2538            (
2539                "count renamed inside it",
2540                r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","pre_tokens":41207}}"#,
2541                "preTokens, postTokens",
2542            ),
2543            (
2544                "trigger renamed inside it",
2545                r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"triggers":"auto","preTokens":41207}}"#,
2546                "trigger, postTokens",
2547            ),
2548            (
2549                "post count renamed inside it",
2550                r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":41207,"post_tokens":1265}}"#,
2551                "postTokens",
2552            ),
2553            (
2554                "no metadata at all",
2555                r#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1"}"#,
2556                "trigger, preTokens, postTokens",
2557            ),
2558        ] {
2559            let log = capture_logs(|| {
2560                parse_session_messages(row.as_bytes());
2561            });
2562
2563            assert!(log.contains("cb1"), "the record for a {shape} names its row: {log}");
2564            assert!(log.contains("s1"), "and the session it was read for: {log}");
2565            assert!(log.contains(missing), "and the fact {shape} left out: {log}");
2566        }
2567    }
2568
2569    /// The negative control: a row carrying its metadata warns about nothing,
2570    /// so the record above is about the drift and not about every boundary.
2571    #[test]
2572    fn a_boundary_row_with_its_metadata_logs_nothing() {
2573        let log = capture_logs(|| {
2574            parse_session_messages(
2575                br#"{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":41207,"postTokens":1265}}"#
2576                    .as_slice(),
2577            );
2578        });
2579
2580        assert!(log.is_empty(), "a row the scan can read warns about nothing: {log}");
2581    }
2582
2583    #[test]
2584    fn sanitize_ascii_only_passthrough() {
2585        assert_eq!(sanitize_path("alphanum123"), "alphanum123");
2586    }
2587
2588    #[test]
2589    fn sanitize_replaces_non_alphanum_with_hyphens() {
2590        assert_eq!(
2591            sanitize_path("/Users/developer/projects/forge"),
2592            "-Users-developer-projects-forge"
2593        );
2594    }
2595
2596    #[test]
2597    fn extract_last_json_string_field_picks_globally_last_across_forms() {
2598        // The compact form appears LATER than the spaced form; the
2599        // globally-last value must win regardless of which pattern is
2600        // scanned first (the bug was the spaced scan clobbering a later
2601        // compact match).
2602        let text = r#"{"tag": "early-spaced"} {"tag":"late-compact"}"#;
2603        assert_eq!(extract_last_json_string_field(text, "tag"), Some("late-compact".to_owned()));
2604        // Reverse: spaced later than compact.
2605        let text2 = r#"{"tag":"early-compact"} {"tag": "late-spaced"}"#;
2606        assert_eq!(extract_last_json_string_field(text2, "tag"), Some("late-spaced".to_owned()));
2607    }
2608
2609    #[test]
2610    fn simple_hash_matches_known_value() {
2611        // Reference: _simple_hash("foo") → "26di" (computed from the
2612        // same 32-bit JS-style hash algorithm).
2613        assert_eq!(simple_hash("foo"), "26di");
2614    }
2615
2616    #[test]
2617    fn long_path_gets_hash_suffix() {
2618        let long = "a".repeat(300);
2619        let got = sanitize_path(&long);
2620        assert_eq!(got.len(), MAX_SANITIZED_LENGTH + 1 + simple_hash(&long).len());
2621    }
2622
2623    #[test]
2624    fn uuid_validator_accepts_canonical() {
2625        assert!(is_valid_uuid("550e8400-e29b-41d4-a716-446655440000"));
2626    }
2627
2628    #[test]
2629    fn uuid_validator_rejects_garbage() {
2630        assert!(!is_valid_uuid("not-a-uuid"));
2631        assert!(!is_valid_uuid("550e8400e29b41d4a716446655440000"));
2632        assert!(!is_valid_uuid(""));
2633    }
2634
2635    #[test]
2636    fn iso_parser_handles_millis() {
2637        let ms = parse_rfc3339_ms("2026-04-22T00:00:00.500Z").unwrap();
2638        assert_eq!(ms % 1000, 500);
2639    }
2640
2641    #[test]
2642    fn extract_json_string_field_finds_compact_form() {
2643        let t = r#"noise {"type":"user","message":{"content":"hi"}} noise"#;
2644        assert_eq!(extract_json_string_field(t, "content"), Some("hi".to_string()));
2645    }
2646
2647    #[test]
2648    fn extract_json_string_field_finds_spaced_form() {
2649        let t = r#"{"gitBranch": "main"}"#;
2650        assert_eq!(extract_json_string_field(t, "gitBranch"), Some("main".to_string()));
2651    }
2652
2653    #[test]
2654    fn extract_json_string_field_handles_escaped_quotes() {
2655        let t = r#"{"customTitle":"he said \"hi\""}"#;
2656        assert_eq!(
2657            extract_json_string_field(t, "customTitle"),
2658            Some(r#"he said "hi""#.to_string())
2659        );
2660    }
2661
2662    #[test]
2663    fn extract_last_json_string_field_picks_last() {
2664        let t = r#"{"tag":"old"} {"tag":"new"}"#;
2665        assert_eq!(extract_last_json_string_field(t, "tag"), Some("new".to_string()));
2666    }
2667
2668    #[test]
2669    fn first_prompt_skips_local_command_stdout() {
2670        let head = r#"{"type":"user","message":{"content":"<local-command-stdout>out</local-command-stdout>"}}
2671{"type":"user","message":{"content":"actual prompt"}}"#;
2672        assert_eq!(extract_first_prompt_from_head(head), Some("actual prompt".to_string()));
2673    }
2674
2675    #[test]
2676    fn first_prompt_falls_back_to_command_name() {
2677        let head = r#"{"type":"user","message":{"content":"<command-name>foo</command-name>"}}"#;
2678        assert_eq!(extract_first_prompt_from_head(head), Some("foo".to_string()));
2679    }
2680
2681    #[test]
2682    fn first_prompt_skips_tool_result_line() {
2683        let head = r#"{"type":"user","message":{"content":[{"type":"tool_result","content":"x"}]}}
2684{"type":"user","message":{"content":"real prompt"}}"#;
2685        assert_eq!(extract_first_prompt_from_head(head), Some("real prompt".to_string()));
2686    }
2687
2688    #[test]
2689    fn parse_session_info_skips_sidechain() {
2690        let head = "{\"isSidechain\":true,\"type\":\"user\"}\n".to_string();
2691        let lite = LiteSessionFile { mtime: 0, size: 1, head: head.clone(), tail: head, tag: None };
2692        assert!(parse_session_info_from_lite("abc", &lite, None).is_none());
2693    }
2694
2695    #[test]
2696    fn parse_session_info_skips_metadata_only() {
2697        let content = "{\"type\":\"tag\",\"tag\":\"meta\"}\n".to_string();
2698        let lite = LiteSessionFile {
2699            mtime: 10,
2700            size: content.len() as u64,
2701            head: content.clone(),
2702            tail: content,
2703            tag: Some("meta".to_string()),
2704        };
2705        // No custom_title, no aiTitle, no lastPrompt, no summary,
2706        // no first_prompt → skipped.
2707        assert!(parse_session_info_from_lite("abc", &lite, None).is_none());
2708    }
2709
2710    #[test]
2711    fn parse_session_info_extracts_prompt_and_tag() {
2712        let content = r#"{"type":"user","timestamp":"2026-04-22T00:00:00.000Z","gitBranch":"main","cwd":"/p","message":{"content":"hello"}}
2713{"type":"tag","tag":"mytag"}
2714"#
2715        .to_string();
2716        let lite = LiteSessionFile {
2717            mtime: 99,
2718            size: content.len() as u64,
2719            head: content.clone(),
2720            tail: content,
2721            tag: Some("mytag".to_string()),
2722        };
2723        let info = parse_session_info_from_lite("abc", &lite, None).expect("some");
2724        assert_eq!(info.first_prompt.as_deref(), Some("hello"));
2725        assert_eq!(info.summary, "hello");
2726        assert_eq!(info.tag.as_deref(), Some("mytag"));
2727        assert_eq!(info.git_branch.as_deref(), Some("main"));
2728        assert_eq!(info.cwd.as_deref(), Some("/p"));
2729        assert!(info.created_at.is_some());
2730    }
2731
2732    #[test]
2733    fn find_session_tag_ignores_tag_on_tool_use_lines() {
2734        // A git-tag tool_use shouldn't be picked up as a session tag -
2735        // the `"tag"` string appears but the line isn't `{"type":"tag"`.
2736        let content = r#"{"type":"user","message":{"content":"hi"}}
2737{"type":"assistant","message":{"content":[{"type":"tool_use","input":{"command":"git tag","tag":"v1.0"}}]}}
2738"#;
2739        assert_eq!(scan_tag_from(std::io::Cursor::new(content), None).0, None);
2740    }
2741
2742    #[test]
2743    fn parse_session_info_prefers_custom_title_over_last_prompt() {
2744        let content = r#"{"type":"user","message":{"content":"initial"}}
2745{"customTitle":"Curated","lastPrompt":"last"}
2746"#
2747        .to_string();
2748        let lite = LiteSessionFile {
2749            mtime: 0,
2750            size: content.len() as u64,
2751            head: content.clone(),
2752            tail: content,
2753            tag: None,
2754        };
2755        let info = parse_session_info_from_lite("abc", &lite, None).expect("some");
2756        assert_eq!(info.summary, "Curated");
2757        assert_eq!(info.custom_title.as_deref(), Some("Curated"));
2758    }
2759
2760    /// **A queued prompt's row sits where the CLI took it, not where it was
2761    /// enqueued.** The attachment is written at the press, inside the turn it
2762    /// interrupted; the `queue-operation remove` row is written when the CLI
2763    /// took the prompt. The live path draws the row at that start, so a read
2764    /// that served the attachment's position put the words above work written
2765    /// after the press (rule 25). An attachment with no remove never started
2766    /// and draws nothing, exactly as the live path drew nothing.
2767    #[test]
2768    fn parse_session_messages_places_a_queued_prompt_at_its_start() {
2769        // claude persists mid-turn queued inputs as
2770        // `{"type":"attachment", "attachment":{"type":"queued_command",
2771        // "prompt":"...", "commandMode":"prompt"}}`, and the start as a
2772        // `queue-operation remove` naming the same words.
2773        let jsonl = r#"{"type":"user","message":{"role":"user","content":"plain prompt"},"uuid":"u1","session_id":"s1","timestamp":"2026-11-03T12:00:50.000Z"}
2774{"type":"attachment","attachment":{"type":"queued_command","prompt":"queued prompt","commandMode":"prompt","source_uuid":"p1"},"uuid":"a1","session_id":"s1","timestamp":"2026-11-03T12:00:58.000Z"}
2775{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"after the press"}]},"uuid":"as1","session_id":"s1","timestamp":"2026-11-03T12:00:59.000Z"}
2776{"type":"queue-operation","operation":"remove","timestamp":"2026-11-03T12:01:10.000Z","sessionId":"s1","content":"queued prompt"}
2777{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]},"uuid":"as2","session_id":"s1","timestamp":"2026-11-03T12:01:11.000Z"}
2778{"type":"attachment","attachment":{"type":"queued_command","prompt":"never started","commandMode":"prompt"},"uuid":"a2","session_id":"s1","timestamp":"2026-11-03T12:02:00.000Z"}
2779"#;
2780        let msgs = parse_session_messages(jsonl.as_bytes()).messages;
2781        assert_eq!(msgs.len(), 4, "the prompt that never started draws no row");
2782        assert_eq!(msgs[0].uuid, "u1");
2783        assert_eq!(msgs[1].uuid, "as1");
2784        assert_eq!(msgs[3].uuid, "as2");
2785        assert!(
2786            msgs[2].uuid.starts_with("queue-remove-2026-11-03T12:01:10.000Z-"),
2787            "the row is named by the start it was served at: {:?}",
2788            msgs[2].uuid,
2789        );
2790        let content = msgs[2]
2791            .message
2792            .get("content")
2793            .and_then(Value::as_array)
2794            .expect("synthesised message has content array");
2795        assert_eq!(content.len(), 1);
2796        assert_eq!(content[0].get("type").and_then(Value::as_str), Some("queued_command"));
2797        assert_eq!(content[0].get("prompt").and_then(Value::as_str), Some("queued prompt"));
2798        assert_eq!(content[0].get("commandMode").and_then(Value::as_str), Some("prompt"));
2799        assert_eq!(
2800            content[0].get("source_uuid").and_then(Value::as_str),
2801            Some("p1"),
2802            "the prompt id the live row pairs with",
2803        );
2804        assert_eq!(
2805            msgs[2].timestamp.as_deref(),
2806            Some("2026-11-03T12:01:10.000Z"),
2807            "the start's clock, which is where the row is placed",
2808        );
2809    }
2810
2811    /// **A hook run the file persisted is a row the live chat drew.** The live
2812    /// stream sends each run as a `system/hook_response` frame; the transcript
2813    /// keeps it as an attachment. Dropping the row left a server page with
2814    /// none of the SessionStart rows the reader was looking at (rule 25), and
2815    /// every landing that merged against such a page moved the live rows it
2816    /// did not carry.
2817    ///
2818    /// **The served set is the live stream's, not the file's.** The CLI gates
2819    /// live hook frames on SessionStart and Setup, so only those events are
2820    /// served; `hook_additional_context` is aggregate context, not a run, and
2821    /// the live path drew it as no row at all.
2822    #[test]
2823    fn parse_session_messages_serves_the_hook_runs_the_live_wire_forwards() {
2824        let jsonl = r###"{"type":"attachment","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"## The PR review gate","stdout":"## The PR review gate","exitCode":0,"toolUseID":"tool-1"},"uuid":"h1","sessionId":"s1"}
2825{"type":"attachment","attachment":{"type":"hook_success","hookName":"PreToolUse:Skill","hookEvent":"PreToolUse","content":"not served live","stdout":"not served live","exitCode":0,"toolUseID":"tool-1"},"uuid":"h-skip","sessionId":"s1"}
2826{"type":"attachment","attachment":{"type":"hook_additional_context","content":["aggregate context"],"hookName":"SessionStart:startup","hookEvent":"SessionStart","toolUseID":"tool-1"},"uuid":"h-context","sessionId":"s1"}
2827{"type":"assistant","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"after the first run"}]},"uuid":"a1","sessionId":"s1"}
2828{"type":"attachment","attachment":{"type":"hook_success","hookName":"Setup:init","hookEvent":"Setup","content":["line one","line two"],"stdout":"line one\nline two","exitCode":0,"toolUseID":"tool-1"},"uuid":"h2","sessionId":"s1"}
2829{"type":"attachment","attachment":{"type":"hook_non_blocking_error","hookName":"SessionStart:startup","hookEvent":"SessionStart","stdout":"","stderr":"Failed with non-blocking status code","exitCode":141,"command":"bash til.sh","durationMs":45,"toolUseID":"tool-1"},"uuid":"h3","sessionId":"s1"}
2830{"type":"attachment","attachment":{"type":"hook_cancelled","hookName":"SessionStart:startup","hookEvent":"SessionStart","command":"check.sh","durationMs":3018,"timedOut":true,"timeoutMs":3000,"toolUseID":"tool-1"},"uuid":"h4","sessionId":"s1"}
2831{"type":"attachment","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"","stdout":"plugin lines","stderr":"","exitCode":0,"toolUseID":"tool-1"},"uuid":"h5","sessionId":"s1"}
2832"###;
2833        let msgs = parse_session_messages(jsonl.as_bytes()).messages;
2834        assert_eq!(
2835            msgs.iter().map(|m| m.uuid.clone()).collect::<Vec<_>>().join(","),
2836            "h1,a1,h2,h3,h4,h5",
2837            "the served runs, in file order, with the assistant row between them"
2838        );
2839        assert!(
2840            msgs.iter().filter(|m| !matches!(m.kind, SessionMessageKind::System)).count() == 1,
2841            "a hook row is a system frame, the shape the live path draws"
2842        );
2843
2844        let h1 = &msgs[0].message;
2845        assert_eq!(h1.get("subtype").and_then(Value::as_str), Some("hook_response"));
2846        assert_eq!(h1.get("hook_id").and_then(Value::as_str), Some("h1"));
2847        assert_eq!(h1.get("uuid").and_then(Value::as_str), Some("h1"));
2848        assert_eq!(h1.get("hook_name").and_then(Value::as_str), Some("SessionStart:startup"));
2849        assert_eq!(h1.get("hook_event").and_then(Value::as_str), Some("SessionStart"));
2850        assert_eq!(h1.get("output").and_then(Value::as_str), Some("## The PR review gate"));
2851        assert_eq!(h1.get("stdout").and_then(Value::as_str), Some("## The PR review gate"));
2852        assert_eq!(h1.get("exit_code").and_then(Value::as_i64), Some(0));
2853        assert_eq!(h1.get("outcome").and_then(Value::as_str), Some("success"));
2854        assert_eq!(h1.get("session_id").and_then(Value::as_str), Some("s1"));
2855        assert_eq!(h1.get("transcript").and_then(Value::as_bool), Some(true));
2856
2857        let h2 = &msgs[2].message;
2858        assert_eq!(
2859            h2.get("output").and_then(Value::as_str),
2860            Some("line one\nline two"),
2861            "the content array joins its lines"
2862        );
2863        assert_eq!(h2.get("hook_event").and_then(Value::as_str), Some("Setup"));
2864
2865        let h3 = &msgs[3].message;
2866        assert_eq!(h3.get("outcome").and_then(Value::as_str), Some("error"));
2867        assert_eq!(
2868            h3.get("output").and_then(Value::as_str),
2869            Some("Failed with non-blocking status code"),
2870            "the stderr fallback when the combined output and stdout are empty"
2871        );
2872        assert_eq!(h3.get("exit_code").and_then(Value::as_i64), Some(141));
2873
2874        let h4 = &msgs[4].message;
2875        assert_eq!(h4.get("outcome").and_then(Value::as_str), Some("cancelled"));
2876        assert_eq!(h4.get("output").and_then(Value::as_str), Some(""));
2877
2878        let h5 = &msgs[5].message;
2879        assert_eq!(
2880            h5.get("output").and_then(Value::as_str),
2881            Some("plugin lines"),
2882            "the stdout fallback for a row with no combined content"
2883        );
2884
2885        // And the rows still decode as the frames a page serves: the scan's
2886        // shape is only a carrier, the wire shape is what crosses.
2887        let frames = messages_from_rows(parse_session_messages(jsonl.as_bytes()).messages, "s1");
2888        assert!(
2889            matches!(&frames[0], forge_primitives::Message::HookResponse { output, exit_code, .. }
2890                if output == "## The PR review gate" && *exit_code == 0),
2891            "the row decodes to the typed live response"
2892        );
2893        assert!(
2894            matches!(&frames[1], forge_primitives::Message::Assistant { .. }),
2895            "and the assistant row keeps its place between the hook rows"
2896        );
2897        assert!(
2898            frames.iter().any(|frame| matches!(frame,
2899                forge_primitives::Message::HookResponse { hook_id, outcome, .. }
2900                    if hook_id == "h4" && outcome == "cancelled")),
2901            "and a cancelled run decodes as the same typed response, not the generic system shape"
2902        );
2903        let crossed = serde_json::to_value(&frames[0]).expect("the frame crosses");
2904        for (field, want) in [
2905            ("hook_id", "h1"),
2906            ("output", "## The PR review gate"),
2907            ("outcome", "success"),
2908            ("transcript", "true"),
2909        ] {
2910            assert_eq!(
2911                crossed.get(field).map(|held| held.to_string().trim_matches('"').to_owned()),
2912                Some(want.to_owned()),
2913                "{field} survives the decode"
2914            );
2915        }
2916    }
2917
2918    /// The count has to come out of the same streamed pass that builds
2919    /// the messages. A transcript that has compacted is usually a large
2920    /// one (a session compacts because it is huge), so a second read of
2921    /// the file to count them would land squarely on the resume path for
2922    /// exactly the sessions this number is about.
2923    ///
2924    /// **And the row rides the list, not only the count.** A resumed
2925    /// conversation draws its boundaries the way a live one does, which needs
2926    /// the frame the fold reads: the count alone leaves a page saying
2927    /// "3 compactions" over a conversation with no boundary anywhere in it.
2928    #[test]
2929    fn parse_session_messages_counts_compaction_boundaries() {
2930        let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
2931{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":1002459,"postTokens":31253}}
2932{"type":"assistant","message":{"role":"assistant","content":[]},"uuid":"as1","session_id":"s1"}
2933{"type":"system","subtype":"compact_boundary","uuid":"cb2","session_id":"s1","compactMetadata":{"trigger":"manual","preTokens":53903,"postTokens":9149}}
2934{"type":"system","subtype":"init","uuid":"x1","session_id":"s1"}
2935"#;
2936        let history = parse_session_messages(jsonl.as_bytes());
2937        assert_eq!(
2938            history.compaction_count, 2,
2939            "both boundaries counted, the other system row not"
2940        );
2941        assert_eq!(
2942            history.messages.len(),
2943            4,
2944            "both boundary rows ride the message list, and the unread system row does not"
2945        );
2946
2947        // The disk row spells its metadata the CLI's way (`compactMetadata`,
2948        // `preTokens`) and the decoder keys on the wire's snake_case nesting,
2949        // so a row handed on un-normalised falls to the generic bucket and
2950        // draws bare where a live boundary draws its counts.
2951        let boundary = history
2952            .messages
2953            .iter()
2954            .find(|message| message.uuid == "cb1")
2955            .expect("the first boundary is carried");
2956        assert_eq!(boundary.session_id, "s1");
2957        assert_eq!(
2958            boundary.message.get("compact_metadata"),
2959            Some(&serde_json::json!({
2960                "trigger": "auto",
2961                "pre_tokens": 1_002_459,
2962                "post_tokens": 31_253,
2963            })),
2964            "normalised to the shape the wire sends",
2965        );
2966        assert_eq!(
2967            serde_json::from_value::<forge_primitives::Message>(boundary.message.clone())
2968                .expect("the kept row decodes"),
2969            forge_primitives::Message::CompactBoundary {
2970                trigger: "auto".to_owned(),
2971                pre_tokens: 1_002_459,
2972                post_tokens: 31_253,
2973                uuid: "cb1".to_owned(),
2974                session_id: "s1".to_owned(),
2975                metadata_extras: forge_primitives::messages::Extras::new(),
2976                extras: forge_primitives::messages::Extras::new(),
2977            },
2978            "and decodes typed, which is what the fold reads",
2979        );
2980    }
2981
2982    #[test]
2983    fn parse_session_messages_counts_no_boundaries_in_a_never_compacted_session() {
2984        let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
2985"#;
2986        assert_eq!(parse_session_messages(jsonl.as_bytes()).compaction_count, 0);
2987    }
2988
2989    /// A turn's hooks are durable only as a `system/stop_hook_summary` row,
2990    /// and the scan dropped every `system` row that was not a compaction
2991    /// boundary, so a view opening the page fresh had nothing to draw the
2992    /// hook row from. The row is carried in the shape the wire sends, so the
2993    /// same decoder turns it into the frame the fold reads.
2994    #[test]
2995    fn parse_session_messages_keeps_a_stop_hook_summary() {
2996        let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
2997{"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"just fmt","durationMs":1400},{"command":"just check","durationMs":62000}],"hookErrors":[],"hasOutput":true,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"toolu_hook","timestamp":"2026-09-04T00:57:38.264Z","uuid":"h1","sessionId":"s1","cwd":"/proj"}
2998{"type":"system","subtype":"init","uuid":"x1","session_id":"s1"}
2999"#;
3000        let history = parse_session_messages(jsonl.as_bytes());
3001
3002        assert_eq!(history.messages.len(), 2, "the hook row joins the turn, and nothing else does");
3003        assert!(
3004            matches!(history.messages[1].kind, SessionMessageKind::System),
3005            "kept as the system row it is",
3006        );
3007        assert_eq!(
3008            history.messages[1].uuid, "h1",
3009            "under its own id, which is what a view keys on"
3010        );
3011        assert_eq!(history.messages[1].timestamp.as_deref(), Some("2026-09-04T00:57:38.264Z"));
3012
3013        let frame: forge_primitives::Message =
3014            serde_json::from_value(history.messages[1].message.clone())
3015                .expect("the kept row decodes as the frame the wire sends");
3016        let forge_primitives::Message::StopHookSummary { actions, hook_infos, .. } = frame else {
3017            panic!("a hook summary");
3018        };
3019        assert_eq!(actions, 2, "carrying the count the row reports");
3020        assert_eq!(hook_infos.len(), 2, "and one entry per hook behind it");
3021        assert_eq!(hook_infos[0].command, "just fmt", "each naming what it ran");
3022        assert_eq!(hook_infos[1].duration_ms, Some(62000), "and how long it took");
3023    }
3024
3025    /// **The read serves the frames the live conversation draws.** A
3026    /// permission refusal, an API error, an informational line and a
3027    /// notification are rows the CLI persists, and the scan kept no system
3028    /// row but its two - so a resumed seat or a reopened page lost lines the
3029    /// live view had. A top-level type this build has no variant for arrives
3030    /// as itself, exactly as the live decoder surfaces it, while the
3031    /// transcript's own bookkeeping rows still stay out of the conversation.
3032    #[test]
3033    fn parse_session_messages_keeps_the_system_rows_the_live_view_draws() {
3034        let jsonl = r#"{"type":"system","subtype":"permission_denied","tool_name":"Bash","tool_use_id":"toolu_1","decision_reason_type":"other","decision_reason":"Contains simple_expansion","message":"Contains simple_expansion","uuid":"p1","sessionId":"s1","timestamp":"2026-10-26T10:00:00.000Z"}
3035{"type":"system","subtype":"api_error","level":"error","error":{"message":"502 upstream request failed","status":502},"retryInMs":610,"uuid":"e1","sessionId":"s1","timestamp":"2026-10-26T10:00:01.000Z"}
3036{"type":"system","subtype":"informational","content":"Goal cleared","level":"warning","uuid":"i1","sessionId":"s1","timestamp":"2026-10-26T10:00:02.000Z"}
3037{"type":"system","subtype":"notification","text":"a stop hook errored","priority":"immediate","uuid":"n1","sessionId":"s1","timestamp":"2026-10-26T10:00:03.000Z"}
3038{"type":"widget_state","widget":{"name":"counter"},"uuid":"w1","sessionId":"s1","timestamp":"2026-10-26T10:00:04.000Z"}
3039{"type":"worktree-state","worktree":{},"uuid":"ws1"}
3040{"type":"mode","mode":"default","uuid":"m1"}
3041{"type":"queue-operation","operation":"enqueue","timestamp":"2026-11-03T12:00:58.593Z","sessionId":"s1","content":"a prompt the reader cancelled"}
3042{"type":"system","subtype":"turn_duration","durationMs":10,"uuid":"t1","sessionId":"s1"}
3043"#;
3044        let history = parse_session_messages(jsonl.as_bytes());
3045        assert_eq!(
3046            history.messages.iter().map(|row| row.uuid.as_str()).collect::<Vec<_>>(),
3047            // `t1` is a subtype with no face of its own yet: the client draws
3048            // it provisionally, so the read keeps it too.
3049            ["p1", "e1", "i1", "n1", "w1", "t1"],
3050            "the live rows are kept, and no bookkeeping row is",
3051        );
3052        assert!(
3053            !history.messages.iter().any(|row| {
3054                row.message.get("type").and_then(serde_json::Value::as_str)
3055                    == Some("queue-operation")
3056            }),
3057            "an enqueue-only queue-operation row is bookkeeping, so a cold read never serves it",
3058        );
3059
3060        let messages = messages_from_rows(history.messages, "s1");
3061        assert!(
3062            matches!(
3063                &messages[0],
3064                forge_primitives::Message::PermissionDenied { tool_use_id, session_id, .. }
3065                    if tool_use_id == "toolu_1" && session_id == "s1"
3066            ),
3067            "the refusal decodes typed, under the session the caller names: {:?}",
3068            messages[0],
3069        );
3070        assert!(
3071            matches!(
3072                &messages[1],
3073                forge_primitives::Message::System { subtype, .. } if subtype == "api_error"
3074            ),
3075            "the api error decodes as the generic system frame: {:?}",
3076            messages[1],
3077        );
3078        assert!(
3079            matches!(
3080                &messages[2],
3081                forge_primitives::Message::System { subtype, .. } if subtype == "informational"
3082            ),
3083            "and so does the informational line: {:?}",
3084            messages[2],
3085        );
3086        assert!(
3087            matches!(
3088                &messages[3],
3089                forge_primitives::Message::Notification { text, .. }
3090                    if text == "a stop hook errored"
3091            ),
3092            "the notification decodes typed: {:?}",
3093            messages[3],
3094        );
3095        assert!(
3096            matches!(
3097                &messages[4],
3098                forge_primitives::Message::Unknown { type_str, raw }
3099                    if type_str == "widget_state" && raw["widget"]["name"] == "counter"
3100            ),
3101            "an unknown top-level row arrives as itself, payload and all: {:?}",
3102            messages[4],
3103        );
3104        assert!(
3105            matches!(&messages[5], forge_primitives::Message::TurnDuration { ms: 10, .. }),
3106            "a system subtype with no face of its own still round-trips: {:?}",
3107            messages[5],
3108        );
3109
3110        let frame = |line: &str| -> forge_primitives::Message {
3111            serde_json::from_value(serde_json::from_str(line).expect("json"))
3112                .expect("a decodable frame")
3113        };
3114        for served in [
3115            r#"{"type":"system","subtype":"permission_denied","tool_name":"Bash","tool_use_id":"t","decision_reason_type":"other","decision_reason":"r","message":"r","uuid":"p","session_id":"s"}"#,
3116            r#"{"type":"system","subtype":"api_error","error":{"message":"down"},"uuid":"e","session_id":"s"}"#,
3117            r#"{"type":"system","subtype":"informational","content":"c","uuid":"i","session_id":"s"}"#,
3118            r#"{"type":"system","subtype":"notification","text":"t","uuid":"n","session_id":"s"}"#,
3119            // A typed system subtype the read keeps while another surface
3120            // draws it: the numbering has to count its row as the scan does.
3121            r#"{"type":"system","subtype":"task_progress","task_id":"t","description":"mapping","usage":{"total_tokens":1,"tool_uses":1,"duration_ms":1},"uuid":"tp","session_id":"s"}"#,
3122        ] {
3123            assert!(
3124                has_a_transcript_row(&frame(served)),
3125                "a frame the read serves counts as having a row: {served}",
3126            );
3127        }
3128        assert!(
3129            !has_a_transcript_row(&frame(
3130                r#"{"type":"system","subtype":"init","uuid":"t","session_id":"s"}"#,
3131            )),
3132            "a system subtype the read drops still counts as no row",
3133        );
3134        assert!(
3135            !has_a_transcript_row(&frame(
3136                r#"{"type":"system","subtype":"thinking_tokens","estimated_tokens":100,"estimated_tokens_delta":10,"uuid":"k","session_id":"s"}"#,
3137            )),
3138            "the thinking counter is a report figure, not a row",
3139        );
3140        assert!(
3141            has_a_transcript_row(&frame(
3142                r#"{"type":"system","subtype":"turn_duration","durationMs":1,"uuid":"t","session_id":"s"}"#,
3143            )),
3144            "and a subtype the client draws provisionally has its row",
3145        );
3146    }
3147
3148    /// **One spelling of the session in a read frame.** The CLI writes
3149    /// `sessionId` on a row and the wire sends `session_id`; a kept row that
3150    /// carried both named the session twice, which is what a reader expanding
3151    /// a provisional row saw raw (Ved, 2026-10-28). The scan drops the row's
3152    /// own spelling, so the read frame is shaped like the live one.
3153    #[test]
3154    fn a_kept_system_row_carries_one_session_spelling() {
3155        let jsonl = r#"{"type":"system","subtype":"api_error","error":{"message":"down"},"uuid":"e1","sessionId":"s1","timestamp":"2026-10-28T10:00:00.000Z"}
3156{"type":"system","subtype":"stop_hook_summary","hookCount":0,"hookInfos":[],"hookErrors":[],"hasOutput":false,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"toolu_h","uuid":"h1","sessionId":"s1","timestamp":"2026-10-28T10:00:01.000Z"}
3157"#;
3158        let frames = messages_from_rows(parse_session_messages(jsonl.as_bytes()).messages, "s1");
3159        assert_eq!(frames.len(), 2, "both kept system rows reach the frame");
3160        for (at, frame) in frames.iter().enumerate() {
3161            let wire = serde_json::to_value(frame).expect("a frame serialises").to_string();
3162            assert!(
3163                !wire.contains("\"sessionId\""),
3164                "frame {at} carries the CLI's spelling as well as the wire's: {wire}",
3165            );
3166            assert_eq!(
3167                wire.matches("\"session_id\"").count(),
3168                1,
3169                "frame {at} names the session exactly once: {wire}",
3170            );
3171        }
3172    }
3173
3174    /// **One list, two consumers.** The scan and the client filter the same
3175    /// `system` subtypes: a subtype in one and not the other either loses a
3176    /// row at the transcript or draws one live that a reopened conversation
3177    /// cannot serve. The two live in different languages, so this test reads
3178    /// the client's literal and compares it with this crate's const.
3179    #[test]
3180    fn the_system_skip_list_matches_the_clients() {
3181        const CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../client/src/chat/units.ts");
3182        const MARKER: &str = "const SYSTEM_DRAWN_ELSEWHERE: ReadonlySet<string> = new Set([";
3183        let source = std::fs::read_to_string(CLIENT)
3184            .unwrap_or_else(|err| panic!("the client's fold is readable at {CLIENT}: {err}"));
3185        let start =
3186            source.find(MARKER).unwrap_or_else(|| panic!("the client still declares {MARKER:?}"));
3187        let block = &source[start + MARKER.len()..];
3188        let end = block
3189            .find("]);")
3190            .unwrap_or_else(|| panic!("the client's skip list closes after its entries"));
3191        let mut theirs: Vec<String> = block[..end]
3192            .split(',')
3193            .filter_map(|entry| {
3194                let entry = entry.trim();
3195                let quoted = entry.strip_prefix('\'')?.strip_suffix('\'')?;
3196                (!quoted.is_empty()).then(|| quoted.to_owned())
3197            })
3198            .collect();
3199        assert!(
3200            !theirs.is_empty(),
3201            "the client's skip list parsed to nothing, so this test compared nothing",
3202        );
3203        theirs.sort();
3204        let mut ours: Vec<String> =
3205            SYSTEM_ROWS_DRAWN_ELSEWHERE.iter().map(|name| (*name).to_owned()).collect();
3206        ours.sort();
3207        assert_eq!(
3208            ours, theirs,
3209            "the scan's SYSTEM_ROWS_DRAWN_ELSEWHERE and the client's SYSTEM_DRAWN_ELSEWHERE \
3210             have diverged; a subtype belongs in both or neither",
3211        );
3212    }
3213
3214    /// **Every row a transcript read keeps reaches the wire stamped**, which
3215    /// is what a page's clock is read from: an open page anchors a turn on
3216    /// the first row that carries a stamp, and the row that opens a turn is
3217    /// the reader's own - so a kind that loses its stamp is a turn that draws
3218    /// with no age at all until an assistant frame lands.
3219    #[test]
3220    fn every_row_a_transcript_read_keeps_reaches_the_wire_stamped() {
3221        let jsonl = r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"uuid":"u1","sessionId":"s1","timestamp":"2026-10-15T05:18:05.217Z"}
3222{"type":"attachment","attachment":{"type":"queued_command","prompt":"queued words","commandMode":"prompt"},"uuid":"a1","session_id":"s1","timestamp":"2026-10-15T05:19:00.100Z"}
3223{"type":"queue-operation","operation":"remove","timestamp":"2026-10-15T05:19:03.000Z","sessionId":"s1","content":"queued words"}
3224{"type":"assistant","message":{"id":"m1","role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"working"}]},"uuid":"a1","sessionId":"s1","timestamp":"2026-10-15T05:19:05.544Z"}
3225{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[],"hookErrors":[],"hasOutput":false,"level":"suggestion","preventedContinuation":false,"stopReason":"","toolUseID":"tu1","uuid":"h1","sessionId":"s1","timestamp":"2026-10-15T05:19:40.632Z"}
3226{"type":"system","subtype":"compact_boundary","uuid":"b1","sessionId":"s1","compactMetadata":{"trigger":"auto","preTokens":1018466,"postTokens":19526},"timestamp":"2026-10-15T05:20:11.004Z"}
3227"#;
3228        let history = parse_session_messages(jsonl.as_bytes());
3229        let messages = messages_from_rows(history.messages, "s1");
3230
3231        let stamps: Vec<(String, Option<String>)> = messages
3232            .iter()
3233            .map(|message| {
3234                let value = serde_json::to_value(message).expect("encode");
3235                let kind = value["type"].as_str().unwrap_or("?").to_owned();
3236                (kind, value["timestamp"].as_str().map(str::to_owned))
3237            })
3238            .collect();
3239        assert_eq!(
3240            stamps,
3241            vec![
3242                ("user".to_owned(), Some("2026-10-15T05:18:05.217Z".to_owned())),
3243                ("user".to_owned(), Some("2026-10-15T05:19:03.000Z".to_owned())),
3244                ("assistant".to_owned(), Some("2026-10-15T05:19:05.544Z".to_owned())),
3245                ("system".to_owned(), Some("2026-10-15T05:19:40.632Z".to_owned())),
3246                ("system".to_owned(), Some("2026-10-15T05:20:11.004Z".to_owned())),
3247            ],
3248            "each kept kind must carry the row's own stamp, in the row's own order",
3249        );
3250    }
3251
3252    #[test]
3253    fn parse_session_messages_skips_attachment_without_queued_command() {
3254        // Other attachment subtypes (image, document, etc.) are not
3255        // user-bubble-worthy - keep skipping them.
3256        let jsonl = r#"{"type":"attachment","attachment":{"type":"image","source":{}},"uuid":"a1","session_id":"s1"}
3257{"type":"assistant","message":{"role":"assistant","content":[]},"uuid":"as1","session_id":"s1"}
3258"#;
3259        let msgs = parse_session_messages(jsonl.as_bytes()).messages;
3260        assert_eq!(msgs.len(), 1, "non-queued_command attachment must be skipped");
3261        assert!(matches!(msgs[0].kind, SessionMessageKind::Assistant));
3262    }
3263
3264    /// A transcript can carry a block of records twice: the second copies
3265    /// share the `uuid`, `type` and message content of the first and differ
3266    /// only in metadata fields, and they sit right after a later run's
3267    /// session-state rows. The whole-file read handed both copies on, so a
3268    /// resumed or freshly-opened conversation drew the block twice. The
3269    /// `uuid` is the key that absorbs it - unique per record and stable
3270    /// across the pair.
3271    #[test]
3272    fn parse_session_messages_reads_a_duplicated_block_once() {
3273        let jsonl = r#"{"type":"user","message":{"role":"user","content":"first"},"uuid":"u1","session_id":"s1","timestamp":"2026-09-04T02:23:48.850Z"}
3274{"type":"assistant","message":{"id":"msg_01","role":"assistant","content":[{"type":"text","text":"answer"}]},"uuid":"a1","session_id":"s1","timestamp":"2026-09-04T02:23:50.062Z"}
3275{"type":"last-prompt","uuid":"lp1"}
3276{"type":"tag","tag":"t"}
3277{"type":"user","message":{"role":"user","content":"first"},"uuid":"u1","slug":"structured-pondering-nygaard","session_id":"s1","timestamp":"2026-09-04T02:23:48.850Z"}
3278{"type":"assistant","message":{"id":"msg_01","role":"assistant","content":[{"type":"text","text":"answer"}]},"uuid":"a1","slug":"structured-pondering-nygaard","session_id":"s1","timestamp":"2026-09-04T02:23:50.062Z"}
3279"#;
3280        let history = parse_session_messages(jsonl.as_bytes());
3281        assert_eq!(
3282            history.messages.iter().map(|row| row.uuid.as_str()).collect::<Vec<_>>(),
3283            ["u1", "a1"],
3284            "the duplicated block reads once, in its original order, and the session-state rows \
3285             stay out",
3286        );
3287    }
3288
3289    /// The dedupe keys on `uuid`, so a boundary row counted from a duplicated
3290    /// block is one compaction, not two: the second copy is the same cut.
3291    #[test]
3292    fn parse_session_messages_counts_a_duplicated_boundary_once() {
3293        let jsonl = r#"{"type":"user","message":{"role":"user","content":"one"},"uuid":"u1","session_id":"s1"}
3294{"type":"system","subtype":"compact_boundary","uuid":"cb1","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":1002459,"postTokens":31253}}
3295{"type":"last-prompt","uuid":"lp1"}
3296{"type":"system","subtype":"compact_boundary","uuid":"cb1","slug":"s","session_id":"s1","compactMetadata":{"trigger":"auto","preTokens":1002459,"postTokens":31253}}
3297"#;
3298        let history = parse_session_messages(jsonl.as_bytes());
3299        assert_eq!(history.compaction_count, 1, "one boundary, written twice");
3300        assert_eq!(history.messages.len(), 2, "and drawn once");
3301    }
3302
3303    fn session_info_with_tag(session_id: &str, tag: Option<&str>) -> SDKSessionInfo {
3304        SDKSessionInfo {
3305            session_id: session_id.to_string(),
3306            summary: "test".to_string(),
3307            last_modified: 0,
3308            file_size: None,
3309            custom_title: None,
3310            first_prompt: None,
3311            git_branch: None,
3312            cwd: None,
3313            storage_key: String::new(),
3314            tag: tag.map(str::to_string),
3315            created_at: None,
3316        }
3317    }
3318
3319    #[test]
3320    fn parse_session_info_filter_excludes_worker_tag_by_default() {
3321        let content = "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hi\"}}\n{\"type\":\"tag\",\"tag\":\"forge:worker:reviewer\"}\n".to_string();
3322        let lite = LiteSessionFile {
3323            mtime: 0,
3324            size: content.len() as u64,
3325            head: content.clone(),
3326            tail: content,
3327            tag: Some("forge:worker:reviewer".to_string()),
3328        };
3329        let info = parse_session_info_from_lite("abc", &lite, None).expect("some");
3330        assert_eq!(info.tag.as_deref(), Some("forge:worker:reviewer"));
3331        // The session is still parseable - filtering happens at the
3332        // list_sessions caller layer, not here.
3333        assert!(should_exclude_worker_tag(&info));
3334    }
3335
3336    #[test]
3337    fn should_exclude_worker_tag_recognises_prefix() {
3338        let info = session_info_with_tag("s1", Some("forge:worker:reviewer"));
3339        assert!(should_exclude_worker_tag(&info));
3340    }
3341
3342    #[test]
3343    fn should_exclude_worker_tag_passes_lead_and_untagged() {
3344        let lead = session_info_with_tag("s1", Some("forge:lead"));
3345        let untagged = session_info_with_tag("s2", None);
3346        assert!(!should_exclude_worker_tag(&lead));
3347        assert!(!should_exclude_worker_tag(&untagged));
3348    }
3349
3350    /// A one-row transcript, tagged when `tag` is given.
3351    fn transcript(tag: Option<&str>) -> String {
3352        use std::fmt::Write as _;
3353        let mut body = "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\
3354                        \"message\":{\"content\":\"hi\"}}\n"
3355            .to_owned();
3356        if let Some(tag) = tag {
3357            let _ = writeln!(body, "{{\"type\":\"tag\",\"tag\":\"{tag}\"}}");
3358        }
3359        body
3360    }
3361
3362    /// The listing's ids, in the order the scan answered them.
3363    fn session_ids(entries: &[SDKSessionInfo]) -> Vec<String> {
3364        entries.iter().map(|info| info.session_id.clone()).collect()
3365    }
3366
3367    /// The scan's directory for `cwd`, which the listing reads.
3368    fn dir_for(config: &Path, cwd: &str) -> PathBuf {
3369        projects_dir_for(config).join(project_key_for_directory(Some(cwd)))
3370    }
3371
3372    /// An explicit mtime, so an ordering assertion does not rest on files
3373    /// written inside the same millisecond.
3374    fn set_age(path: &Path, seconds: u64) {
3375        let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds.max(1));
3376        fs::File::options()
3377            .write(true)
3378            .open(path)
3379            .expect("open for its mtime")
3380            .set_modified(when)
3381            .expect("set its mtime");
3382    }
3383
3384    /// A listing is the seat's own sessions. A worker's label is what it
3385    /// can resume, and the directory cannot answer that alone: a non-git
3386    /// worker shares the project root with its lead, so the two seats'
3387    /// transcripts sit in one directory. Catches a filter that hides
3388    /// every worker tag whatever the seat, one that keeps another label's
3389    /// sessions, and one that matches a label by prefix - `alpha` must not
3390    /// offer `alpha-2`'s sessions, which a resume would adopt as its own.
3391    #[tokio::test]
3392    async fn a_listing_keeps_the_seats_own_sessions_and_no_others() {
3393        let tmp = tempfile::tempdir().unwrap();
3394        let cwd = tmp.path().join("project");
3395        fs::create_dir_all(&cwd).unwrap();
3396        let cwd = cwd.to_string_lossy().into_owned();
3397        let dir = dir_for(tmp.path(), &cwd);
3398        fs::create_dir_all(&dir).unwrap();
3399        write_session_jsonl(&dir, "lead-own", &transcript(None));
3400        write_session_jsonl(&dir, "alpha-own", &transcript(Some("forge:worker:alpha")));
3401        write_session_jsonl(&dir, "alpha-2-own", &transcript(Some("forge:worker:alpha-2")));
3402        write_session_jsonl(&dir, "beta-own", &transcript(Some("forge:worker:beta")));
3403
3404        let lead = list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Hidden, None).await;
3405        assert_eq!(
3406            session_ids(&lead),
3407            vec!["lead-own"],
3408            "a lead's listing is the project's own sessions, never its workers'"
3409        );
3410
3411        let worker =
3412            list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Only("alpha".to_owned()), None)
3413                .await;
3414        assert_eq!(
3415            session_ids(&worker),
3416            vec!["alpha-own"],
3417            "a worker's listing is the sessions its own label ran, not its prefix kin's"
3418        );
3419    }
3420
3421    /// **A worker's listing does not read what its cached tags already rule
3422    /// out** (#2567). The head-and-tail read of every transcript in the
3423    /// directory was the whole cost of a `/resume` list on a non-git worker,
3424    /// which shares the project root with its lead. The answer is the same
3425    /// rows either way, so the skip is read off its own log line.
3426    #[tokio::test]
3427    async fn a_workers_listing_skips_what_its_cached_tags_rule_out() {
3428        let logs = LogCapture::default();
3429        let subscriber = tracing_subscriber::fmt()
3430            .with_max_level(tracing::Level::DEBUG)
3431            .with_ansi(false)
3432            .with_writer(logs.clone())
3433            .finish();
3434        let _guard = tracing::subscriber::set_default(subscriber);
3435
3436        let tmp = tempfile::tempdir().unwrap();
3437        let cwd = tmp.path().join("project");
3438        fs::create_dir_all(&cwd).unwrap();
3439        let cwd = cwd.to_string_lossy().into_owned();
3440        let dir = dir_for(tmp.path(), &cwd);
3441        fs::create_dir_all(&dir).unwrap();
3442        write_session_jsonl(&dir, "lead-own", &transcript(None));
3443        write_session_jsonl(&dir, "beta-own", &transcript(Some("forge:worker:beta")));
3444        write_session_jsonl(&dir, "alpha-own", &transcript(Some("forge:worker:alpha")));
3445
3446        // A boot scan's cache: one full pass, then exactly what it learned.
3447        let first = std::sync::Arc::new(SessionTagCache::new(std::collections::HashMap::new()));
3448        let read =
3449            list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Hidden, Some(&first)).await;
3450        assert_eq!(read.len(), 1, "precondition: a lead's listing hides its workers");
3451        let prior: std::collections::HashMap<String, SessionTagScan> =
3452            first.updates().into_iter().collect();
3453        assert_eq!(prior.len(), 3, "and the pass still read every transcript's tag");
3454        let warm = std::sync::Arc::new(SessionTagCache::new(prior));
3455
3456        let worker = list_sessions(
3457            tmp.path(),
3458            Some(&cwd),
3459            None,
3460            0,
3461            Workers::Only("alpha".to_owned()),
3462            Some(&warm),
3463        )
3464        .await;
3465        assert_eq!(
3466            session_ids(&worker),
3467            vec!["alpha-own"],
3468            "the listing is still the label's own sessions",
3469        );
3470        let logged = String::from_utf8(logs.0.lock().clone()).unwrap();
3471        assert!(
3472            logged.contains("session_scan_narrowed") && logged.contains("skipped=2"),
3473            "the two transcripts the cache ruled out are named as skipped: {logged}",
3474        );
3475
3476        // **A transcript that grew is read, not skipped.** The bytes since the
3477        // last scan can carry a `/new` re-tag, so a cache entry behind its
3478        // file proves nothing - and here the new tag makes the file the
3479        // worker's own.
3480        let mut retagged = transcript(Some("forge:worker:beta"));
3481        retagged.push_str("{\"type\":\"tag\",\"tag\":\"forge:worker:alpha\"}\n");
3482        write_session_jsonl(&dir, "beta-own", &retagged);
3483        let again = list_sessions(
3484            tmp.path(),
3485            Some(&cwd),
3486            None,
3487            0,
3488            Workers::Only("alpha".to_owned()),
3489            Some(&warm),
3490        )
3491        .await;
3492        // The scan orders by millisecond mtime, so rows written inside one
3493        // millisecond have no promised order among themselves.
3494        let mut again_ids = session_ids(&again);
3495        again_ids.sort();
3496        assert_eq!(
3497            again_ids,
3498            vec!["alpha-own", "beta-own"],
3499            "a transcript that changed after its scan must be read, not skipped",
3500        );
3501
3502        // **A path the cache has never seen is read, not skipped.** The cache
3503        // cannot vouch for a transcript created after its scan.
3504        write_session_jsonl(&dir, "gamma-own", &transcript(Some("forge:worker:alpha")));
3505        let fresh = list_sessions(
3506            tmp.path(),
3507            Some(&cwd),
3508            None,
3509            0,
3510            Workers::Only("alpha".to_owned()),
3511            Some(&warm),
3512        )
3513        .await;
3514        let fresh_ids = session_ids(&fresh);
3515        assert!(
3516            fresh_ids.iter().any(|id| id.as_str() == "gamma-own"),
3517            "a transcript the cache has not scanned must be read: {fresh_ids:?}",
3518        );
3519    }
3520
3521    /// **The cap bounds the reads, not only the answer** (#2567). A label
3522    /// with more runs than a page serves must not pay a head-and-tail read
3523    /// per transcript: the newest are read first and the scan stops when the
3524    /// answer is full.
3525    #[tokio::test]
3526    async fn a_capped_listing_reads_only_the_rows_it_serves() {
3527        let logs = LogCapture::default();
3528        let subscriber = tracing_subscriber::fmt()
3529            .with_max_level(tracing::Level::DEBUG)
3530            .with_ansi(false)
3531            .with_writer(logs.clone())
3532            .finish();
3533        let _guard = tracing::subscriber::set_default(subscriber);
3534
3535        let tmp = tempfile::tempdir().unwrap();
3536        let cwd = tmp.path().join("project");
3537        fs::create_dir_all(&cwd).unwrap();
3538        let cwd = cwd.to_string_lossy().into_owned();
3539        let dir = dir_for(tmp.path(), &cwd);
3540        fs::create_dir_all(&dir).unwrap();
3541        // **Created oldest-first, so directory order and mtime order
3542        // disagree.** run-7 is written first and run-0 last; a scan that
3543        // trusts readdir's order answers the oldest rows and the assertion
3544        // below fails.
3545        for at in (0..8).rev() {
3546            let path = write_session_jsonl(
3547                &dir,
3548                &format!("run-{at}"),
3549                &transcript(Some("forge:worker:alpha")),
3550            );
3551            // run-0 is the newest, run-7 the oldest.
3552            set_age(&path, at + 1);
3553        }
3554
3555        let listed = list_sessions(
3556            tmp.path(),
3557            Some(&cwd),
3558            Some(2),
3559            0,
3560            Workers::Only("alpha".to_owned()),
3561            None,
3562        )
3563        .await;
3564        assert_eq!(session_ids(&listed), vec!["run-0", "run-1"], "the two newest rows");
3565
3566        let logged = String::from_utf8(logs.0.lock().clone()).unwrap();
3567        assert!(
3568            logged.contains("session_scan_read") && logged.contains("read=2"),
3569            "the scan read every transcript for a two-row page: {logged}",
3570        );
3571
3572        // The control: no cap still reads the whole directory.
3573        let all =
3574            list_sessions(tmp.path(), Some(&cwd), None, 0, Workers::Only("alpha".to_owned()), None)
3575                .await;
3576        assert_eq!(all.len(), 8, "an uncapped listing reads every transcript");
3577    }
3578
3579    /// The worker filter lands BEFORE the cap, so a listing is never
3580    /// shortened by rows it would not have carried. Catches the cap-first
3581    /// order: with the newest transcript a worker's, a cap of one spends
3582    /// its single slot on that row and answers nothing.
3583    #[tokio::test]
3584    async fn the_cap_is_applied_to_the_rows_the_listing_carries() {
3585        let tmp = tempfile::tempdir().unwrap();
3586        let cwd = tmp.path().join("project");
3587        fs::create_dir_all(&cwd).unwrap();
3588        let cwd = cwd.to_string_lossy().into_owned();
3589        let dir = dir_for(tmp.path(), &cwd);
3590        fs::create_dir_all(&dir).unwrap();
3591        let held = write_session_jsonl(&dir, "lead-own", &transcript(None));
3592        // The worker's row is the newest, so a cap spent before the filter
3593        // lands on it and the listing comes back empty.
3594        let newest =
3595            write_session_jsonl(&dir, "alpha-own", &transcript(Some("forge:worker:alpha")));
3596        set_age(&held, 600);
3597        set_age(&newest, 60);
3598
3599        let capped = list_sessions(tmp.path(), Some(&cwd), Some(1), 0, Workers::Hidden, None).await;
3600        assert_eq!(
3601            session_ids(&capped),
3602            vec!["lead-own"],
3603            "the cap must be spent on the rows the listing carries"
3604        );
3605    }
3606
3607    // -----------------------------------------------------------------
3608    // Tag-scan regression tests.
3609    //
3610    // The tag row is written ONCE at session start and persists for the
3611    // life of the JSONL; PR #167's `/new` flow can re-write a tag
3612    // mid-file. The lite head/tail window misses both cases on large
3613    // transcripts. These tests exercise the full-file scan path via
3614    // `read_session_info`.
3615    // -----------------------------------------------------------------
3616
3617    fn write_session_jsonl(dir: &Path, name: &str, body: &str) -> PathBuf {
3618        let path = dir.join(format!("{name}.jsonl"));
3619        fs::write(&path, body).unwrap();
3620        path
3621    }
3622
3623    #[test]
3624    fn read_session_info_finds_tag_at_start_of_large_file() {
3625        // The bug shape: tag row is written at session start and sits
3626        // permanently near byte 0. Tail-only extraction scans only
3627        // the last LITE_READ_BUF_SIZE bytes, so for any transcript
3628        // > ~130 KB the tag falls outside the tail window and resume
3629        // sees `info.tag = None`. Build a transcript well past two
3630        // window-widths so the bug is unambiguous.
3631        let window = usize::try_from(LITE_READ_BUF_SIZE).unwrap();
3632        let tmp = tempfile::tempdir().unwrap();
3633        let mut body = String::new();
3634        body.push_str(
3635            "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hello\"}}\n",
3636        );
3637        body.push_str("{\"type\":\"tag\",\"tag\":\"forge:worker:reviewer\"}\n");
3638        let filler = "{\"type\":\"assistant\",\"message\":{\"content\":\"x\"}}\n";
3639        while body.len() < window * 3 {
3640            body.push_str(filler);
3641        }
3642        let path = write_session_jsonl(tmp.path(), "abc", &body);
3643
3644        let info = read_session_info(&path, None).expect("session info parsed");
3645        assert_eq!(info.tag.as_deref(), Some("forge:worker:reviewer"));
3646    }
3647
3648    #[test]
3649    fn read_session_info_finds_tag_mid_file() {
3650        // The `/new` case (PR #167): user fires `/new` mid-session and
3651        // the CLI appends a fresh tag row at the current cursor. For a
3652        // large transcript that's neither head nor tail, it lands in
3653        // the middle, where neither window sees it.
3654        let window = usize::try_from(LITE_READ_BUF_SIZE).unwrap();
3655        let tmp = tempfile::tempdir().unwrap();
3656        let mut body = String::new();
3657        body.push_str(
3658            "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hello\"}}\n",
3659        );
3660        let filler = "{\"type\":\"assistant\",\"message\":{\"content\":\"x\"}}\n";
3661        // Push past the head window with filler, then plant the tag.
3662        while body.len() < window + 4_096 {
3663            body.push_str(filler);
3664        }
3665        body.push_str("{\"type\":\"tag\",\"tag\":\"forge:worker:tester\"}\n");
3666        // Then push past the tail window with more filler so the tag
3667        // sits in the middle region neither head nor tail covers.
3668        while body.len() < window * 3 {
3669            body.push_str(filler);
3670        }
3671        let path = write_session_jsonl(tmp.path(), "abc", &body);
3672
3673        let info = read_session_info(&path, None).expect("session info parsed");
3674        assert_eq!(info.tag.as_deref(), Some("forge:worker:tester"));
3675    }
3676
3677    /// **The title read does not scan for the tag.** A tag scan covers the
3678    /// whole file, and the caller that reads one transcript for its title (a
3679    /// catalog row mirrored at Connected) must not pay for bytes it does not
3680    /// use; routing that call back through the full read would restore the
3681    /// scan, and the tag on the answer is what says so.
3682    #[test]
3683    fn a_title_read_carries_the_title_and_not_the_tag() {
3684        let tmp = tempfile::tempdir().unwrap();
3685        let tagged = "{\"type\":\"user\",\"message\":{\"content\":\"the work I left\"}}\n\
3686                      {\"type\":\"tag\",\"tag\":\"forge:worker:gone\"}\n";
3687        let path = write_session_jsonl(tmp.path(), "abc", tagged);
3688
3689        let info = read_session_info_at(&path).expect("a readable transcript");
3690
3691        assert_eq!(info.summary, "the work I left", "the title is read");
3692        assert_eq!(info.tag, None, "and the tag is not, because that scan is the whole file");
3693    }
3694
3695    /// A transcript that shrank was truncated or replaced, so an offset
3696    /// recorded against the old bytes describes nothing in the new ones.
3697    /// Resuming past the end of the replacement would read no bytes at
3698    /// all and keep answering with a tag the file no longer contains.
3699    #[test]
3700    fn a_shrunk_transcript_is_scanned_again_rather_than_resumed() {
3701        let tmp = tempfile::tempdir().unwrap();
3702        let tagged = "{\"type\":\"user\",\"message\":{\"content\":\"hi\"}}\n\
3703                      {\"type\":\"tag\",\"tag\":\"forge:worker:gone\"}\n";
3704        let path = write_session_jsonl(tmp.path(), "abc", tagged);
3705
3706        let cache = SessionTagCache::default();
3707        assert_eq!(
3708            read_session_lite(&path, Some(&cache), true).unwrap().tag.as_deref(),
3709            Some("forge:worker:gone"),
3710            "the first scan must find the tag it will later be asked to forget"
3711        );
3712
3713        // Replay the cache the store would have handed back, then replace
3714        // the file with shorter, untagged content.
3715        let primed = SessionTagCache::new(cache.updates().into_iter().collect());
3716        std::fs::write(&path, "{\"type\":\"user\",\"message\":{\"content\":\"x\"}}\n").unwrap();
3717
3718        assert_eq!(
3719            read_session_lite(&path, Some(&primed), true).unwrap().tag,
3720            None,
3721            "a replaced transcript must be re-scanned, not answered from the old offset"
3722        );
3723    }
3724
3725    /// A resumed scan must agree with a whole-file one for every split
3726    /// point, and the hard case is a split inside a line: the writer had
3727    /// only flushed half a tag row when the previous scan ran. Crediting
3728    /// the file's length rather than the last newline would restart in
3729    /// the middle of that row, fail the prefix test, and lose the tag
3730    /// for good - it is never re-read.
3731    #[test]
3732    fn a_resumed_scan_sees_a_tag_that_was_half_written_when_it_stopped() {
3733        let settled = "{\"type\":\"user\",\"message\":{\"content\":\"hi\"}}\n";
3734        let tag_row = "{\"type\":\"tag\",\"tag\":\"forge-worker-x\"}\n";
3735        // The first scan sees a whole line plus the front of the tag row.
3736        let torn = &tag_row[..20];
3737        let (found, _project, state) =
3738            scan_tag_from(std::io::Cursor::new(format!("{settled}{torn}")), None);
3739        assert_eq!(found, None, "half a tag row carries no closing quote to parse");
3740        assert_eq!(
3741            state.scanned_len,
3742            settled.len() as u64,
3743            "an unterminated line must not be credited, or it is skipped on resume"
3744        );
3745
3746        // The writer finishes the row; the next scan resumes from the
3747        // recorded offset, exactly as the production path seeks.
3748        let whole = format!("{settled}{tag_row}");
3749        let resume_at = usize::try_from(state.scanned_len).unwrap();
3750        let (resumed, _project, _) =
3751            scan_tag_from(std::io::Cursor::new(&whole[resume_at..]), Some(state));
3752        assert_eq!(
3753            resumed.as_deref(),
3754            Some("forge-worker-x"),
3755            "a resumed scan must find the completed row a whole-file scan would"
3756        );
3757        assert_eq!(
3758            resumed,
3759            scan_tag_from(std::io::Cursor::new(whole.as_str()), None).0,
3760            "resuming must agree with scanning the whole file"
3761        );
3762    }
3763
3764    #[test]
3765    fn read_session_info_picks_last_tag_when_multiple() {
3766        // Preserve PR #167 semantics: when the JSONL carries multiple
3767        // tag rows (original spawn tag + later `/new` re-tag), the
3768        // most recent one wins. File must be larger than two window
3769        // widths so neither tag lands in head or tail, otherwise the
3770        // old tail-only path would coincidentally pick the right one
3771        // and mask a behavioural regression.
3772        let window = usize::try_from(LITE_READ_BUF_SIZE).unwrap();
3773        let tmp = tempfile::tempdir().unwrap();
3774        let mut body = String::new();
3775        body.push_str(
3776            "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hi\"}}\n",
3777        );
3778        body.push_str("{\"type\":\"tag\",\"tag\":\"first\"}\n");
3779        let filler = "{\"type\":\"assistant\",\"message\":{\"content\":\"x\"}}\n";
3780        while body.len() < window + 4_096 {
3781            body.push_str(filler);
3782        }
3783        body.push_str("{\"type\":\"tag\",\"tag\":\"second\"}\n");
3784        while body.len() < window * 3 {
3785            body.push_str(filler);
3786        }
3787        let path = write_session_jsonl(tmp.path(), "abc", &body);
3788
3789        let info = read_session_info(&path, None).expect("session info parsed");
3790        assert_eq!(info.tag.as_deref(), Some("second"));
3791    }
3792
3793    #[test]
3794    fn read_session_info_reports_storage_dir_key() {
3795        // The storage key is the `projects/<KEY>/` dir the transcript
3796        // physically lives in - ground truth, unlike the head-read cwd.
3797        let tmp = tempfile::tempdir().unwrap();
3798        let storage_key = "-Users-me-Projects-playground--claude-worktrees-gpt-tutor";
3799        let project_dir = tmp.path().join(storage_key);
3800        fs::create_dir_all(&project_dir).unwrap();
3801        let path = write_session_jsonl(
3802            &project_dir,
3803            "abc",
3804            "{\"type\":\"user\",\"timestamp\":\"2026-04-22T00:00:00.000Z\",\"message\":{\"content\":\"hi\"}}\n",
3805        );
3806
3807        let info = read_session_info(&path, None).expect("session info parsed");
3808        assert_eq!(
3809            info.storage_key, storage_key,
3810            "the scanned session reports the projects/<KEY>/ dir it lives in",
3811        );
3812    }
3813
3814    /// A sub-agent's own frames live only in the CLI's sidecar
3815    /// (`<session>/subagents/agent-*.jsonl`), which nothing read: after a
3816    /// resume the dispatch's card held no calls (#1763). The transcript read
3817    /// folds each sidecar under the first dispatch its chain answers - where
3818    /// the dispatch ran, not at the end - which is the join the card fold
3819    /// reads.
3820    #[test]
3821    fn get_session_messages_folds_subagent_sidecars_under_their_dispatch() {
3822        let config_dir = tempfile::tempdir().unwrap();
3823        let session_id = "550e8400-e29b-41d4-a716-446655440000";
3824        let directory = "/Users/me/Projects/playground";
3825        let dir = project_dir_for(config_dir.path(), directory);
3826        fs::create_dir_all(&dir).unwrap();
3827        write_session_jsonl(
3828            &dir,
3829            session_id,
3830            "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
3831             {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n\
3832             {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]},\"uuid\":\"a2\"}\n",
3833        );
3834        // The CLI's own layout: one sidecar per sub-agent, the dispatch it
3835        // ran under named in the sibling meta file.
3836        let sidecars = dir.join(session_id).join("subagents");
3837        fs::create_dir_all(&sidecars).unwrap();
3838        fs::write(
3839            sidecars.join("agent-abc.meta.json"),
3840            "{\"agentType\":\"Explore\",\"description\":\"map it\",\"toolUseId\":\"toolu_dispatch\"}",
3841        )
3842        .unwrap();
3843        fs::write(
3844            sidecars.join("agent-abc.jsonl"),
3845            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_child\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"uuid\":\"c1\"}\n\
3846             {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_child\",\"content\":\"out\"}]},\"uuid\":\"c2\"}\n",
3847        )
3848        .unwrap();
3849        // Two sidecars can share one dispatch, and both join under it.
3850        fs::write(
3851            sidecars.join("agent-def.meta.json"),
3852            "{\"agentType\":\"Explore\",\"description\":\"more\",\"toolUseId\":\"toolu_dispatch\"}",
3853        )
3854        .unwrap();
3855        fs::write(
3856            sidecars.join("agent-def.jsonl"),
3857            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]},\"uuid\":\"d1\"}\n",
3858        )
3859        .unwrap();
3860        // A sidecar whose dispatch is not in the transcript joins nothing;
3861        // a meta with no sidecar file is skipped rather than failing the read.
3862        fs::write(
3863            sidecars.join("agent-zzz.meta.json"),
3864            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_absent\"}",
3865        )
3866        .unwrap();
3867        fs::write(
3868            sidecars.join("agent-zzz.jsonl"),
3869            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"orphan\"}]},\"uuid\":\"x1\"}\n",
3870        )
3871        .unwrap();
3872        fs::write(
3873            sidecars.join("agent-nofile.meta.json"),
3874            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
3875        )
3876        .unwrap();
3877        // A sidecar with no meta at all, a meta that does not parse, and a
3878        // meta whose dispatch id is empty are each dropped rather than
3879        // failing the read or joining anything.
3880        for (stem, frame) in [("agent-lone", "y1"), ("agent-bad", "y2"), ("agent-empty", "y3")] {
3881            if stem != "agent-lone" {
3882                let meta = if stem == "agent-bad" {
3883                    "not json".to_owned()
3884                } else {
3885                    "{\"agentType\":\"Explore\",\"toolUseId\":\"\"}".to_owned()
3886                };
3887                fs::write(sidecars.join(format!("{stem}.meta.json")), meta).unwrap();
3888            }
3889            fs::write(
3890                sidecars.join(format!("{stem}.jsonl")),
3891                format!(
3892                    "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"{frame}\"}}]}},\"uuid\":\"{frame}\"}}\n"
3893                ),
3894            )
3895            .unwrap();
3896        }
3897
3898        let messages =
3899            get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
3900
3901        let uuids: Vec<_> = messages.iter().map(|message| message.uuid.as_str()).collect();
3902        assert_eq!(uuids.len(), 6, "the transcript's three rows plus three sidecar frames");
3903        assert_eq!(&uuids[..2], &["a1", "u1"], "the dispatch and its result come first");
3904        assert_eq!(uuids[5], "a2", "and the conversation's later row stays last");
3905        let mut folded = uuids[2..5].to_vec();
3906        folded.sort_unstable();
3907        assert_eq!(
3908            folded,
3909            vec!["c1", "c2", "d1"],
3910            "every sidecar sharing the dispatch sits where it ran, not at the end",
3911        );
3912        for absent in ["x1", "y1", "y2", "y3"] {
3913            assert!(
3914                !uuids.contains(&absent),
3915                "'{absent}' has no joinable dispatch and must not be folded",
3916            );
3917        }
3918        assert_eq!(
3919            messages.iter().filter(|message| message.parent_tool_use_id.is_none()).count(),
3920            3,
3921            "the transcript's own rows stay the main thread's",
3922        );
3923        for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
3924            assert_eq!(
3925                message.parent_tool_use_id.as_deref(),
3926                Some("toolu_dispatch"),
3927                "each folded frame names the dispatch id itself, not the stem or the agent id",
3928            );
3929        }
3930        // The chat-row read takes the session's own rows only: a folded
3931        // sidecar frame here would be drawn as a chat row no card owns.
3932        let main_thread =
3933            read_session_messages_main_thread(config_dir.path(), session_id, Some(directory))
3934                .messages;
3935        let main_uuids: Vec<_> = main_thread.iter().map(|message| message.uuid.as_str()).collect();
3936        assert_eq!(
3937            main_uuids,
3938            vec!["a1", "u1", "a2"],
3939            "the main-thread read leaves every sub-agent frame out",
3940        );
3941    }
3942
3943    /// **A nested dispatch's sidecar folds under the dispatch that owns it.**
3944    /// A nested dispatch's `Task` call is a call inside the outer agent's
3945    /// sidecar, never a row the transcript carries, so its sidecar used to
3946    /// join against whatever the growing message list held: the nested id
3947    /// when the parent had been spliced first, nothing when the child was
3948    /// read first. The fold now walks the dispatch chain to the first
3949    /// dispatch the transcript answers, three levels down included, and
3950    /// every nested row lands under that expansion.
3951    ///
3952    /// The read is taken twice with the meta mtimes swapped: the child may
3953    /// sort before or after its parent, and neither order may move a row.
3954    /// Each block is emitted inside the row that fired it, so the outer
3955    /// agent's first frame, then the nested agent's work, then the outer
3956    /// agent's result read in the order they ran.
3957    #[test]
3958    fn get_session_messages_folds_a_nested_sidecar_under_its_owner() {
3959        let config_dir = tempfile::tempdir().unwrap();
3960        let session_id = "550e8400-e29b-41d4-a716-446655440002";
3961        let directory = "/Users/me/Projects/playground";
3962        let dir = project_dir_for(config_dir.path(), directory);
3963        fs::create_dir_all(&dir).unwrap();
3964        write_session_jsonl(
3965            &dir,
3966            session_id,
3967            "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
3968             {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n\
3969             {\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]},\"uuid\":\"a2\"}\n",
3970        );
3971        let sidecars = dir.join(session_id).join("subagents");
3972        fs::create_dir_all(&sidecars).unwrap();
3973        // The outer agent's sidecar: it fired the nested dispatch and holds
3974        // the result that answers it, which is why the nested sidecar used
3975        // to join at all.
3976        fs::write(
3977            sidecars.join("agent-outer.meta.json"),
3978            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
3979        )
3980        .unwrap();
3981        fs::write(
3982            sidecars.join("agent-outer.jsonl"),
3983            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_inner\",\"name\":\"Agent\",\"input\":{\"prompt\":\"inner\"}}]},\"uuid\":\"o1\"}\n\
3984             {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_inner\",\"content\":\"inner done\"}]},\"uuid\":\"o2\"}\n",
3985        )
3986        .unwrap();
3987        // The nested agent's sidecar, newer than its parent's.
3988        fs::write(
3989            sidecars.join("agent-nested.meta.json"),
3990            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_inner\",\"parentAgentId\":\"outer\",\"spawnDepth\":2}",
3991        )
3992        .unwrap();
3993        fs::write(
3994            sidecars.join("agent-nested.jsonl"),
3995            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_deep\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"uuid\":\"n1\"}\n\
3996             {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_deep\",\"content\":\"out\"}]},\"uuid\":\"n2\"}\n",
3997        )
3998        .unwrap();
3999        // The third level: its dispatch is a call inside the nested agent's
4000        // sidecar, so only the chain reaches the card.
4001        fs::write(
4002            sidecars.join("agent-deep.meta.json"),
4003            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_deep\",\"parentAgentId\":\"nested\",\"spawnDepth\":3}",
4004        )
4005        .unwrap();
4006        fs::write(
4007            sidecars.join("agent-deep.jsonl"),
4008            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"deep\"}]},\"uuid\":\"d1\"}\n",
4009        )
4010        .unwrap();
4011
4012        let read = || get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4013        let assert_folded = |messages: &[SessionMessage]| {
4014            let uuids: Vec<_> = messages.iter().map(|message| message.uuid.as_str()).collect();
4015            assert_eq!(
4016                uuids,
4017                vec!["a1", "u1", "o1", "n1", "d1", "n2", "o2", "a2"],
4018                "the transcript's rows with each block emitted inside the row that fired it",
4019            );
4020            for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
4021                assert_eq!(
4022                    message.parent_tool_use_id.as_deref(),
4023                    Some("toolu_dispatch"),
4024                    "every nested frame names the card that owns the dispatch, not the cardless one",
4025                );
4026            }
4027        };
4028
4029        // Children newer than their parents: the shape the old growing-list
4030        // fold skipped.
4031        set_age(&sidecars.join("agent-outer.meta.json"), 600);
4032        set_age(&sidecars.join("agent-nested.meta.json"), 60);
4033        set_age(&sidecars.join("agent-deep.meta.json"), 30);
4034        assert_folded(&read());
4035
4036        // And the reverse, the measured anomaly (6 of 162 nested sidecars) where
4037        // the old fold joined the child against its parent's spliced rows and
4038        // stamped the nested id.
4039        set_age(&sidecars.join("agent-outer.meta.json"), 30);
4040        set_age(&sidecars.join("agent-nested.meta.json"), 60);
4041        set_age(&sidecars.join("agent-deep.meta.json"), 600);
4042        assert_folded(&read());
4043    }
4044
4045    /// **A nested sidecar folds even when its parent's body fell past the
4046    /// budget.** The chain comes off the metas, not the bodies: reading only
4047    /// the sidecars inside the budget used to strand a child whose parent was
4048    /// skipped, and its work vanished from the resume with no row.
4049    #[test]
4050    fn a_nested_sidecar_folds_when_its_parent_body_was_skipped() {
4051        let config_dir = tempfile::tempdir().unwrap();
4052        let session_id = "550e8400-e29b-41d4-a716-446655440004";
4053        let directory = "/Users/me/Projects/playground";
4054        let dir = project_dir_for(config_dir.path(), directory);
4055        fs::create_dir_all(&dir).unwrap();
4056        write_session_jsonl(
4057            &dir,
4058            session_id,
4059            "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
4060             {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n",
4061        );
4062        let sidecars = dir.join(session_id).join("subagents");
4063        fs::create_dir_all(&sidecars).unwrap();
4064        fs::write(
4065            sidecars.join("agent-outer.meta.json"),
4066            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
4067        )
4068        .unwrap();
4069        fs::write(
4070            sidecars.join("agent-outer.jsonl"),
4071            format!(
4072                "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"tool_use\",\"id\":\"toolu_inner\",\"name\":\"Agent\",\"input\":{{\"prompt\":\"inner\"}}}}]}},\"uuid\":\"o1\"}}\n{}",
4073                " ".repeat(400)
4074            ),
4075        )
4076        .unwrap();
4077        fs::write(
4078            sidecars.join("agent-nested.meta.json"),
4079            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_inner\",\"parentAgentId\":\"outer\",\"spawnDepth\":2}",
4080        )
4081        .unwrap();
4082        fs::write(
4083            sidecars.join("agent-nested.jsonl"),
4084            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"kept\"}]},\"uuid\":\"n1\"}\n",
4085        )
4086        .unwrap();
4087        // A second child of the same skipped parent: both become roots, and
4088        // their order must be the one they fired in (oldest first).
4089        fs::write(
4090            sidecars.join("agent-second.meta.json"),
4091            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_other\",\"parentAgentId\":\"outer\",\"spawnDepth\":2}",
4092        )
4093        .unwrap();
4094        fs::write(
4095            sidecars.join("agent-second.jsonl"),
4096            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"older\"}]},\"uuid\":\"m1\"}\n",
4097        )
4098        .unwrap();
4099        set_age(&sidecars.join("agent-outer.meta.json"), 600);
4100        set_age(&sidecars.join("agent-nested.meta.json"), 60);
4101        set_age(&sidecars.join("agent-second.meta.json"), 120);
4102
4103        // The budget fits the children's bodies only: the parent's is skipped.
4104        let transcript = dir.join(format!("{session_id}.jsonl"));
4105        let (sidecars, links) = subagent_sidecars_with_budget(session_id, &transcript, 300);
4106        assert!(
4107            sidecars.iter().all(|(dispatch, _)| dispatch != "toolu_dispatch"),
4108            "the parent's body is past the budget",
4109        );
4110        assert_eq!(links.len(), 3, "but every meta is read for the chain");
4111
4112        let mut messages = parse_session_messages(fs::File::open(&transcript).unwrap()).messages;
4113        fold_subagent_sidecars(&mut messages, sidecars, &links, session_id);
4114
4115        assert_eq!(
4116            messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4117            vec!["a1", "u1", "m1", "n1"],
4118            "both children fold, the older root first, although the parent's body was skipped",
4119        );
4120        for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
4121            assert_eq!(
4122                message.parent_tool_use_id.as_deref(),
4123                Some("toolu_dispatch"),
4124                "every child is stamped with the dispatch the transcript answers",
4125            );
4126        }
4127    }
4128
4129    /// **A child meta with no `parentAgentId` still resolves through the
4130    /// parent body.** The parent's own rows name the child's dispatch, and
4131    /// that link is enough.
4132    #[test]
4133    fn a_child_meta_without_a_parent_agent_id_resolves_through_the_body() {
4134        let config_dir = tempfile::tempdir().unwrap();
4135        let session_id = "550e8400-e29b-41d4-a716-446655440006";
4136        let directory = "/Users/me/Projects/playground";
4137        let dir = project_dir_for(config_dir.path(), directory);
4138        fs::create_dir_all(&dir).unwrap();
4139        write_session_jsonl(
4140            &dir,
4141            session_id,
4142            "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n\
4143             {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_dispatch\",\"content\":\"done\"}]},\"uuid\":\"u1\"}\n",
4144        );
4145        let sidecars = dir.join(session_id).join("subagents");
4146        fs::create_dir_all(&sidecars).unwrap();
4147        fs::write(
4148            sidecars.join("agent-outer.meta.json"),
4149            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
4150        )
4151        .unwrap();
4152        fs::write(
4153            sidecars.join("agent-outer.jsonl"),
4154            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_child\",\"name\":\"Agent\",\"input\":{\"prompt\":\"inner\"}}]},\"uuid\":\"o1\"}\n\
4155             {\"type\":\"user\",\"isSidechain\":true,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_child\",\"content\":\"ok\"}]},\"uuid\":\"o2\"}\n",
4156        )
4157        .unwrap();
4158        // No parentAgentId: only the parent body names this dispatch.
4159        fs::write(
4160            sidecars.join("agent-child.meta.json"),
4161            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_child\"}",
4162        )
4163        .unwrap();
4164        fs::write(
4165            sidecars.join("agent-child.jsonl"),
4166            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"child\"}]},\"uuid\":\"c1\"}\n",
4167        )
4168        .unwrap();
4169        set_age(&sidecars.join("agent-outer.meta.json"), 600);
4170        set_age(&sidecars.join("agent-child.meta.json"), 60);
4171
4172        let messages =
4173            get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4174
4175        assert_eq!(
4176            messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4177            vec!["a1", "u1", "o1", "c1", "o2"],
4178            "the child folds inside the row that fired it",
4179        );
4180        for message in messages.iter().filter(|message| message.parent_tool_use_id.is_some()) {
4181            assert_eq!(message.parent_tool_use_id.as_deref(), Some("toolu_dispatch"));
4182        }
4183    }
4184
4185    /// **A chain that loops, or loses its parent meta, joins nothing.** Both
4186    /// shapes are read off disk and either could be torn by a sync; the walk
4187    /// must terminate and the rows must not land under an id nothing draws.
4188    #[test]
4189    fn a_sidecar_chain_with_a_cycle_or_a_missing_parent_joins_nothing() {
4190        let config_dir = tempfile::tempdir().unwrap();
4191        let session_id = "550e8400-e29b-41d4-a716-446655440005";
4192        let directory = "/Users/me/Projects/playground";
4193        let dir = project_dir_for(config_dir.path(), directory);
4194        fs::create_dir_all(&dir).unwrap();
4195        write_session_jsonl(
4196            &dir,
4197            session_id,
4198            "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"before\"}]},\"uuid\":\"a1\"}\n",
4199        );
4200        let sidecars = dir.join(session_id).join("subagents");
4201        fs::create_dir_all(&sidecars).unwrap();
4202        let write = |stem: &str, meta: &str| {
4203            fs::write(sidecars.join(format!("agent-{stem}.meta.json")), meta).unwrap();
4204            fs::write(
4205                sidecars.join(format!("agent-{stem}.jsonl")),
4206                format!(
4207                    "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"{stem}\"}}]}},\"uuid\":\"{stem}\"}}\n"
4208                ),
4209            )
4210            .unwrap();
4211        };
4212        // Two sidecars that name each other: neither chain reaches an answer.
4213        write("a", "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\",\"parentAgentId\":\"b\"}");
4214        write("b", "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_b\",\"parentAgentId\":\"a\"}");
4215        // A child whose parent meta is absent.
4216        write(
4217            "c",
4218            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_c\",\"parentAgentId\":\"ghost\"}",
4219        );
4220
4221        let messages =
4222            get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4223
4224        assert_eq!(
4225            messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4226            vec!["a1"],
4227            "a cycle and a missing parent both resolve to nothing, and nothing loops",
4228        );
4229        // The rows above would also vanish for a walk that answered a
4230        // non-answered id, so pin the resolution itself.
4231        let mut made_under = HashMap::new();
4232        made_under.insert("toolu_a".to_owned(), "toolu_b".to_owned());
4233        made_under.insert("toolu_b".to_owned(), "toolu_a".to_owned());
4234        made_under.insert("toolu_c".to_owned(), "toolu_ghost".to_owned());
4235        let mut answered = HashSet::new();
4236        answered.insert("toolu_top".to_owned());
4237        assert_eq!(
4238            owning_dispatch("toolu_a", &answered, &made_under),
4239            None,
4240            "a cycle resolves to nothing"
4241        );
4242        assert_eq!(owning_dispatch("toolu_b", &answered, &made_under), None);
4243        assert_eq!(
4244            owning_dispatch("toolu_c", &answered, &made_under),
4245            None,
4246            "a missing parent resolves to nothing",
4247        );
4248        made_under.insert("toolu_top_child".to_owned(), "toolu_top".to_owned());
4249        assert_eq!(
4250            owning_dispatch("toolu_top_child", &answered, &made_under),
4251            Some("toolu_top"),
4252            "a chain that reaches an answered dispatch resolves to it",
4253        );
4254    }
4255
4256    /// A sidecar whose chain never reaches a dispatch the transcript answers
4257    /// joins nothing: folding it under an id no card owns would put rows in
4258    /// the conversation that draw nowhere.
4259    #[test]
4260    fn a_sidecar_nested_under_an_unanswered_dispatch_joins_nothing() {
4261        let config_dir = tempfile::tempdir().unwrap();
4262        let session_id = "550e8400-e29b-41d4-a716-446655440003";
4263        let directory = "/Users/me/Projects/playground";
4264        let dir = project_dir_for(config_dir.path(), directory);
4265        fs::create_dir_all(&dir).unwrap();
4266        // The outer dispatch has no result row: nothing answers it.
4267        write_session_jsonl(
4268            &dir,
4269            session_id,
4270            "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_dispatch\",\"name\":\"Agent\",\"input\":{\"prompt\":\"explore\"}}]},\"uuid\":\"a1\"}\n",
4271        );
4272        let sidecars = dir.join(session_id).join("subagents");
4273        fs::create_dir_all(&sidecars).unwrap();
4274        fs::write(
4275            sidecars.join("agent-outer.meta.json"),
4276            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_dispatch\"}",
4277        )
4278        .unwrap();
4279        fs::write(
4280            sidecars.join("agent-outer.jsonl"),
4281            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_inner\",\"name\":\"Agent\",\"input\":{\"prompt\":\"inner\"}}]},\"uuid\":\"o1\"}\n",
4282        )
4283        .unwrap();
4284        fs::write(
4285            sidecars.join("agent-nested.meta.json"),
4286            "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_inner\"}",
4287        )
4288        .unwrap();
4289        fs::write(
4290            sidecars.join("agent-nested.jsonl"),
4291            "{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"nested\"}]},\"uuid\":\"n1\"}\n",
4292        )
4293        .unwrap();
4294
4295        let messages =
4296            get_session_messages(config_dir.path(), session_id, Some(directory)).messages;
4297
4298        assert_eq!(
4299            messages.iter().map(|message| message.uuid.as_str()).collect::<Vec<_>>(),
4300            vec!["a1"],
4301            "neither sidecar has a row the transcript answers",
4302        );
4303    }
4304
4305    /// The read budget bounds the parse: a sidecar larger than the budget is
4306    /// skipped while a smaller one is still read. A resume otherwise opens
4307    /// and parses every sidecar the session ever wrote, and most are older
4308    /// than the window the conversation keeps (#1763).
4309    #[test]
4310    fn subagent_sidecars_stop_at_the_read_budget() {
4311        let config_dir = tempfile::tempdir().unwrap();
4312        let session_id = "550e8400-e29b-41d4-a716-446655440001";
4313        let directory = "/Users/me/Projects/playground";
4314        let dir = project_dir_for(config_dir.path(), directory);
4315        fs::create_dir_all(&dir).unwrap();
4316        write_session_jsonl(&dir, session_id, "");
4317        let transcript = dir.join(format!("{session_id}.jsonl"));
4318        let sidecars = dir.join(session_id).join("subagents");
4319        fs::create_dir_all(&sidecars).unwrap();
4320        let write_sidecar = |stem: &str, uuid: &str, pad: usize| {
4321            fs::write(
4322                sidecars.join(format!("{stem}.meta.json")),
4323                "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\"}",
4324            )
4325            .unwrap();
4326            fs::write(
4327                sidecars.join(format!("{stem}.jsonl")),
4328                format!(
4329                    "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"x\"}}]}},\"uuid\":\"{uuid}\"}}\n{}",
4330                    " ".repeat(pad)
4331                ),
4332            )
4333            .unwrap();
4334        };
4335        write_sidecar("agent-small", "s1", 0);
4336        write_sidecar("agent-large", "l1", 400);
4337
4338        let (sidecars, _) = subagent_sidecars_with_budget(session_id, &transcript, 200);
4339        let uuids: Vec<_> =
4340            sidecars.iter().flat_map(|(_, rows)| rows.iter().map(|row| row.uuid.clone())).collect();
4341
4342        assert!(uuids.iter().any(|uuid| uuid == "s1"), "a sidecar inside the budget is read");
4343        assert!(
4344            !uuids.iter().any(|uuid| uuid == "l1"),
4345            "a sidecar past the budget is skipped rather than parsed",
4346        );
4347    }
4348
4349    /// **The budget spends on the newest sidecars first.** A sidecar past
4350    /// the budget keeps its dispatch alone, and the sidecars beyond the
4351    /// conversation window are the oldest ones - so a read that kept the
4352    /// oldest would bring back exactly the #1763 symptom at the newest edge.
4353    #[test]
4354    fn subagent_sidecars_spend_the_budget_on_the_newest_first() {
4355        let config_dir = tempfile::tempdir().unwrap();
4356        let session_id = "550e8400-e29b-41d4-a716-446655440002";
4357        let directory = "/Users/me/Projects/playground";
4358        let dir = project_dir_for(config_dir.path(), directory);
4359        fs::create_dir_all(&dir).unwrap();
4360        write_session_jsonl(&dir, session_id, "");
4361        let transcript = dir.join(format!("{session_id}.jsonl"));
4362        let sidecars = dir.join(session_id).join("subagents");
4363        fs::create_dir_all(&sidecars).unwrap();
4364        let write_sidecar = |stem: &str, uuid: &str| {
4365            fs::write(
4366                sidecars.join(format!("{stem}.meta.json")),
4367                "{\"agentType\":\"Explore\",\"toolUseId\":\"toolu_a\"}",
4368            )
4369            .unwrap();
4370            fs::write(
4371                sidecars.join(format!("{stem}.jsonl")),
4372                format!(
4373                    "{{\"type\":\"assistant\",\"isSidechain\":true,\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"x\"}}]}},\"uuid\":\"{uuid}\"}}\n"
4374                ),
4375            )
4376            .unwrap();
4377        };
4378        write_sidecar("agent-older", "old");
4379        write_sidecar("agent-newer", "new");
4380        set_age(&sidecars.join("agent-older.meta.json"), 600);
4381        set_age(&sidecars.join("agent-newer.meta.json"), 60);
4382        let one = fs::metadata(sidecars.join("agent-newer.jsonl")).unwrap().len();
4383
4384        let (sidecars, _) = subagent_sidecars_with_budget(session_id, &transcript, one);
4385        let uuids: Vec<_> =
4386            sidecars.iter().flat_map(|(_, rows)| rows.iter().map(|row| row.uuid.clone())).collect();
4387
4388        assert!(uuids.iter().any(|uuid| uuid == "new"), "the newest sidecar is read");
4389        assert!(
4390            !uuids.iter().any(|uuid| uuid == "old"),
4391            "an older sidecar past the budget is skipped, not kept in place of the newest",
4392        );
4393    }
4394
4395    /// A resumed git worker anchors its history read at the worktree run
4396    /// dir (`claude --resume` receives that cwd via `worker_tag_dir`), so
4397    /// the JSONL is read from the worktree project key, not the repo-root
4398    /// key. The same session id under the repo-root key is invisible -
4399    /// which is why the resume-map must pick a worktree-scoped session.
4400    #[test]
4401    fn get_session_messages_reads_git_worker_from_worktree_key() {
4402        let config_dir = tempfile::tempdir().unwrap();
4403        let session_id = "550e8400-e29b-41d4-a716-446655440000";
4404        let repo_root = "/Users/me/Projects/playground";
4405        let worktree = "/Users/me/Projects/playground/.claude/worktrees/gpt-tutor";
4406
4407        let worktree_dir = project_dir_for(config_dir.path(), worktree);
4408        fs::create_dir_all(&worktree_dir).unwrap();
4409        write_session_jsonl(
4410            &worktree_dir,
4411            session_id,
4412            "{\"type\":\"user\",\"message\":{\"content\":\"hello from the worktree\"}}\n",
4413        );
4414
4415        let from_worktree =
4416            get_session_messages(config_dir.path(), session_id, Some(worktree)).messages;
4417        assert_eq!(from_worktree.len(), 1, "resume reads the JSONL under the worktree key");
4418
4419        let from_repo_root =
4420            get_session_messages(config_dir.path(), session_id, Some(repo_root)).messages;
4421        assert!(
4422            from_repo_root.is_empty(),
4423            "the same session id under the repo-root key is not where a git worker reads",
4424        );
4425    }
4426
4427    /// A transcript of `rows` plain turns, each a user row carrying its own
4428    /// number and a uuid a caller can anchor on.
4429    fn a_transcript(rows: usize) -> String {
4430        use std::fmt::Write as _;
4431        let mut out = String::new();
4432        for at in 0..rows {
4433            let _ = writeln!(
4434                out,
4435                "{{\"type\":\"user\",\"uuid\":\"u{at}\",\"session_id\":\"s1\",\
4436                 \"message\":{{\"role\":\"user\",\"content\":\"turn {at}\"}}}}"
4437            );
4438        }
4439        out
4440    }
4441
4442    /// An anchor on a row the file carries, without a byte: what a caller
4443    /// holding the row but not its position reads by.
4444    fn anchored(row: &str) -> forge_primitives::TranscriptAnchor {
4445        forge_primitives::TranscriptAnchor { row: row.to_owned(), offset: None }
4446    }
4447
4448    /// The words a frame carries, for a span whose rows are its turns' names.
4449    fn said(message: &forge_primitives::Message) -> String {
4450        let forge_primitives::Message::User { message, .. } = message else {
4451            return String::new();
4452        };
4453        message
4454            .content
4455            .iter()
4456            .find_map(|block| match block {
4457                forge_primitives::ContentBlock::Text { text, .. } => Some(text.clone()),
4458                _ => None,
4459            })
4460            .unwrap_or_default()
4461    }
4462
4463    /// The read's rows, in the words the file wrote them with.
4464    fn words(span: &forge_primitives::TranscriptSpan) -> Vec<String> {
4465        span.messages.iter().map(said).collect()
4466    }
4467
4468    /// **A page reads the rows just below its anchor, down to the file's
4469    /// start.** The anchor names the row the page above stopped at; the rows
4470    /// the read serves are the ones strictly older than it, and the file's own
4471    /// first row is where the span says the history ends.
4472    #[test]
4473    fn a_row_read_serves_the_rows_below_its_anchor() {
4474        let config = tempfile::tempdir().unwrap();
4475        let cwd = "/Users/me/Projects/playground";
4476        let dir = project_dir_for(config.path(), cwd);
4477        fs::create_dir_all(&dir).unwrap();
4478        let session = "550e8400-e29b-41d4-a716-446655440000";
4479        write_session_jsonl(&dir, session, &a_transcript(40));
4480
4481        let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 100)
4482            .expect("the transcript has rows below the anchor");
4483
4484        assert!(span.exhausted, "the read reached the file's own first row");
4485        assert_eq!(span.messages.len(), 30, "every row below the anchor, and none above");
4486        assert_eq!(span.messages.len(), span.offsets.len(), "each row with the byte it starts at");
4487        let expected: Vec<String> = (0..30).map(|at| format!("turn {at}")).collect();
4488        assert_eq!(words(&span), expected, "in conversation order");
4489
4490        // A page asks for its own budget of rows and gets the NEWEST of the
4491        // older rows: what does not fit is left for the page below it.
4492        let trimmed = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 5)
4493            .expect("a span the budget trims");
4494        assert_eq!(
4495            words(&trimmed),
4496            (25..30).map(|at| format!("turn {at}")).collect::<Vec<_>>(),
4497            "the page's own rows and no more, taken nearest the anchor",
4498        );
4499        assert!(!trimmed.exhausted, "which is not the file's start");
4500
4501        // And the walk's first ask, on a window that holds no row to name: the
4502        // file's own newest rows.
4503        let newest = read_rows(config.path(), session, Some(cwd), None, 5)
4504            .expect("the file answers with its newest rows");
4505        assert_eq!(
4506            words(&newest),
4507            (35..40).map(|at| format!("turn {at}")).collect::<Vec<_>>(),
4508            "the newest rows the session wrote",
4509        );
4510        assert!(!newest.exhausted, "with more above them");
4511    }
4512
4513    /// **A page reads the duplicated block once too.** The paging span read is
4514    /// the second reader of the same file, and it serves the rows a page below
4515    /// the window's floor draws - which is where a re-appended block sits. Its
4516    /// row-level dedupe keys on the `uuid` the same way the whole-file read's
4517    /// does.
4518    #[test]
4519    fn a_row_read_reads_a_duplicated_block_once() {
4520        use std::fmt::Write as _;
4521        let config = tempfile::tempdir().unwrap();
4522        let cwd = "/Users/me/Projects/playground";
4523        let dir = project_dir_for(config.path(), cwd);
4524        fs::create_dir_all(&dir).unwrap();
4525        let session = "550e8400-e29b-41d4-a716-446655440000";
4526        let mut body = a_transcript(6);
4527        // A later run's session-state rows, then the re-appended tail block:
4528        // u3..u5 written a second time under their original uuids.
4529        let _ = writeln!(body, "{{\"type\":\"last-prompt\",\"uuid\":\"lp1\"}}");
4530        for at in 3..6 {
4531            let _ = writeln!(
4532                body,
4533                "{{\"type\":\"user\",\"uuid\":\"u{at}\",\"slug\":\"s\",\"session_id\":\"s1\",\
4534                 \"message\":{{\"role\":\"user\",\"content\":\"turn {at}\"}}}}"
4535            );
4536        }
4537        write_session_jsonl(&dir, session, &body);
4538
4539        let span = read_rows(config.path(), session, Some(cwd), None, 100)
4540            .expect("the transcript answers with its rows");
4541        assert_eq!(
4542            ids(&span),
4543            ["u0", "u1", "u2", "u3", "u4", "u5"],
4544            "the newest rows, the duplicated tail block among them only once",
4545        );
4546    }
4547
4548    /// **A chained read seeks where a locating read searches.** Given the byte
4549    /// its anchor row starts at, the read takes the window ending there; given
4550    /// only the id, it has to find the row, which a window too small to reach
4551    /// it cannot do - and the walk's first page is the one that pays for the
4552    /// search.
4553    #[test]
4554    fn a_row_read_seeks_when_its_anchor_carries_a_byte() {
4555        // A cap of a couple of hundred bytes: two rows or so, which is not
4556        // where an anchor twenty rows back is.
4557        const SMALL: u64 = 200;
4558        let config = tempfile::tempdir().unwrap();
4559        let cwd = "/Users/me/Projects/playground";
4560        let dir = project_dir_for(config.path(), cwd);
4561        fs::create_dir_all(&dir).unwrap();
4562        let session = "550e8400-e29b-41d4-a716-446655440000";
4563        write_session_jsonl(&dir, session, &a_transcript(40));
4564
4565        let located = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 100)
4566            .expect("the first read locates the anchor");
4567        let oldest = located.offsets.first().copied().expect("the span's oldest row byte");
4568        let oldest_row = said(located.messages.first().expect("a first"));
4569        assert_eq!(oldest_row, "turn 0", "precondition: the span ran to the file's first row");
4570
4571        // The page below the span's own start: the next read is handed that
4572        // row's byte, and reads below it - which is nothing, because the span
4573        // held the file's start.
4574        let resume =
4575            forge_primitives::TranscriptAnchor { row: "u0".to_owned(), offset: Some(oldest) };
4576        let chained =
4577            read_rows_with(config.path(), session, Some(cwd), Some(&resume), 100, SMALL, SMALL)
4578                .expect("the resumed read seeks to the row's byte");
4579        assert!(chained.messages.is_empty(), "nothing sits below the file's first row");
4580        assert!(chained.exhausted, "and the read says so");
4581
4582        // The same anchor without its byte cannot be reached inside the cap:
4583        // the search starts at the file's end and this cap does not reach
4584        // twenty rows back, so the read answers nothing rather than a page it
4585        // cannot vouch for.
4586        assert!(
4587            read_rows_with(
4588                config.path(),
4589                session,
4590                Some(cwd),
4591                Some(&anchored("u0")),
4592                100,
4593                SMALL,
4594                SMALL,
4595            )
4596            .is_none(),
4597            "a locating read whose window cannot reach its anchor answers nothing",
4598        );
4599
4600        // And a chained read with a byte serves the rows just below it with no
4601        // search at all: under the same unreachably small cap, the read seeks
4602        // rather than searches and answers a short page with a cursor instead
4603        // of nothing.
4604        let chained = read_rows_with(
4605            config.path(),
4606            session,
4607            Some(cwd),
4608            Some(&forge_primitives::TranscriptAnchor {
4609                row: "u20".to_owned(),
4610                offset: located.offsets.get(20).copied(),
4611            }),
4612            100,
4613            SMALL,
4614            SMALL,
4615        )
4616        .expect("the chained read seeks to the row's byte");
4617        assert!(!chained.messages.is_empty(), "and answers the rows the byte reaches");
4618        assert_eq!(
4619            said(chained.messages.last().expect("a last")),
4620            "turn 19",
4621            "the newest row below the anchor",
4622        );
4623        assert!(!chained.exhausted, "with the file's start still above it");
4624    }
4625
4626    /// A transcript of `pairs` tool calls, each an assistant row calling and
4627    /// the user row carrying its result.
4628    fn a_called_transcript(pairs: usize) -> String {
4629        use std::fmt::Write as _;
4630        let mut out = String::new();
4631        for at in 0..pairs {
4632            let _ = writeln!(
4633                out,
4634                "{{\"type\":\"assistant\",\"uuid\":\"call{at}\",\"session_id\":\"s1\",\
4635                 \"message\":{{\"id\":\"m{at}\",\"role\":\"assistant\",\"model\":\"claude-opus-5\",\
4636                 \"content\":[{{\"type\":\"tool_use\",\
4637                 \"id\":\"tu{at}\",\"name\":\"Bash\",\"input\":{{\"command\":\"ls\"}}}}]}}}}"
4638            );
4639            let _ = writeln!(
4640                out,
4641                "{{\"type\":\"user\",\"uuid\":\"result{at}\",\"session_id\":\"s1\",\
4642                 \"message\":{{\"role\":\"user\",\"content\":[{{\"type\":\"tool_result\",\
4643                 \"tool_use_id\":\"tu{at}\",\"content\":\"ok\"}}]}}}}"
4644            );
4645        }
4646        out
4647    }
4648
4649    /// The ids of a span's rows, for a transcript whose rows each carry one.
4650    fn ids(span: &forge_primitives::TranscriptSpan) -> Vec<String> {
4651        span.messages
4652            .iter()
4653            .map(|message| match message {
4654                forge_primitives::Message::Assistant { uuid: Some(uuid), .. }
4655                | forge_primitives::Message::User { uuid: Some(uuid), .. }
4656                | forge_primitives::Message::StopHookSummary { uuid, .. }
4657                | forge_primitives::Message::CompactBoundary { uuid, .. } => uuid.clone(),
4658                other => panic!("every row of this transcript carries an id: {other:?}"),
4659            })
4660            .collect()
4661    }
4662
4663    /// **A page never opens on a result without its call.** The rows a page
4664    /// keeps are the newest of the older rows, so a cut can land among result
4665    /// rows - and those are deferred to the page below, which is cut from the
4666    /// call that produced them. Nothing is dropped: the result the cut stepped
4667    /// past is carried with its call.
4668    #[test]
4669    fn a_row_read_defers_leading_results_to_the_page_below() {
4670        let config = tempfile::tempdir().unwrap();
4671        let cwd = "/Users/me/Projects/playground";
4672        let dir = project_dir_for(config.path(), cwd);
4673        fs::create_dir_all(&dir).unwrap();
4674        let session = "550e8400-e29b-41d4-a716-446655440000";
4675        write_session_jsonl(&dir, session, &a_called_transcript(20));
4676
4677        // Three rows below the anchor land mid-pair, and the cut's room reaches
4678        // past that: the page opens on a CALL - the leading result rows are
4679        // deferred - and closes on the newest pair below the anchor.
4680        let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("call10")), 3)
4681            .expect("a span below the anchor");
4682        assert_eq!(
4683            ids(&span),
4684            vec!["call9", "result9"],
4685            "the page opens on the call the cut stepped back to, rather than on a result without \
4686             its call",
4687        );
4688
4689        // **The walk down, page after page.** Every page opens on a call, and
4690        // together they serve every row below the anchor exactly once, in
4691        // order - the rows a cut stepped past are the page below's newest.
4692        let mut seen: Vec<String> = Vec::new();
4693        let mut anchor = Some(anchored("call10"));
4694        let mut pages = 0_usize;
4695        while let Some(at) = anchor {
4696            let span = read_rows(config.path(), session, Some(cwd), Some(&at), 3).expect("a page");
4697            let page = ids(&span);
4698            assert!(
4699                page.first().is_some_and(|id| id.starts_with("call")),
4700                "every page below the floor opens on a call, never on a bare result: {page:?}",
4701            );
4702            assert!(page.len() <= 3, "and carries the rows it was asked for: {page:?}");
4703            seen.extend(page.clone());
4704            pages += 1;
4705            assert!(pages < 40, "the walk reaches the file's first row");
4706            anchor = (!span.exhausted).then(|| forge_primitives::TranscriptAnchor {
4707                row: page[0].clone(),
4708                offset: span.offsets.first().copied(),
4709            });
4710        }
4711        let expected: Vec<String> =
4712            (0..10).rev().flat_map(|at| [format!("call{at}"), format!("result{at}")]).collect();
4713        assert_eq!(
4714            seen, expected,
4715            "the whole span below the anchor, once each, newest page first, with every result \
4716             served beside the call that produced it",
4717        );
4718    }
4719
4720    /// **A stale byte does not answer with a stale page.** The anchor's byte
4721    /// is verified before it is believed: a transcript rewritten under a walk
4722    /// carries another row at that byte, and the read falls back to searching
4723    /// for the anchor by id - so both routes answer the rows the id names.
4724    #[test]
4725    fn a_row_read_falls_back_when_the_anchors_byte_moved() {
4726        let config = tempfile::tempdir().unwrap();
4727        let cwd = "/Users/me/Projects/playground";
4728        let dir = project_dir_for(config.path(), cwd);
4729        fs::create_dir_all(&dir).unwrap();
4730        let session = "550e8400-e29b-41d4-a716-446655440000";
4731        write_session_jsonl(&dir, session, &a_transcript(40));
4732
4733        let located = read_rows(config.path(), session, Some(cwd), Some(&anchored("u30")), 100)
4734            .expect("the first read locates the anchor");
4735        let last = located.messages.len() - 1;
4736        let resume = forge_primitives::TranscriptAnchor {
4737            row: "u29".to_owned(),
4738            offset: located.offsets.get(last).copied(),
4739        };
4740        assert!(resume.offset.is_some(), "precondition: the span gave the row its byte");
4741
4742        // A row prepended: every byte below it moved, so the anchored byte no
4743        // longer holds the row it was read for.
4744        let prepended = format!(
4745            "{{\"type\":\"user\",\"uuid\":\"u_pre\",\"session_id\":\"s1\",\
4746             \"message\":{{\"role\":\"user\",\"content\":\"prepended\"}}}}\n{}",
4747            a_transcript(40),
4748        );
4749        write_session_jsonl(&dir, session, &prepended);
4750
4751        let stale = read_rows(config.path(), session, Some(cwd), Some(&resume), 100);
4752        let located_again =
4753            read_rows(config.path(), session, Some(cwd), Some(&anchored("u29")), 100);
4754        assert_eq!(
4755            stale.map(|span| words(&span)),
4756            located_again.map(|span| words(&span)),
4757            "a stale byte answers exactly as the read that never had one: the anchor's row is \
4758             found again by its id, so the page below it is the page below it",
4759        );
4760    }
4761
4762    /// **A sub-agent's frame has no transcript row.** The file's rule drops
4763    /// it, and a numbering that counted it as a row would be off for every
4764    /// page below it: the two halves have to say the same thing.
4765    #[test]
4766    fn a_sub_agents_frame_has_no_row() {
4767        let row = serde_json::json!({
4768            "type": "user",
4769            "uuid": "u1",
4770            "parent_tool_use_id": "toolu_dispatch",
4771            "session_id": "s1",
4772            "message": {"role": "user", "content": "under a sub-agent"},
4773        });
4774        let frame: forge_primitives::Message =
4775            serde_json::from_value(row.clone()).expect("a frame");
4776        assert!(!has_a_transcript_row(&frame), "a sub-agent's frame carries no row");
4777        assert!(transcript_row(&row).is_none(), "and the row rule drops it, as this says it does");
4778    }
4779
4780    /// **A stamped refusal has no transcript row either.** Stamping a
4781    /// refusal with the dispatch it belongs under makes it a sub-agent frame
4782    /// the row rule drops, so a numbering that counted it as a row would be
4783    /// off for every page below it.
4784    #[test]
4785    fn a_stamped_refusal_has_no_row() {
4786        let row = serde_json::json!({
4787            "type": "system",
4788            "subtype": "permission_denied",
4789            "tool_name": "Bash",
4790            "tool_use_id": "call_refused",
4791            "decision_reason_type": "subcommandResults",
4792            "message": "Permission to use Bash has been denied.",
4793            "uuid": "p1",
4794            "session_id": "s1",
4795            "parent_tool_use_id": "toolu_dispatch",
4796        });
4797        let frame: forge_primitives::Message =
4798            serde_json::from_value(row).expect("a stamped refusal");
4799        assert!(!has_a_transcript_row(&frame), "a stamped refusal carries no row");
4800        let unstamped: forge_primitives::Message = serde_json::from_value(serde_json::json!({
4801            "type": "system",
4802            "subtype": "permission_denied",
4803            "tool_name": "Bash",
4804            "tool_use_id": "call_refused",
4805            "decision_reason_type": "other",
4806            "decision_reason": "Contains simple_expansion",
4807            "message": "Contains simple_expansion",
4808            "uuid": "p2",
4809            "session_id": "s1",
4810        }))
4811        .expect("a session-level refusal");
4812        assert!(has_a_transcript_row(&unstamped), "the session's own refusal keeps its row");
4813    }
4814
4815    /// The one row rule's forks reach a span: a queued prompt's attachment row
4816    /// is hoisted into the block a view reads it as, and a boundary row is the
4817    /// frame it is - not skipped, and not flattened into something else.
4818    #[test]
4819    fn a_row_read_types_the_rows_the_conversion_has_forks_for() {
4820        let config = tempfile::tempdir().unwrap();
4821        let cwd = "/Users/me/Projects/playground";
4822        let dir = project_dir_for(config.path(), cwd);
4823        fs::create_dir_all(&dir).unwrap();
4824        let session = "550e8400-e29b-41d4-a716-446655440000";
4825        let body = format!(
4826            "{}\n{}\n{}\n{}\n{}",
4827            "{\"type\":\"user\",\"uuid\":\"u0\",\"session_id\":\"s1\",\
4828             \"message\":{\"role\":\"user\",\"content\":\"turn 0\"}}",
4829            "{\"type\":\"attachment\",\"uuid\":\"a1\",\"session_id\":\"s1\",\
4830             \"attachment\":{\"type\":\"queued_command\",\"prompt\":\"queued\",\"commandMode\":\"prompt\"}}",
4831            "{\"type\":\"queue-operation\",\"operation\":\"remove\",\"timestamp\":\"2026-11-03T12:00:00Z\",\
4832             \"sessionId\":\"s1\",\"content\":\"queued\"}",
4833            "{\"type\":\"system\",\"subtype\":\"compact_boundary\",\"uuid\":\"h1\",\
4834             \"sessionId\":\"s1\",\"compactMetadata\":{\"trigger\":\"auto\",\"preTokens\":1,\"postTokens\":1}}",
4835            "{\"type\":\"user\",\"uuid\":\"u2\",\"session_id\":\"s1\",\
4836             \"message\":{\"role\":\"user\",\"content\":\"turn 1\"}}",
4837        );
4838        write_session_jsonl(&dir, session, &body);
4839
4840        let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("u2")), 100)
4841            .expect("a span over the forks");
4842
4843        assert!(span.exhausted, "every row the read kept is below the anchor");
4844        assert_eq!(span.messages.len(), 3, "the attachment's row is a frame, not a skip");
4845        assert!(
4846            matches!(
4847                &span.messages[1],
4848                forge_primitives::Message::User { message, .. }
4849                    if matches!(message.content[0], forge_primitives::ContentBlock::QueuedCommand { .. })
4850            ),
4851            "the queued command arrives as the block a view reads it as",
4852        );
4853        assert!(
4854            matches!(&span.messages[2], forge_primitives::Message::CompactBoundary { uuid, .. }
4855                if uuid == "h1"),
4856            "and the boundary row as the frame it is, not a skip: {:?}",
4857            span.messages[2],
4858        );
4859    }
4860
4861    /// **Only a start reason draws the prompt.** A removal carrying any other
4862    /// reason did not take the prompt, so neither the removal nor the
4863    /// attachment it would have paired with draws a row.
4864    #[test]
4865    fn a_remove_reason_that_is_not_a_start_draws_no_prompt_row() {
4866        let jsonl = r#"{"type":"user","message":{"role":"user","content":"plain"},"uuid":"u1","session_id":"s1"}
4867{"type":"attachment","attachment":{"type":"queued_command","prompt":"cancelled words","commandMode":"prompt"},"uuid":"a1","session_id":"s1","timestamp":"2026-11-03T12:00:00Z"}
4868{"type":"queue-operation","operation":"remove","reason":"cancelled","timestamp":"2026-11-03T12:00:05Z","sessionId":"s1","content":"cancelled words"}
4869"#;
4870        let msgs = parse_session_messages(jsonl.as_bytes()).messages;
4871        assert_eq!(
4872            msgs.iter().map(|row| row.uuid.as_str()).collect::<Vec<_>>(),
4873            ["u1"],
4874            "the prompt a non-start removal names draws nothing",
4875        );
4876    }
4877
4878    /// **The paged read holds a queued prompt until its start too.** An
4879    /// assistant row written between the attachment and its `remove` must
4880    /// come first in the served span: the page's order is the start's, and a
4881    /// span that served the attachment at its own byte would put the words
4882    /// above work the same turn wrote after the press.
4883    #[test]
4884    fn a_paged_read_serves_a_queued_prompt_at_its_remove() {
4885        let config = tempfile::tempdir().unwrap();
4886        let cwd = "/Users/me/Projects/playground";
4887        let dir = project_dir_for(config.path(), cwd);
4888        fs::create_dir_all(&dir).unwrap();
4889        let session = "550e8400-e29b-41d4-a716-446655440000";
4890        let body = format!(
4891            "{}\n{}\n{}\n{}\n{}",
4892            "{\"type\":\"user\",\"uuid\":\"u1\",\"session_id\":\"s1\",\
4893             \"message\":{\"role\":\"user\",\"content\":\"before\"},\
4894             \"timestamp\":\"2026-11-03T11:59:00Z\"}",
4895            "{\"type\":\"attachment\",\"uuid\":\"a1\",\"session_id\":\"s1\",\
4896             \"attachment\":{\"type\":\"queued_command\",\"prompt\":\"queued words\",\
4897             \"commandMode\":\"prompt\",\"source_uuid\":\"p1\"},\
4898             \"timestamp\":\"2026-11-03T12:00:00Z\"}",
4899            "{\"type\":\"assistant\",\"uuid\":\"as1\",\"session_id\":\"s1\",\
4900             \"message\":{\"id\":\"m1\",\"role\":\"assistant\",\"model\":\"claude-opus-5\",\
4901             \"content\":[{\"type\":\"text\",\"text\":\"working\"}]},\
4902             \"timestamp\":\"2026-11-03T12:00:01Z\"}",
4903            "{\"type\":\"queue-operation\",\"operation\":\"remove\",\
4904             \"reason\":\"absorbed_mid_turn\",\"timestamp\":\"2026-11-03T12:00:05Z\",\
4905             \"sessionId\":\"s1\",\"content\":\"queued words\"}",
4906            "{\"type\":\"user\",\"uuid\":\"u2\",\"session_id\":\"s1\",\
4907             \"message\":{\"role\":\"user\",\"content\":\"after\"},\
4908             \"timestamp\":\"2026-11-03T12:00:06Z\"}",
4909        );
4910        write_session_jsonl(&dir, session, &body);
4911
4912        let span = read_rows(config.path(), session, Some(cwd), Some(&anchored("u2")), 100)
4913            .expect("a span over the queued prompt");
4914
4915        assert_eq!(span.messages.len(), 3, "the page carries the rows below the anchor");
4916        assert!(
4917            matches!(&span.messages[1], forge_primitives::Message::Assistant { .. }),
4918            "the assistant row written between the press and the start is served first",
4919        );
4920        let forge_primitives::Message::User { uuid, timestamp, message, .. } = &span.messages[2]
4921        else {
4922            panic!("the queued prompt is a user row: {:?}", span.messages[2]);
4923        };
4924        assert!(
4925            uuid.as_deref().is_some_and(|id| id.starts_with("queue-remove-2026-11-03T12:00:05Z-")),
4926            "named by the start it was served at: {uuid:?}",
4927        );
4928        assert_eq!(
4929            timestamp.as_deref(),
4930            Some("2026-11-03T12:00:05Z"),
4931            "stamped at the start, not the press",
4932        );
4933        assert!(
4934            message.content.iter().any(|block| matches!(
4935                block,
4936                forge_primitives::ContentBlock::QueuedCommand { prompt, source_uuid, .. }
4937                    if prompt.as_str() == Some("queued words")
4938                        && source_uuid.as_deref() == Some("p1")
4939            )),
4940            "carrying the prompt's words and the id the live row pairs with: {message:?}",
4941        );
4942    }
4943
4944    /// **The read is a window, not the file.** The rows are found in a tail
4945    /// window, and it grows only while the rows asked for are not inside it,
4946    /// so the transcript's older megabytes are not read for a page that lands
4947    /// inside the first window.
4948    #[test]
4949    fn a_row_read_grows_its_window_only_while_the_rows_are_outside_it() {
4950        let config = tempfile::tempdir().unwrap();
4951        let cwd = "/Users/me/Projects/playground";
4952        let dir = project_dir_for(config.path(), cwd);
4953        fs::create_dir_all(&dir).unwrap();
4954        let session = "550e8400-e29b-41d4-a716-446655440000";
4955        let body = a_transcript(40);
4956        write_session_jsonl(&dir, session, &body);
4957        let len = body.len() as u64;
4958
4959        // A file's worth of cap, a tenth of it a step: the first window holds
4960        // the newest rows and not the thirty a page asked for, so the read
4961        // grows - and the page it answers is the one budgeted, not the window.
4962        let grown = read_rows_with(config.path(), session, Some(cwd), None, 30, len / 10, len)
4963            .expect("the read grows to hold the page");
4964        assert_eq!(grown.messages.len(), 30, "the page's own rows and no more");
4965        assert_eq!(
4966            said(grown.messages.first().expect("a first")),
4967            "turn 10",
4968            "taken nearest the boundary",
4969        );
4970        assert!(!grown.exhausted, "which is not the file's start");
4971
4972        // And the cap is where the search stops: an anchor further from the
4973        // end than the cap reaches answers nothing rather than a long read.
4974        assert!(
4975            read_rows_with(
4976                config.path(),
4977                session,
4978                Some(cwd),
4979                Some(&anchored("u5")),
4980                100,
4981                len / 5,
4982                len / 5,
4983            )
4984            .is_none(),
4985            "an anchor the cap cannot reach is not answered by reading further",
4986        );
4987    }
4988
4989    /// An anchor the file does not carry is a page the read cannot vouch for:
4990    /// the search finds no such row and the answer is nothing, which the
4991    /// caller draws as the end of the history.
4992    #[test]
4993    fn a_row_read_refuses_an_anchor_the_file_does_not_carry() {
4994        let config = tempfile::tempdir().unwrap();
4995        let cwd = "/Users/me/Projects/playground";
4996        let dir = project_dir_for(config.path(), cwd);
4997        fs::create_dir_all(&dir).unwrap();
4998        let session = "550e8400-e29b-41d4-a716-446655440000";
4999        write_session_jsonl(&dir, session, &a_transcript(40));
5000
5001        assert!(
5002            read_rows(config.path(), session, Some(cwd), Some(&anchored("nowhere")), 100).is_none(),
5003            "an anchor the file does not carry answers nothing",
5004        );
5005    }
5006
5007    /// No file for the session is no page: the read answers nothing rather
5008    /// than an empty span a caller could read as the end of a history.
5009    #[test]
5010    fn a_row_read_answers_nothing_without_a_transcript() {
5011        let config = tempfile::tempdir().unwrap();
5012        let cwd = "/Users/me/Projects/playground";
5013        let dir = project_dir_for(config.path(), cwd);
5014        fs::create_dir_all(&dir).unwrap();
5015        let session = "550e8400-e29b-41d4-a716-446655440000";
5016
5017        assert!(
5018            read_rows(config.path(), session, Some(cwd), Some(&anchored("u1")), 100).is_none(),
5019            "no file for the session is no span",
5020        );
5021        write_session_jsonl(&dir, session, "");
5022        assert!(
5023            read_rows(config.path(), session, Some(cwd), None, 100).is_none(),
5024            "and an empty file is none either",
5025        );
5026        assert!(
5027            read_rows(config.path(), "../escape", Some(cwd), None, 100).is_none(),
5028            "and an id that is not a uuid names a path this refuses rather than resolves",
5029        );
5030    }
5031}