Files
claude-cloak/src/sessions.rs
Jonas H ba6b18e7d9 Lane server tools, lift task notes, parse ANSI
Three streams of data were being lost or mangled in the feed.

WebSearch is not purely client-side: it issues a nested /v1/messages that
declares Anthropic's hosted web_search under the parent session id and with
no agent-id header. Read as a turn start it pushed a fake user prompt,
clobbered the main lane's system/tools signatures and downgraded a [1m]
session to the short window on the next resume. Requests are now classified
three ways (Turn / ServerTool / Side) from the tools array shape alone, and a
nested call gets its own lane, readable only in the A popup. Its result and
citations arrive complete in the stream and are echoed back in no later
request body, so they are attached as the stream delivers them.

An Agent call returns its tool_result immediately ("async agent launched"),
so the real completion is a <task-notification> injected into the parent's
next user turn. Those are lifted out of the prompt: the report moves onto the
Agent entry it answers, the usage totals onto the lane, and the status
becomes one glyph-led note line. A finished agent used to keep reading as
running.

Tool output we do not control carries SGR codes. A self-contained parser maps
them to styles instead of leaving [1m as literal text; filled blocks keep
their own colours and take only the attributes.

Also: a non-2xx upstream response now surfaces as an error entry instead of a
silent stall, tool renderers cover the file/shell/task/prompt/web families,
and the fake upstream answers the nested hosted-tool request so both search
paths run offline.
2026-08-27 10:24:05 +02:00

1669 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Past-session discovery + the on-disk turn tree: keeps the session list
//! populated with this directory's history, parses a session's JSONL into a
//! tree of *turns* (Claude Code records carry `uuid`/`parentUuid`, and every
//! Esc-Esc rewind leaves a branch point behind), and materializes new session
//! files from a chosen set of turns (branching / cherry-picking).
//!
//! Claude Code stores one `.jsonl` file per session under
//! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every
//! character that isn't an ASCII letter or digit with `-` (so `/`, `.`, `_`,
//! spaces … all become `-`) — see `encode_cwd`.
use crate::app::{
Entry, Kind, LaneId, MAIN_LANE, Session, SharedApp, TaskNotification, ToolResult,
flatten_result_content, lock_app, split_task_notifications, strip_injected, task_note_line,
};
use serde_json::Value;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Clone, PartialEq)]
pub struct DiskSession {
pub uuid: String,
/// Subagent transcripts recorded alongside this session, shown as `⑂N` in
/// the list. Counted in the same cached pass as the label (a subagent run
/// always writes the parent's `Agent` records too, so the parent's mtime
/// moves whenever this can change).
pub agents: usize,
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
pub label: String,
/// API model id of the session's last main-chain assistant message
/// (empty when the file has none yet). `App::resume_model` turns it into
/// the `--model` argument a resume spawns with.
pub model: String,
pub modified: SystemTime,
}
/// Background scanner: keeps `App::disk_sessions` in sync with the project
/// directory so the UI never does disk I/O for the session list. Polls ~1/s;
/// labels and models are re-read only for files whose mtime changed, so the
/// steady-state cost is one `read_dir` + a stat per file. The app mutex is
/// only taken when the list actually changed.
pub fn spawn_scanner(app: SharedApp) {
std::thread::spawn(move || {
// uuid → (mtime when read, label, model, subagent count): skip
// re-parsing unchanged files (one pass yields all — see `read_meta`).
let mut meta: HashMap<String, (SystemTime, String, String, usize)> = HashMap::new();
let mut last: Vec<DiskSession> = Vec::new();
loop {
let list = scan(&mut meta).unwrap_or_default();
if list != last {
last = list.clone();
lock_app(&app).set_disk_sessions(list);
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
});
}
/// One scan of the project directory, newest first.
fn scan(
meta: &mut HashMap<String, (SystemTime, String, String, usize)>,
) -> Result<Vec<DiskSession>, String> {
let dir = project_dir()?;
let rd = std::fs::read_dir(&dir)
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
let mut sessions: Vec<DiskSession> = rd
.flatten()
.filter_map(|e| {
let path = e.path();
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
return None;
}
let uuid = path.file_stem()?.to_str()?.to_string();
let modified = e.metadata().ok()?.modified().ok()?;
let (label, model, agents) = match meta.get(&uuid) {
Some((m, l, md, n)) if *m == modified => (l.clone(), md.clone(), *n),
_ => {
let (label, model) = read_meta(&path, &uuid);
let agents = scan_agents(&uuid).len();
meta.insert(
uuid.clone(),
(modified, label.clone(), model.clone(), agents),
);
(label, model, agents)
}
};
Some(DiskSession {
uuid,
label,
model,
agents,
modified,
})
})
.collect();
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(sessions)
}
/// One turn of a session: a real user prompt plus everything that chains off
/// it (assistant blocks, tool results, attachments…) until the next prompt.
pub struct Turn {
/// First line of the user's prompt (injected blocks filtered out).
pub label: String,
/// Indent for tree rendering: abandoned (non-trunk) branches sit one
/// level under their fork point; the most recent continuation stays at
/// its parent's depth.
pub depth: usize,
/// Parent turn (None for the session's first prompt).
pub parent: Option<usize>,
/// Raw JSONL lines belonging to this turn, in file order.
pub lines: Vec<String>,
}
/// A session's turns as a tree. Built from `uuid`/`parentUuid` chains:
/// uuid-less records (mode, file-history-snapshot, last-prompt…) attach to
/// the turn of the record preceding them in the file.
pub struct TurnTree {
pub turns: Vec<Turn>,
/// Records before/outside any turn (mode, the caveat record, …).
pub preamble: Vec<String>,
/// Turn indices in render order: DFS where abandoned branches are listed
/// (indented) right after their fork point and the trunk continues below.
pub display: Vec<usize>,
}
impl TurnTree {
/// Children of `t` in creation (= chronological) order.
fn children(&self, t: usize) -> impl Iterator<Item = usize> + '_ {
(t + 1..self.turns.len()).filter(move |&c| self.turns[c].parent == Some(t))
}
/// Follow the most recent child from `t` down to a leaf — the path a
/// `claude --resume` would continue on from that point.
pub fn trunk_leaf(&self, from: usize) -> usize {
let mut t = from;
while let Some(c) = self.children(t).max() {
t = c;
}
t
}
/// Root → `leaf` chain, inclusive.
pub fn chain(&self, leaf: usize) -> Vec<usize> {
let mut v = vec![leaf];
let mut t = leaf;
while let Some(p) = self.turns[t].parent {
v.push(p);
t = p;
}
v.reverse();
v
}
}
/// The user-typed prompt text of a record, if it starts a turn: a `user`
/// record that is not meta/sidechain and carries non-injected text.
fn prompt_text(v: &Value) -> Option<String> {
if v.get("type").and_then(Value::as_str) != Some("user")
|| v.get("isSidechain").and_then(Value::as_bool) == Some(true)
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
{
return None;
}
let text = match v.pointer("/message/content") {
Some(Value::String(s)) => strip_injected(s),
Some(Value::Array(blocks)) => blocks
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|b| b.get("text").and_then(Value::as_str))
.map(strip_injected)
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => return None,
};
(!text.is_empty()).then_some(text)
}
/// Parse a session file into its turn tree. None when the file has no turns
/// (nothing recorded yet, or unreadable).
pub fn load_tree(uuid: &str) -> Option<TurnTree> {
let path = project_dir().ok()?.join(format!("{uuid}.jsonl"));
let f = std::fs::File::open(path).ok()?;
let reader = std::io::BufReader::new(f);
let tree = build_tree(reader.lines().map_while(Result::ok));
(!tree.turns.is_empty()).then_some(tree)
}
/// Tree construction from raw JSONL lines (separated from I/O for tests).
pub(crate) fn build_tree(lines: impl IntoIterator<Item = String>) -> TurnTree {
let mut turns: Vec<Turn> = Vec::new();
let mut preamble: Vec<String> = Vec::new();
// record uuid → turn it belongs to (None = preamble).
let mut turn_of: HashMap<String, Option<usize>> = HashMap::new();
// positional bucket for uuid-less records: the previous record's turn.
let mut cur: Option<usize> = None;
for line in lines {
let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue;
};
let uuid = v.get("uuid").and_then(Value::as_str).map(str::to_string);
let bucket = match (&uuid, prompt_text(&v)) {
(Some(_), Some(label)) => {
// A real prompt starts a new turn, chained to the turn of its
// parent record (rewinds make this an *earlier* turn).
let parent = v
.get("parentUuid")
.and_then(Value::as_str)
.and_then(|p| turn_of.get(p).copied())
.flatten();
turns.push(Turn {
label: one_line(&label),
depth: 0,
parent,
lines: Vec::new(),
});
Some(turns.len() - 1)
}
(Some(_), None) => match v.get("parentUuid").and_then(Value::as_str) {
// Chain membership when the parent is known; otherwise fall
// back to position (orphaned records exist in old files).
Some(p) => turn_of.get(p).copied().unwrap_or(cur),
None => None, // root record → preamble
},
(None, _) => cur, // uuid-less metadata rides with its neighbours
};
match bucket {
Some(t) => turns[t].lines.push(line),
None => preamble.push(line),
}
if let Some(u) = uuid {
turn_of.insert(u, bucket);
}
cur = bucket;
}
// Render order + depths: abandoned branches indent under the fork point
// and are listed first; the most recent child continues at parent depth.
let mut children: Vec<Vec<usize>> = vec![Vec::new(); turns.len()];
let mut roots: Vec<usize> = Vec::new();
for (i, t) in turns.iter().enumerate() {
match t.parent {
Some(p) => children[p].push(i),
None => roots.push(i),
}
}
let mut display = Vec::with_capacity(turns.len());
let mut stack: Vec<(usize, usize)> = roots.iter().rev().map(|&r| (r, 0)).collect();
while let Some((t, d)) = stack.pop() {
display.push(t);
turns[t].depth = d;
let ch = &children[t];
for (i, &c) in ch.iter().enumerate().rev() {
stack.push((c, if i + 1 == ch.len() { d } else { d + 1 }));
}
}
TurnTree { turns, preamble, display }
}
/// Write a brand-new session file made of `turn_idxs` (chronological order)
/// plus the preamble: sessionId rewritten throughout, each turn's first
/// record re-parented onto the previous turn's tail (the first one onto the
/// preamble tail / null), so the result is a self-consistent linear session
/// fully decoupled from its origin. Returns the new session uuid.
pub fn materialize(tree: &TurnTree, turn_idxs: &[usize], title: &str) -> Result<String, String> {
let dir = project_dir()?;
materialize_in(&dir, tree, turn_idxs, title)
}
fn materialize_in(
dir: &std::path::Path,
tree: &TurnTree,
turn_idxs: &[usize],
title: &str,
) -> Result<String, String> {
let new_uuid = uuid::Uuid::new_v4().to_string();
let mut out = String::new();
// Our own title record first: `read_label` (and Claude Code's picker)
// prefer it, so the branch gets a meaningful name.
out.push_str(
&serde_json::json!({"type":"ai-title","aiTitle":title,"sessionId":new_uuid}).to_string(),
);
out.push('\n');
fn emit(
line: &str,
new_uuid: &str,
head_of_turn: &mut bool,
tail: &mut Option<String>,
out: &mut String,
) {
let Ok(mut v) = serde_json::from_str::<Value>(line) else {
return;
};
// The origin's title must not override ours.
if v.get("type").and_then(Value::as_str) == Some("ai-title") {
return;
}
if let Some(o) = v.as_object_mut() {
o.insert("sessionId".into(), Value::String(new_uuid.to_string()));
if let Some(u) = o.get("uuid").and_then(Value::as_str).map(str::to_string) {
if *head_of_turn {
o.insert(
"parentUuid".into(),
tail.clone().map_or(Value::Null, Value::String),
);
*head_of_turn = false;
}
*tail = Some(u);
}
}
out.push_str(&v.to_string());
out.push('\n');
}
let mut tail: Option<String> = None;
let mut no_rewire = false; // preamble keeps its own chain
for l in &tree.preamble {
emit(l, &new_uuid, &mut no_rewire, &mut tail, &mut out);
}
for &t in turn_idxs {
let turn = tree
.turns
.get(t)
.ok_or_else(|| format!("turn {t} out of range"))?;
let mut head = true;
for l in &turn.lines {
emit(l, &new_uuid, &mut head, &mut tail, &mut out);
}
}
let path = dir.join(format!("{new_uuid}.jsonl"));
std::fs::write(&path, out).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(new_uuid)
}
/// One subagent transcript on disk. Claude Code ≥2.1.2x writes each subagent
/// to `<project>/<session-uuid>/subagents/agent-<agentId>.jsonl` with a small
/// `agent-<agentId>.meta.json` sidecar; the sidecar alone reconstructs the whole
/// lane tree, so a multi-megabyte transcript is only read when its lane is
/// actually rendered.
pub struct DiskAgent {
pub agent_id: String,
pub agent_type: String,
pub description: String,
/// The parent's `Agent` tool_use id — where this lane is spliced in.
pub tool_use_id: String,
/// Set from spawn depth 2 (a subagent of a subagent).
pub parent_agent_id: Option<String>,
pub spawn_depth: u8,
pub path: PathBuf,
}
/// An agent transcript larger than this is summarised instead of parsed: the
/// view is built synchronously while the app mutex is held, and a few MB of
/// JSONL would stall the UI (and the proxy tap that shares the mutex).
const MAX_AGENT_BYTES: u64 = 8 * 1024 * 1024;
/// Subagent transcripts recorded for `uuid`, in spawn order. Reads only the
/// `.meta.json` sidecars.
pub fn scan_agents(uuid: &str) -> Vec<DiskAgent> {
let Ok(dir) = project_dir() else {
return Vec::new();
};
scan_agents_in(&dir.join(uuid).join("subagents"))
}
/// `scan_agents` against an explicit directory (the I/O seam tests use).
pub(crate) fn scan_agents_in(subdir: &std::path::Path) -> Vec<DiskAgent> {
let Ok(rd) = std::fs::read_dir(subdir) else {
return Vec::new();
};
let mut out: Vec<DiskAgent> = rd
.flatten()
.filter_map(|e| {
let meta_path = e.path();
let name = meta_path.file_name()?.to_str()?;
let agent_id = name.strip_prefix("agent-")?.strip_suffix(".meta.json")?;
let v: Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).ok()?).ok()?;
let field = |k: &str| {
v.get(k)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
Some(DiskAgent {
agent_id: agent_id.to_string(),
agent_type: field("agentType"),
description: field("description"),
tool_use_id: field("toolUseId"),
parent_agent_id: v
.get("parentAgentId")
.and_then(Value::as_str)
.map(str::to_string),
spawn_depth: v
.get("spawnDepth")
.and_then(Value::as_u64)
.unwrap_or(1)
.try_into()
.unwrap_or(1),
path: subdir.join(format!("agent-{agent_id}.jsonl")),
})
})
.collect();
out.sort_by(|a, b| {
a.spawn_depth
.cmp(&b.spawn_depth)
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
out
}
/// A rendered transcript: the feed `Session` plus which tree path it shows.
pub struct HistoryView {
pub session: Session,
/// Some(leaf turn) when rendered along a tree path; None = whole file
/// in raw order (legacy view for un-expanded stubs).
pub leaf: Option<usize>,
/// (turn index, first entry index) along the path — feed auto-scroll.
pub turn_entries: Vec<(usize, usize)>,
}
/// Rebuild a feed `Session` from a past session's JSONL transcript so the
/// feed isn't blank when resuming (no API traffic flows until the next turn).
/// Live taps for the resumed session find this entry by key and append to it.
pub fn load_history(uuid: &str) -> Option<Session> {
load_view(uuid, None).map(|h| h.session)
}
/// Path-aware transcript view: with `path = Some((tree, leaf))` only the
/// preamble + the root→leaf chain is rendered (dead branches excluded) and
/// per-turn entry offsets are recorded; with None, the whole file.
pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<HistoryView> {
// Subagent transcripts live in their own files next to the session's, so
// they are spliced in after the main chain is parsed (see `splice_agents`).
let agents = scan_agents(uuid);
match path {
None => {
let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl"));
load_file_view(&fpath, uuid, &agents)
}
Some((tree, leaf)) => {
let mut p = EntryParser::new();
let mut turn_entries: Vec<(usize, usize)> = Vec::new();
for l in &tree.preamble {
p.line(l);
}
for &t in &tree.chain(leaf) {
turn_entries.push((t, p.entries.len()));
for l in &tree.turns[t].lines {
p.line(l);
}
}
let anchors = std::mem::take(&mut p.agent_tools);
let usage = std::mem::take(&mut p.task_usage);
let mut view = p.into_view(uuid, Some(leaf), turn_entries)?;
splice_agents(&mut view, anchors, &agents, usage);
Some(view)
}
}
}
/// Whole-file transcript view from an explicit path (the I/O-location seam:
/// tests parse temp files without touching `HOME`).
pub(crate) fn load_file_view(
path: &std::path::Path,
uuid: &str,
agents: &[DiskAgent],
) -> Option<HistoryView> {
let f = std::fs::File::open(path).ok()?;
let mut p = EntryParser::new();
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
p.line(&line);
}
let anchors = std::mem::take(&mut p.agent_tools);
let usage = std::mem::take(&mut p.task_usage);
let mut view = p.into_view(uuid, None, Vec::new())?;
splice_agents(&mut view, anchors, agents, usage);
Some(view)
}
/// Claude Code's own accounting for one whole agent run, scraped from the
/// `<usage>` block of a `<task-notification>`. A transcript records no *API*
/// usage, but it does record the notifications, so this is the only token
/// figure an on-disk lane can ever have (`Lane::subagent_tokens` and friends).
#[derive(Default, Clone, Copy, Debug)]
struct LaneUsage {
tokens: Option<u64>,
tool_uses: Option<u64>,
duration_ms: Option<u64>,
}
/// Agent id → run totals. The key is a notification's `<task-id>`, which is
/// also the agent id its lane is registered under and the stem of its
/// `subagents/agent-<id>.jsonl` — so the usage a *parent's* file reports finds
/// the child's lane without any extra lookup table.
type UsageByAgent = HashMap<String, LaneUsage>;
impl LaneUsage {
/// Field-by-field, later totals winning — same policy as the live
/// `Session::record_task_usage` (an agent woken again by `SendMessage`
/// reports afresh).
fn merge(&mut self, o: LaneUsage) {
self.tokens = o.tokens.or(self.tokens);
self.tool_uses = o.tool_uses.or(self.tool_uses);
self.duration_ms = o.duration_ms.or(self.duration_ms);
}
fn is_empty(&self) -> bool {
self.tokens.is_none() && self.tool_uses.is_none() && self.duration_ms.is_none()
}
}
/// Remember a notification's `<usage>` under its agent id, for `splice_agents`
/// to hand to that agent's lane once the lanes exist.
fn record_task_usage(usage: &mut UsageByAgent, n: &TaskNotification) {
let u = LaneUsage {
tokens: n.subagent_tokens,
tool_uses: n.tool_uses,
duration_ms: n.duration_ms,
};
if u.is_empty() {
return;
}
usage.entry(n.task_id.clone()).or_default().merge(u);
}
/// Parse one subagent transcript into its own lane, merging any `<usage>` it
/// reports for *its* children into `usage`. Oversized files are summarised
/// rather than parsed: the view is built while the app mutex is held (the
/// proxy tap shares it), so a few MB of JSONL must not stall it.
fn parse_agent_file(
path: &std::path::Path,
lane: LaneId,
usage: &mut UsageByAgent,
) -> (Vec<Entry>, HashMap<String, usize>) {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
if size > MAX_AGENT_BYTES {
let mb = size / (1024 * 1024);
let note =
Entry::meta(format!("(subagent transcript too large to show: {mb} MB)")).in_lane(lane);
return (vec![note], HashMap::new());
}
let Ok(f) = std::fs::File::open(path) else {
return (Vec::new(), HashMap::new());
};
let mut p = EntryParser::new_lane(lane);
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
p.line(&line);
}
for (id, u) in p.task_usage {
usage.entry(id).or_default().merge(u);
}
(p.entries, p.agent_tools)
}
/// Tool calls per lane, for the agent picker's summary line.
fn count_tools(entries: &[Entry]) -> HashMap<LaneId, usize> {
let mut out: HashMap<LaneId, usize> = HashMap::new();
for e in entries {
if matches!(e.kind, Kind::Tool { .. }) {
*out.entry(e.lane).or_default() += 1;
}
}
out
}
/// Insert `add` at `at`, keeping the anchor map pointing at the same entries.
/// Anchors *at* the insertion point shift; the spawning tool call itself sits
/// at `at - 1` and stays put.
fn insert_entries(
entries: &mut Vec<Entry>,
anchors: &mut HashMap<String, usize>,
at: usize,
add: Vec<Entry>,
) {
let n = add.len();
let at = at.min(entries.len());
entries.splice(at..at, add);
for v in anchors.values_mut() {
if *v >= at {
*v += n;
}
}
}
/// Splice each subagent transcript into the view right after the `Agent` tool
/// call that spawned it, as its own lane.
///
/// Deepest agents go first, so a depth-2 transcript is nested into its parent's
/// entry list *before* that list is spliced into the main chain; within a level
/// the highest anchor goes first. A lane whose spawn point is not in this view
/// (a path view can exclude that turn) keeps no entries, so it never shows up
/// in the agent popup.
///
/// `usage` carries the `<task-notification>` totals the main chain reported
/// (see `LaneUsage`); each agent file's own notifications are merged in as it
/// is parsed, and the lot is handed to the lanes at the end.
fn splice_agents(
view: &mut HistoryView,
mut anchors: HashMap<String, usize>,
agents: &[DiskAgent],
mut usage: UsageByAgent,
) {
if agents.is_empty() {
return;
}
struct Loaded {
id: String,
lane: LaneId,
entries: Vec<Entry>,
anchors: HashMap<String, usize>,
tool_use_id: String,
parent: Option<String>,
depth: u8,
spliced: bool,
}
let mut loaded: Vec<Loaded> = agents
.iter()
.map(|a| {
let lane = view.session.add_lane(
a.agent_id.clone(),
a.agent_type.clone(),
a.description.clone(),
Some(a.tool_use_id.clone()),
None,
a.spawn_depth,
);
let (entries, anchors) = parse_agent_file(&a.path, lane, &mut usage);
Loaded {
id: a.agent_id.clone(),
lane,
entries,
anchors,
tool_use_id: a.tool_use_id.clone(),
parent: a.parent_agent_id.clone(),
depth: a.spawn_depth,
spliced: false,
}
})
.collect();
// Nest children into their parents, deepest first.
let mut order: Vec<usize> = (0..loaded.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(loaded[i].depth));
for ci in order {
let Some(pid) = loaded[ci].parent.clone() else {
continue;
};
let Some(pi) = loaded.iter().position(|l| l.id == pid) else {
continue;
};
if pi == ci || loaded[ci].entries.is_empty() {
continue;
}
let child = std::mem::take(&mut loaded[ci].entries);
let tuid = loaded[ci].tool_use_id.clone();
let parent_lane = loaded[pi].lane;
{
let p = &mut loaded[pi];
match p.anchors.get(&tuid).copied() {
Some(at) => insert_entries(&mut p.entries, &mut p.anchors, at + 1, child),
// The parent's transcript doesn't contain the spawn point:
// keep the entries rather than lose them.
None => p.entries.extend(child),
}
}
loaded[ci].spliced = true;
let l = &mut view.session.lanes[loaded[ci].lane as usize];
l.parent = Some(parent_lane);
}
// Splice what's left into the main chain, highest anchor first.
let mut top: Vec<usize> = (0..loaded.len()).filter(|&i| !loaded[i].spliced).collect();
top.sort_by_key(|&i| {
std::cmp::Reverse(anchors.get(&loaded[i].tool_use_id).copied().unwrap_or(0))
});
for i in top {
let entries = std::mem::take(&mut loaded[i].entries);
if entries.is_empty() {
continue;
}
let Some(at) = anchors.get(&loaded[i].tool_use_id).copied() else {
continue; // spawn point outside this view
};
let n = entries.len();
insert_entries(&mut view.session.entries, &mut anchors, at + 1, entries);
// Turn offsets after the insertion point move with it.
for (_, e) in view.turn_entries.iter_mut() {
if *e > at {
*e += n;
}
}
let l = &mut view.session.lanes[loaded[i].lane as usize];
l.anchor = Some(at);
l.parent = Some(MAIN_LANE);
}
view.session.reindex_lanes();
// Tool counts for the agent picker, straight off the entries.
for (lane, n) in count_tools(&view.session.entries) {
view.session.lanes[lane as usize].tool_calls = n;
}
// Run totals for the picker and the agent-feed title. A transcript records
// no API usage, but the `<task-notification>`s in it carry Claude Code's
// own accounting, keyed by the agent id the lane is registered under. An
// id that matches no lane (a background *bash* task, or an agent whose
// transcript this session never kept) is simply skipped — the same
// silence as the live `Session::record_task_usage`.
for (id, u) in &usage {
let Some(&lane) = view.session.lane_of_agent.get(id) else {
continue;
};
let l = &mut view.session.lanes[lane as usize];
l.subagent_tokens = u.tokens.or(l.subagent_tokens);
l.tool_uses = u.tool_uses.or(l.tool_uses);
l.duration_ms = u.duration_ms.or(l.duration_ms);
}
}
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
/// file and path views).
struct EntryParser {
entries: Vec<Entry>,
model: String,
/// tool_use id → entry index, to attach results from later user lines.
tool_idx: HashMap<String, usize>,
/// `Agent`/`Task` tool_use id → entry index. Kept separately from
/// `tool_idx`, which is *drained* as results attach — the anchor is still
/// needed afterwards to splice the subagent's transcript in.
agent_tools: HashMap<String, usize>,
/// `<usage>` totals scraped from this file's `<task-notification>`s, keyed
/// by the agent id they report on. Collected in the parse pass and applied
/// to the lanes by `splice_agents`, which is where the lanes exist.
task_usage: UsageByAgent,
/// Lane every parsed entry is tagged with (0 = the main chain).
lane: LaneId,
/// Keep `isSidechain` records instead of skipping them. A subagent file
/// (`subagents/agent-<id>.jsonl`) consists *entirely* of such records, so
/// the main-chain skip would yield an empty lane.
keep_sidechain: bool,
}
impl EntryParser {
fn new() -> Self {
Self {
entries: Vec::new(),
model: String::from("(resumed)"),
tool_idx: HashMap::new(),
agent_tools: HashMap::new(),
task_usage: UsageByAgent::new(),
lane: MAIN_LANE,
keep_sidechain: false,
}
}
/// Parser for one subagent transcript: entries land in `lane` and the
/// sidechain records that make up the file are kept.
fn new_lane(lane: LaneId) -> Self {
Self {
lane,
keep_sidechain: true,
..Self::new()
}
}
/// Wrap the parsed entries into a `HistoryView` (None when empty).
fn into_view(
self,
uuid: &str,
leaf: Option<usize>,
turn_entries: Vec<(usize, usize)>,
) -> Option<HistoryView> {
if self.entries.is_empty() {
return None;
}
// One construction site for a `Session`, so a new field only needs a
// default in `Session::new`.
let mut session = Session::new(uuid.to_string(), self.model);
session.entries = self.entries;
Some(HistoryView {
session,
leaf,
turn_entries,
})
}
fn line(&mut self, line: &str) {
let lane = self.lane;
let entries = &mut self.entries;
let tool_idx = &mut self.tool_idx;
let agent_tools = &mut self.agent_tools;
let task_usage = &mut self.task_usage;
let Ok(v) = serde_json::from_str::<Value>(line) else {
return;
};
// Skip synthetic/meta user lines, and — for the main chain — subagent
// records. `new_lane` parsers keep the latter: an agent file is made of
// nothing else. (Claude Code ≥2.1.2x writes subagents to their own
// files, so the main-chain guard is also belt-and-braces for older
// transcripts that inlined them.)
if (!self.keep_sidechain && v.get("isSidechain").and_then(Value::as_bool) == Some(true))
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
{
return;
}
match v.get("type").and_then(Value::as_str) {
Some("assistant") => {
if let Some(m) = v.pointer("/message/model").and_then(Value::as_str) {
self.model = m.to_string();
}
let Some(blocks) = v.pointer("/message/content").and_then(Value::as_array)
else {
return;
};
for b in blocks {
let entry = match b.get("type").and_then(Value::as_str) {
Some("thinking") => {
Some(Entry::done(Kind::Thinking, text_of(b, "thinking")))
}
Some("redacted_thinking") => {
Some(Entry::done(Kind::Thinking, "[redacted]".into()))
}
Some("text") => Some(Entry::done(Kind::Text, text_of(b, "text"))),
Some("tool_use" | "server_tool_use" | "mcp_tool_use") => {
let tool_name = b.get("name").and_then(Value::as_str).unwrap_or("");
if let Some(id) = b.get("id").and_then(Value::as_str) {
tool_idx.insert(id.to_string(), entries.len());
if crate::app::is_agent_tool(tool_name) {
agent_tools.insert(id.to_string(), entries.len());
}
}
let input = b.get("input").map_or(String::new(), |i| {
serde_json::to_string_pretty(i).unwrap_or_default()
});
let name = b
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
Some(Entry::done(Kind::Tool { name }, input))
}
_ => None,
};
if let Some(e) = entry {
entries.push(e.in_lane(lane));
}
}
}
Some("user") => match v.pointer("/message/content") {
Some(Value::String(s)) => {
push_user_text(entries, agent_tools, task_usage, s, lane)
}
Some(Value::Array(blocks)) => {
for b in blocks {
match b.get("type").and_then(Value::as_str) {
Some("text") => push_user_text(
entries,
agent_tools,
task_usage,
&text_of(b, "text"),
lane,
),
Some("tool_result") => {
let Some(idx) = b
.get("tool_use_id")
.and_then(Value::as_str)
.and_then(|id| tool_idx.remove(id))
else {
continue;
};
if let Some(e) = entries.get_mut(idx) {
e.result = Some(ToolResult {
content: flatten_result_content(b.get("content")),
is_error: b
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
});
}
}
_ => {}
}
}
}
_ => {}
},
_ => {}
}
}
}
fn text_of(b: &Value, key: &str) -> String {
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
}
/// Move an agent's final report onto the `Agent` tool entry that spawned it,
/// replacing the `Async agent launched successfully… agentId: <hex>`
/// acknowledgement the parent model got at launch time. The disk mirror of
/// `Session::attach_task_report`, and a shorter one: the notification names
/// the `<tool-use-id>` of that very call, and `EntryParser::agent_tools` still
/// holds it (unlike `tool_idx`, which is drained when the launch result
/// attaches) — so no lane/anchor round-trip is needed. Both records live in
/// the same file, nested agents included: a depth-2 `Agent` call and the
/// notification answering it both sit in the parent *agent's* transcript.
///
/// False when the spawn point is not in this parse (a path view can exclude
/// that turn, and 6 of the notifications in the real transcripts name a call
/// recorded nowhere we read); the caller then keeps the report inline in the
/// note rather than losing it.
fn attach_task_report(
entries: &mut [Entry],
agent_tools: &HashMap<String, usize>,
n: &TaskNotification,
report: &str,
) -> bool {
let Some(&idx) = n.tool_use_id.as_deref().and_then(|id| agent_tools.get(id)) else {
return false;
};
let Some(e) = entries.get_mut(idx) else {
return false;
};
e.result = Some(ToolResult {
content: report.to_string(),
is_error: n.failed(),
});
true
}
/// Translate a user text block into feed entries, exactly as the live path
/// (`app::record_user_prompt`) does, so a session reads the same whether it is
/// streaming or loaded from disk:
///
/// 1. every `<task-notification>` is lifted out — of the prompt *and* of each
/// injected reminder, since Claude Code sometimes wraps one in a
/// `<system-reminder>` — and becomes one `Kind::TaskNote` line, with the
/// agent's `<result>` report moved onto its `Agent` tool entry (or kept
/// inline when that call is not in this view). `<usage>` is remembered for
/// the lane;
/// 2. each remaining reminder as a dimmed `Kind::Reminder`;
/// 3. what is left as the real prompt, `Kind::User`.
///
/// Notes come first, as they do live. Nothing is dropped: a block that fails
/// to parse is handed back inside the text by `split_task_notifications` and
/// so still shows in the prompt.
///
/// No resend dedup here (the live path's) — a transcript records each turn
/// once, so an identical note twice means the agent really was woken twice.
fn push_user_text(
entries: &mut Vec<Entry>,
agent_tools: &HashMap<String, usize>,
usage: &mut UsageByAgent,
text: &str,
lane: LaneId,
) {
let (reminders, prompt) = crate::app::extract_user_text(text);
let (mut notes, prompt) = split_task_notifications(&prompt);
let reminders: Vec<String> = reminders
.into_iter()
.filter_map(|r| {
let (n, rest) = split_task_notifications(&r);
notes.extend(n);
// A reminder that was *only* a notification leaves nothing to show.
(!rest.is_empty()).then_some(rest)
})
.collect();
for n in &notes {
record_task_usage(usage, n);
let report = n.result.as_deref().map(str::trim).filter(|r| !r.is_empty());
let attached = report.is_some_and(|r| attach_task_report(entries, agent_tools, n, r));
let line = task_note_line(n, if attached { None } else { report });
entries.push(Entry::done(Kind::TaskNote, line).in_lane(lane));
}
for r in reminders {
entries.push(Entry::done(Kind::Reminder, r).in_lane(lane));
}
if !prompt.is_empty() {
entries.push(Entry::done(Kind::User, prompt).in_lane(lane));
}
}
/// Encode the current working directory the same way Claude Code does:
/// every character that is not an ASCII letter or digit becomes `-` (so `/`,
/// `.`, `_`, spaces, … all collapse to `-`, and the leading `/` becomes a
/// leading `-`). Consecutive specials are *not* merged — `/.config` maps to
/// `--config`, matching Claude Code's `path.replace(/[^a-zA-Z0-9]/g, '-')`.
fn project_dir() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
let cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?;
let encoded = encode_cwd(
cwd.to_str().ok_or_else(|| {
"cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string()
})?,
);
let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded);
if !dir.is_dir() {
return Err("no past sessions recorded for this directory".to_string());
}
Ok(dir)
}
/// The path → project-folder mapping, split out so it can be unit-tested
/// without touching `HOME`/cwd. Mirrors Claude Code's
/// `dir.replace(/[^a-zA-Z0-9]/g, '-')`.
fn encode_cwd(path: &str) -> String {
path.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// Read a session's list metadata from its JSONL file in one pass:
/// `(label, model)`.
///
/// The label prefers `ai-title`; it falls back to the last non-empty
/// `last-prompt` text, then the uuid prefix. The model is the API model id of
/// the last *main-chain* assistant message — what Claude Code itself recorded
/// for the newest turn, so a resume can continue on it. Subagent records
/// (`isSidechain`) carry their own model and are skipped, as are the
/// `<synthetic>` ids Claude Code writes for API-error records.
fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
let fallback = || uuid.chars().take(8).collect::<String>();
let Ok(f) = std::fs::File::open(path) else {
return (fallback(), String::new());
};
let reader = std::io::BufReader::new(f);
let mut ai_title: Option<String> = None;
let mut last_prompt: Option<String> = None;
let mut model = String::new();
for line in reader.lines().map_while(Result::ok) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
match v.get("type").and_then(|t| t.as_str()) {
// First title wins (the scan no longer stops there — it still
// needs the model): a branch file we materialized carries our own
// `⑂ …` title first, and Claude Code may append its own later.
Some("ai-title") if ai_title.is_none() => {
if let Some(t) = v.get("aiTitle").and_then(|t| t.as_str())
&& !t.is_empty()
{
ai_title = Some(one_line(t));
}
}
Some("last-prompt") => {
if let Some(p) = v.get("lastPrompt").and_then(|p| p.as_str())
&& !p.is_empty()
{
last_prompt = Some(one_line(p));
}
}
Some("assistant") => {
if v.get("isSidechain").and_then(Value::as_bool) != Some(true)
&& let Some(m) = v.pointer("/message/model").and_then(Value::as_str)
&& !m.starts_with('<')
{
model = m.to_string();
}
}
_ => {}
}
}
let label = ai_title.or(last_prompt).unwrap_or_else(fallback);
(label, model)
}
/// First line only, control characters dropped — labels go into a one-row
/// list item, so embedded newlines/tabs would smear the layout.
fn one_line(s: &str) -> String {
s.lines()
.next()
.unwrap_or("")
.chars()
.filter(|c| !c.is_control())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// A user-prompt record (turn start).
fn prompt(uuid: &str, parent: Option<&str>, text: &str) -> String {
serde_json::json!({
"type": "user", "uuid": uuid, "parentUuid": parent,
"sessionId": "orig",
"message": {"role": "user", "content": text}
})
.to_string()
}
/// An assistant text record.
fn reply(uuid: &str, parent: &str, text: &str) -> String {
serde_json::json!({
"type": "assistant", "uuid": uuid, "parentUuid": parent,
"sessionId": "orig",
"message": {"model": "claude-x", "content": [{"type": "text", "text": text}]}
})
.to_string()
}
/// A branched fixture: prompt A → prompt B (abandoned) and, after a
/// rewind onto A's reply, prompt B2 → C (the trunk).
fn branched() -> Vec<String> {
vec![
r#"{"type":"mode","mode":"normal","sessionId":"orig"}"#.into(), // preamble
prompt("u1", None, "prompt A"),
reply("u2", "u1", "reply A"),
prompt("u3", Some("u2"), "prompt B"),
reply("u4", "u3", "reply B"),
prompt("u5", Some("u2"), "prompt B2"), // rewind: forks off reply A
reply("u6", "u5", "reply B2"),
prompt("u7", Some("u6"), "prompt C"),
reply("u8", "u7", "reply C"),
]
}
#[test]
fn tree_groups_turns_and_orders_branches() {
let tree = build_tree(branched());
assert_eq!(tree.turns.len(), 4);
assert_eq!(tree.preamble.len(), 1, "mode record lands in the preamble");
let labels: Vec<&str> = tree.turns.iter().map(|t| t.label.as_str()).collect();
assert_eq!(labels, ["prompt A", "prompt B", "prompt B2", "prompt C"]);
assert_eq!(tree.turns[1].parent, Some(0), "B forks off A");
assert_eq!(tree.turns[2].parent, Some(0), "B2 forks off A");
assert_eq!(tree.turns[3].parent, Some(2));
// Display: A, then the abandoned B indented, then trunk B2 → C.
assert_eq!(tree.display, [0, 1, 2, 3]);
assert_eq!(tree.turns[0].depth, 0);
assert_eq!(tree.turns[1].depth, 1, "abandoned branch indents");
assert_eq!(tree.turns[2].depth, 0, "most recent child stays on trunk");
assert_eq!(tree.turns[3].depth, 0);
// Trunk resolution + chains.
assert_eq!(tree.trunk_leaf(0), 3);
assert_eq!(tree.trunk_leaf(1), 1, "dead branch ends at its own leaf");
assert_eq!(tree.chain(3), [0, 2, 3]);
assert_eq!(tree.chain(1), [0, 1]);
}
#[test]
fn path_view_excludes_dead_branches_and_offsets_turns() {
let tree = build_tree(branched());
let h = load_view("some-uuid", Some((&tree, 3))).expect("view");
let texts: Vec<&str> = h.session.entries.iter().map(|e| e.content.as_str()).collect();
assert!(texts.iter().any(|t| t.contains("prompt B2")));
assert!(
!texts.iter().any(|t| *t == "reply B" || t.contains("prompt B\n") || t.ends_with("prompt B")),
"dead branch content must not leak into the path view: {texts:?}"
);
assert_eq!(h.leaf, Some(3));
// Turn offsets point at each turn's first entry (its prompt).
assert_eq!(h.turn_entries.len(), 3);
for &(t, e) in &h.turn_entries {
assert!(
h.session.entries[e].content.contains(&tree.turns[t].label),
"offset {e} should land on the prompt of turn {t}"
);
}
}
/// A subagent transcript is a separate file linked by `toolUseId`; the view
/// must splice it in right after the `Agent` call, in its own lane, with a
/// nested (depth-2) agent inside its parent's lane — and the main chain's
/// turn offsets must survive the insertion.
#[test]
fn subagent_files_splice_into_their_agent_call() {
let dir = std::env::temp_dir().join(format!("ct-agents-{}", std::process::id()));
let subs = dir.join("subagents");
std::fs::create_dir_all(&subs).unwrap();
let main = dir.join("s1.jsonl");
std::fs::write(
&main,
[
r#"{"type":"user","uuid":"u1","parentUuid":null,"message":{"role":"user","content":"go"}}"#,
r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","message":{"model":"claude-x","content":[{"type":"tool_use","id":"toolu_A","name":"Agent","input":{"subagent_type":"Explore","description":"sweep","prompt":"p"}}]}}"#,
r#"{"type":"user","uuid":"u2","parentUuid":"a1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_A","content":"done"}]}}"#,
r#"{"type":"assistant","uuid":"a2","parentUuid":"u2","message":{"model":"claude-x","content":[{"type":"text","text":"after"}]}}"#,
]
.join("\n"),
)
.unwrap();
// Depth-1 agent: makes its own nested Agent call.
std::fs::write(
subs.join("agent-aaa1.meta.json"),
r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#,
)
.unwrap();
std::fs::write(
subs.join("agent-aaa1.jsonl"),
[
r#"{"type":"user","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"role":"user","content":"p"}}"#,
r#"{"type":"assistant","isSidechain":true,"uuid":"s2","parentUuid":"s1","message":{"model":"claude-y","content":[{"type":"text","text":"child text"}]}}"#,
r#"{"type":"assistant","isSidechain":true,"uuid":"s3","parentUuid":"s2","message":{"model":"claude-y","content":[{"type":"tool_use","id":"toolu_B","name":"Agent","input":{"subagent_type":"oracle","description":"deep","prompt":"q"}}]}}"#,
]
.join("\n"),
)
.unwrap();
// Depth-2 agent, spawned by the first one.
std::fs::write(
subs.join("agent-bbb2.meta.json"),
r#"{"agentType":"oracle","description":"deep","toolUseId":"toolu_B","parentAgentId":"aaa1","spawnDepth":2}"#,
)
.unwrap();
std::fs::write(
subs.join("agent-bbb2.jsonl"),
r#"{"type":"assistant","isSidechain":true,"uuid":"n1","parentUuid":null,"message":{"model":"claude-z","content":[{"type":"text","text":"nested text"}]}}"#,
)
.unwrap();
let agents = scan_agents_in(&subs);
assert_eq!(agents.len(), 2);
assert_eq!(agents[0].agent_id, "aaa1");
assert_eq!(agents[1].parent_agent_id.as_deref(), Some("aaa1"));
let view = load_file_view(&main, "s1", &agents).expect("view");
let s = &view.session;
// Lanes: main + the two agents, labelled from their sidecars.
assert_eq!(s.lanes.len(), 3);
assert_eq!(s.lanes[1].title(), "Explore · sweep");
assert_eq!(
s.lanes[2].parent,
Some(1),
"nested agent hangs off its parent lane"
);
assert!(
s.lanes[1].finished(),
"a transcript on disk is a finished run"
);
assert_eq!(s.agent_lanes(), vec![1, 2]);
// Order: the child's entries sit between the Agent call and what
// followed it, and the nested lane sits inside its parent's stretch.
let order: Vec<(LaneId, &str)> = s
.entries
.iter()
.map(|e| (e.lane, e.content.as_str()))
.collect();
let pos = |needle: &str| {
order
.iter()
.position(|(_, c)| c.contains(needle))
.unwrap_or_else(|| panic!("missing {needle} in {order:?}"))
};
// A tool entry's content is its pretty-printed input.
assert!(pos("\"subagent_type\": \"Explore\"") < pos("child text"));
assert!(pos("child text") < pos("nested text"));
assert!(pos("nested text") < pos("after"));
assert_eq!(order[pos("child text")].0, 1);
assert_eq!(order[pos("nested text")].0, 2);
assert_eq!(order[pos("after")].0, MAIN_LANE);
// first_entry is recomputed after splicing, so a lane can anchor.
let first_of = |l: LaneId| s.entries.iter().position(|e| e.lane == l);
assert_eq!(s.lanes[1].first_entry, first_of(1));
assert_eq!(s.lanes[2].first_entry, first_of(2));
std::fs::remove_dir_all(&dir).ok();
}
/// An `Agent` tool call, and the launch acknowledgement its tool_result
/// carries (Claude Code launches every agent asynchronously).
fn agent_call(uuid: &str, parent: &str, tool_id: &str) -> String {
serde_json::json!({
"type": "assistant", "uuid": uuid, "parentUuid": parent,
"message": {"model": "claude-x", "content": [{
"type": "tool_use", "id": tool_id, "name": "Agent",
"input": {"subagent_type": "Explore", "description": "sweep", "prompt": "p"}
}]}
})
.to_string()
}
fn launch_result(uuid: &str, parent: &str, tool_id: &str, agent_id: &str) -> String {
serde_json::json!({
"type": "user", "uuid": uuid, "parentUuid": parent,
"message": {"role": "user", "content": [{
"type": "tool_result", "tool_use_id": tool_id,
"content": format!("Async agent launched successfully, agentId: {agent_id}")
}]}
})
.to_string()
}
/// A completion `<task-notification>` in Claude Code's real wire shape.
fn completion_note(task_id: &str, tool_id: &str, result: &str) -> String {
format!(
"<task-notification>\n\
<task-id>{task_id}</task-id>\n\
<tool-use-id>{tool_id}</tool-use-id>\n\
<output-file>/tmp/claude/tasks/{task_id}.output</output-file>\n\
<status>completed</status>\n\
<summary>Agent \"sweep\" finished</summary>\n\
<note>A task-notification fires each time this agent stops.</note>\n\
<result>{result}</result>\n\
<usage><subagent_tokens>128633</subagent_tokens><tool_uses>63</tool_uses>\
<duration_ms>1115197</duration_ms></usage>\n\
</task-notification>"
)
}
/// The four records of an agent run: prompt, `Agent` call, launch
/// acknowledgement, and the turn its completion notification rode in on.
fn agent_run(note: &str) -> Vec<String> {
vec![
prompt("u1", None, "go"),
agent_call("a1", "u1", "toolu_A"),
launch_result("u2", "a1", "toolu_A", "aaa1"),
prompt("u3", Some("a1"), &format!("{note}\nwhat did it find?")),
]
}
fn view_of(lines: &[String]) -> Session {
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let p = write_jsonl(&refs);
let s = load_file_view(&p, "sess-notif", &[]).map(|h| h.session);
std::fs::remove_file(&p).ok();
s.expect("view")
}
fn only_note(s: &Session) -> &Entry {
let mut it = s.entries.iter().filter(|e| e.kind == Kind::TaskNote);
let n = it.next().expect("a Kind::TaskNote entry");
assert!(it.next().is_none(), "exactly one note expected");
n
}
/// A `<task-notification>` in a transcript is lifted out of the prompt: a
/// one-line `Kind::TaskNote` in front of it, the XML (and the `<note>`
/// boilerplate) gone from the user entry — the live path's rendering.
#[test]
fn disk_task_notification_becomes_a_note_beside_the_prompt() {
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")));
let note = only_note(&s);
assert!(note.content.starts_with('✔'), "{}", note.content);
assert!(note.content.contains("sweep finished"), "{}", note.content);
assert!(note.content.contains("128.6k tok · 63 tools · 18m35s"), "{}", note.content);
// The report went to the tool call, so it is not repeated inline, and
// the spool path is only shown when there is no report at all.
assert!(!note.content.contains("THE REPORT"), "{}", note.content);
assert!(!note.content.contains(".output"), "{}", note.content);
for e in &s.entries {
assert!(!e.content.contains("<task-notification>"), "raw XML left in {:?}", e.content);
assert!(!e.content.contains("task-notification fires"), "boilerplate kept");
}
// What the user actually typed survives, as its own entry after the note.
let users: Vec<&str> = s
.entries
.iter()
.filter(|e| e.kind == Kind::User)
.map(|e| e.content.as_str())
.collect();
assert_eq!(users, ["go", "what did it find?"]);
let pos = |k: &Kind| s.entries.iter().position(|e| &e.kind == k).unwrap();
assert!(
pos(&Kind::TaskNote) < s.entries.iter().rposition(|e| e.kind == Kind::User).unwrap(),
"note precedes the prompt it rode in with"
);
}
/// The report replaces the `Async agent launched…` placeholder on the
/// `Agent` entry when `<tool-use-id>` resolves to a call in this view.
#[test]
fn disk_task_report_replaces_the_launch_placeholder() {
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")));
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
let r = tool.result.as_ref().expect("a result");
assert_eq!(r.content, "THE REPORT");
assert!(!r.is_error);
}
/// … and when it resolves to nothing (the spawning turn is outside this
/// view), the report stays inline in the note rather than being lost.
#[test]
fn disk_task_report_stays_inline_when_unresolved() {
let s = view_of(&agent_run(&completion_note("aaa1", "toolu_GONE", "THE REPORT")));
let note = only_note(&s);
assert!(note.content.contains("\n THE REPORT"), "{}", note.content);
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
assert!(
tool.result.as_ref().unwrap().content.contains("Async agent launched"),
"the launch acknowledgement is untouched when the report can't be placed"
);
}
/// A failed run marks the attached report as an error, and a `<status>`
/// holding a raw error body (4 of the real ones) is kept on the note.
#[test]
fn disk_failed_task_marks_the_report_as_an_error() {
let note = "<task-notification>\n\
<task-id>aaa1</task-id>\n\
<tool-use-id>toolu_A</tool-use-id>\n\
<status>Error: 403: {\"message\":\"Access to model denied.\"}</status>\n\
<summary>Agent \"sweep\" failed</summary>\n\
<result>partial work</result>\n\
</task-notification>";
let s = view_of(&agent_run(note));
let n = only_note(&s);
assert!(n.content.starts_with('✖'), "{}", n.content);
assert!(n.content.contains("Error: 403"), "{}", n.content);
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
let r = tool.result.as_ref().unwrap();
assert_eq!(r.content, "partial work");
assert!(r.is_error, "a failed run's report is an error result");
}
/// A monitor event is a progress ping: a note, no lane touched, no usage.
/// (An on-disk lane is finished by construction — `Session::add_lane` —
/// so what matters here is that the short id claims no lane at all.)
#[test]
fn disk_monitor_event_is_a_note_and_claims_no_lane() {
let note = "<task-notification>\n\
<task-id>b8s2gso3a</task-id>\n\
<summary>Monitor event: \"world skin bench\"</summary>\n\
<event>BENCH progress phase=traverse ms=141974</event>\n\
</task-notification>";
let s = view_of(&agent_run(note));
let n = only_note(&s);
assert!(n.content.starts_with('▸'), "{}", n.content);
assert!(n.content.contains("Monitor event: world skin bench"), "{}", n.content);
assert!(n.content.contains("\n BENCH progress phase=traverse"), "{}", n.content);
assert!(!s.lane_of_agent.contains_key("b8s2gso3a"), "a monitor id is not a lane");
assert_eq!(s.lanes.len(), 1, "no agent transcripts here, so main only");
// Its `Agent` call keeps the launch acknowledgement: a monitor event
// reports on nothing that has a report.
let tool = s
.entries
.iter()
.find(|e| matches!(&e.kind, Kind::Tool { name } if name == "Agent"))
.expect("Agent entry");
assert!(tool.result.as_ref().unwrap().content.contains("Async agent launched"));
}
/// A notification wrapped in a `<system-reminder>` (Claude Code does this)
/// is still lifted, and a reminder that held nothing else vanishes with it.
#[test]
fn disk_notification_inside_a_reminder_is_lifted() {
let note = completion_note("aaa1", "toolu_A", "THE REPORT");
let wrapped = format!("<system-reminder>\n{note}\n</system-reminder>");
let s = view_of(&agent_run(&wrapped));
let n = only_note(&s);
assert!(n.content.starts_with('✔'), "{}", n.content);
assert!(
!s.entries.iter().any(|e| e.kind == Kind::Reminder),
"a reminder that was only a notification leaves nothing behind"
);
}
/// `<usage>` totals reach the agent's own lane — the only token figures an
/// on-disk lane can have. Matched by agent id: the notification's
/// `<task-id>` is the stem of `subagents/agent-<id>.jsonl`.
#[test]
fn disk_task_usage_reaches_the_lane() {
let dir = std::env::temp_dir().join(format!("ct-usage-{}", std::process::id()));
let subs = dir.join("subagents");
std::fs::create_dir_all(&subs).unwrap();
let main = dir.join("s-usage.jsonl");
std::fs::write(
&main,
agent_run(&completion_note("aaa1", "toolu_A", "THE REPORT")).join("\n"),
)
.unwrap();
std::fs::write(
subs.join("agent-aaa1.meta.json"),
r#"{"agentType":"Explore","description":"sweep","toolUseId":"toolu_A","spawnDepth":1}"#,
)
.unwrap();
std::fs::write(
subs.join("agent-aaa1.jsonl"),
r#"{"type":"assistant","isSidechain":true,"uuid":"s1","parentUuid":null,"message":{"model":"claude-y","content":[{"type":"text","text":"child"}]}}"#,
)
.unwrap();
let agents = scan_agents_in(&subs);
let s = load_file_view(&main, "s-usage", &agents).expect("view").session;
std::fs::remove_dir_all(&dir).ok();
let lane = &s.lanes[1];
assert_eq!(lane.agent_id, "aaa1");
assert_eq!(lane.subagent_tokens, Some(128633));
assert_eq!(lane.tool_uses, Some(63));
assert_eq!(lane.duration_ms, Some(1115197));
// The main lane never takes an agent's totals.
assert_eq!(s.lanes[MAIN_LANE as usize].subagent_tokens, None);
}
#[test]
fn materialize_chain_and_stitch() {
let tree = build_tree(branched());
let dir = std::env::temp_dir().join(format!("ct-mat-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Plain branch: chain through the abandoned B turn.
let new = materialize_in(&dir, &tree, &tree.chain(1), "⑂ prompt B").unwrap();
let body = std::fs::read_to_string(dir.join(format!("{new}.jsonl"))).unwrap();
let recs: Vec<Value> = body.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(recs[0]["type"], "ai-title");
assert_eq!(recs[0]["aiTitle"], "⑂ prompt B");
for r in &recs {
assert_eq!(r["sessionId"], Value::String(new.clone()), "sessionId rewritten");
}
assert!(!body.contains("prompt B2"), "other branch excluded");
let rebuilt = build_tree(body.lines().map(str::to_string));
assert_eq!(rebuilt.turns.len(), 2);
assert_eq!(rebuilt.turns[1].parent, Some(0), "chain stays linked");
// Visual stitch: only the C turn, fully decoupled.
let new2 = materialize_in(&dir, &tree, &[3], "⑂ prompt C").unwrap();
let body2 = std::fs::read_to_string(dir.join(format!("{new2}.jsonl"))).unwrap();
let rebuilt2 = build_tree(body2.lines().map(str::to_string));
assert_eq!(rebuilt2.turns.len(), 1);
assert_eq!(rebuilt2.turns[0].label, "prompt C");
assert_eq!(rebuilt2.turns[0].parent, None, "first turn re-parented to root");
assert!(!body2.contains("prompt A"), "unselected turns dropped");
std::fs::remove_dir_all(&dir).ok();
}
fn write_jsonl(lines: &[&str]) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"ct-sessions-test-{}-{}.jsonl",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let mut f = std::fs::File::create(&path).unwrap();
for l in lines {
writeln!(f, "{l}").unwrap();
}
path
}
/// Property check against whatever real session files exist for this
/// directory (no-op elsewhere): every turn appears in `display` exactly
/// once, depths are sane, and chains terminate (parents always precede
/// children, so the tree is acyclic by construction — verify anyway).
#[test]
fn real_session_trees_uphold_invariants() {
let Ok(dir) = project_dir() else { return };
let Ok(rd) = std::fs::read_dir(&dir) else { return };
for e in rd.flatten() {
let path = e.path();
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
continue;
}
let Ok(body) = std::fs::read_to_string(&path) else { continue };
let tree = build_tree(body.lines().map(str::to_string));
let mut seen = vec![false; tree.turns.len()];
for &t in &tree.display {
assert!(!seen[t], "{path:?}: turn {t} displayed twice");
seen[t] = true;
}
assert!(seen.iter().all(|&s| s), "{path:?}: turn missing from display");
for (i, t) in tree.turns.iter().enumerate() {
assert!(!t.label.is_empty(), "{path:?}: empty turn label");
if let Some(p) = t.parent {
assert!(p < i, "{path:?}: parent {p} not before child {i}");
}
assert!(!tree.chain(i).is_empty());
}
}
}
#[test]
fn encode_cwd_matches_claude_code() {
// Plain path: only slashes fold.
assert_eq!(encode_cwd("/home/jonas/projects/claude-cloak"), "-home-jonas-projects-claude-cloak");
// Dot folds to `-`; a leading `/.` yields a double dash (no merging).
assert_eq!(encode_cwd("/home/jonas/dotfiles/nvim/.config/nvim"), "-home-jonas-dotfiles-nvim--config-nvim");
assert_eq!(encode_cwd("/home/jonas/sources/llama.cpp"), "-home-jonas-sources-llama-cpp");
// Underscore folds too; case and digits are preserved.
assert_eq!(encode_cwd("/home/jonas/Downloads/ldd_wine"), "-home-jonas-Downloads-ldd-wine");
assert_eq!(encode_cwd("/a/b1_c2.d3"), "-a-b1-c2-d3");
}
#[test]
fn label_prefers_ai_title() {
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
r#"{"type":"ai-title","aiTitle":"retitled later"}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "bug fixing session");
std::fs::remove_file(p).ok();
}
#[test]
fn label_falls_back_to_last_prompt_then_uuid() {
let p = write_jsonl(&[
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "latest");
std::fs::remove_file(p).ok();
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
assert_eq!(read_meta(&p, "deadbeef-0000").0, "deadbeef");
std::fs::remove_file(p).ok();
}
#[test]
fn meta_reads_the_last_main_chain_model() {
let p = write_jsonl(&[
r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","content":[]}}"#,
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-5-20250929","content":[]}}"#,
// A subagent turn runs its own model — not the session's.
r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-haiku-4-5-20251001","content":[]}}"#,
// An API-error record carries a placeholder id, not a model.
r#"{"type":"assistant","message":{"model":"<synthetic>","content":[]}}"#,
]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "claude-sonnet-4-5-20250929");
std::fs::remove_file(p).ok();
// A session with no assistant record yet reports no model.
let p = write_jsonl(&[r#"{"type":"user","message":{"role":"user","content":"hi"}}"#]);
assert_eq!(read_meta(&p, "deadbeef-0000").1, "");
std::fs::remove_file(p).ok();
}
#[test]
fn history_rebuilds_entries_with_tool_results() {
let p = write_jsonl(&[
r#"{"type":"user","message":{"role":"user","content":"fix the bug"}}"#,
r#"{"type":"assistant","message":{"model":"claude-x","content":[
{"type":"thinking","thinking":"hmm"},
{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}
]}}"#
.replace('\n', " ")
.leak(),
r#"{"type":"user","message":{"role":"user","content":[
{"type":"tool_result","tool_use_id":"toolu_1","content":"file_a","is_error":false}
]}}"#
.replace('\n', " ")
.leak(),
r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-x","content":[{"type":"text","text":"subagent noise"}]}}"#,
r#"{"type":"assistant","message":{"model":"claude-x","content":[{"type":"text","text":"done"}]}}"#,
]);
// Parse via the explicit-path seam: no HOME mutation (an unsafe
// set_var would race the env reads of concurrently running tests).
let uuid = "11111111-2222-3333-4444-555555555555";
let s = load_file_view(&p, uuid, &[]).map(|h| h.session);
std::fs::remove_file(&p).ok();
let s = s.expect("history loaded");
assert_eq!(s.key, uuid);
assert_eq!(s.main().model, "claude-x");
// user prompt, thinking, tool, text — sidechain line skipped.
assert_eq!(s.entries.len(), 4);
assert!(matches!(s.entries[0].kind, Kind::User));
assert_eq!(s.entries[0].content, "fix the bug");
assert!(matches!(s.entries[1].kind, Kind::Thinking));
let tool = &s.entries[2];
assert!(matches!(&tool.kind, Kind::Tool { name } if name == "Bash"));
assert_eq!(tool.result.as_ref().unwrap().content, "file_a");
assert_eq!(s.entries[3].content, "done");
assert!(s.entries.iter().all(|e| e.done));
}
}