use std::collections::HashMap;
use chrono::{DateTime, Utc};
use super::{Source, Update};
use crate::transcript::Entry;
#[derive(Debug, Clone)]
pub enum Timing {
Dated(DateTime<Utc>),
Pending(String),
Leader,
}
#[derive(Debug)]
pub struct ReplayItem {
pub(crate) timing: Timing,
pub update: Update,
}
impl ReplayItem {
pub fn ts(&self) -> Option<DateTime<Utc>> {
match self.timing {
Timing::Dated(t) => Some(t),
Timing::Pending(_) | Timing::Leader => None,
}
}
pub(crate) fn at(ts: Option<DateTime<Utc>>, update: Update) -> Self {
let timing = match ts {
Some(t) => Timing::Dated(t),
None => undated_timing(&update),
};
ReplayItem { timing, update }
}
pub fn live(update: Update) -> Self {
let ts = match &update {
Update::Entry { entry, .. } => entry_timestamp(entry),
Update::SubagentMeta { .. } => None,
};
Self::at(ts, update)
}
}
fn undated_timing(update: &Update) -> Timing {
let agent = match update {
Update::SubagentMeta { agent_id, .. } => Some(agent_id.clone()),
Update::Entry {
source: Source::Journal(_),
entry,
} => match entry {
Entry::Result(l) => l.agent_id.clone(),
Entry::Started(l) => l.agent_id.clone(),
_ => None,
},
Update::Entry { .. } => None,
};
agent.map_or(Timing::Leader, Timing::Pending)
}
pub(crate) fn date_and_sort(items: &mut [ReplayItem]) {
date_and_sort_inner(items, true);
}
pub(crate) fn date_and_sort_live(items: &mut [ReplayItem]) {
date_and_sort_inner(items, false);
}
fn date_and_sort_inner(items: &mut [ReplayItem], complete: bool) {
let earliest = items.iter().filter_map(|i| i.ts()).min();
let mut first_entry_ts: HashMap<String, DateTime<Utc>> = HashMap::new();
let mut last_entry_ts: HashMap<String, DateTime<Utc>> = HashMap::new();
for item in items.iter() {
if let (
Some(ts),
Update::Entry {
source: Source::Sub(id),
..
},
) = (item.ts(), &item.update)
{
first_entry_ts
.entry(id.clone())
.and_modify(|t| *t = (*t).min(ts))
.or_insert(ts);
last_entry_ts
.entry(id.clone())
.and_modify(|t| *t = (*t).max(ts))
.or_insert(ts);
}
}
for item in items.iter_mut() {
if matches!(item.timing, Timing::Dated(_)) {
continue;
}
let resolved = match &item.update {
Update::SubagentMeta { agent_id, .. } => first_entry_ts.get(agent_id).copied(),
Update::Entry {
source: Source::Journal(_),
entry,
} => match entry {
Entry::Result(l) => l
.agent_id
.as_ref()
.and_then(|id| last_entry_ts.get(id))
.copied(),
Entry::Started(l) => l
.agent_id
.as_ref()
.and_then(|id| first_entry_ts.get(id))
.copied(),
_ => None,
}
.or(if complete { earliest } else { None }),
_ => None,
};
if let Some(t) = resolved {
item.timing = Timing::Dated(t);
}
}
let rank = |u: &Update| match u {
Update::SubagentMeta { .. } => 0u8,
Update::Entry { .. } => 1u8,
};
items.sort_by(|a, b| match (a.ts(), b.ts()) {
(Some(x), Some(y)) => x
.cmp(&y)
.then_with(|| rank(&a.update).cmp(&rank(&b.update))),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
});
}
pub(crate) fn entry_timestamp(entry: &Entry) -> Option<DateTime<Utc>> {
match entry {
Entry::User(e) => e.envelope.timestamp,
Entry::Assistant(e) => e.envelope.timestamp,
Entry::System(e) => e.envelope.timestamp,
Entry::Attachment(e) => e.envelope.timestamp,
_ => None,
}
}
pub fn replay_from_jsonl(text: &str) -> (Vec<ReplayItem>, crate::state::SessionInfo) {
let mut items: Vec<ReplayItem> = Vec::new();
push_lines(text, &Source::Main, &mut items);
finish(items)
}
pub struct DemoSubagent<'a> {
pub agent_id: &'a str,
pub meta: &'a str,
pub transcript: &'a str,
pub workflow: Option<&'a str>,
pub journal: bool,
}
pub fn replay_from_session(
main: &str,
subagents: &[DemoSubagent],
) -> (Vec<ReplayItem>, crate::state::SessionInfo) {
let mut items: Vec<ReplayItem> = Vec::new();
push_lines(main, &Source::Main, &mut items);
for sub in subagents {
if sub.journal {
if let Some(wf) = sub.workflow {
push_lines(sub.transcript, &Source::Journal(wf.to_string()), &mut items);
}
continue;
}
if let Ok(meta) = serde_json::from_str::<crate::transcript::SubagentMeta>(sub.meta) {
items.push(ReplayItem::at(
None,
Update::SubagentMeta {
agent_id: sub.agent_id.to_string(),
workflow: sub.workflow.map(str::to_owned),
meta,
},
));
}
push_lines(
sub.transcript,
&Source::Sub(sub.agent_id.to_string()),
&mut items,
);
}
finish(items)
}
fn push_lines(text: &str, source: &Source, items: &mut Vec<ReplayItem>) {
let mut last_ts: Option<DateTime<Utc>> = None;
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let Some(entry) = crate::transcript::parse_line(line) else {
continue;
};
let ts = entry_timestamp(&entry).or(last_ts);
if ts.is_some() {
last_ts = ts;
}
items.push(ReplayItem::at(
ts,
Update::Entry {
source: source.clone(),
entry,
},
));
}
}
fn finish(mut items: Vec<ReplayItem>) -> (Vec<ReplayItem>, crate::state::SessionInfo) {
let mut info = crate::state::SessionInfo::default();
items.retain(|item| match &item.update {
Update::Entry { entry, .. } if entry.is_timeline_noise() => {
info.apply(entry);
false
}
_ => true,
});
date_and_sort(&mut items);
(items, info)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn journal_ledger_dates_to_the_agents_first_or_last_entry() {
let sub = |t: &str| {
format!(
r#"{{"type":"user","uuid":"u","timestamp":"{t}","message":{{"role":"user","content":"x"}}}}"#
)
};
let mut items = Vec::new();
push_lines(
&format!(
"{}\n{}\n",
sub("2026-06-05T10:00:05.000Z"),
sub("2026-06-05T10:00:15.000Z")
),
&Source::Sub("subX".into()),
&mut items,
);
push_lines(
r#"{"type":"started","key":"k","agentId":"subX"}"#,
&Source::Journal("wf".into()),
&mut items,
);
push_lines(
r#"{"type":"result","key":"k","agentId":"subX","result":"done"}"#,
&Source::Journal("wf".into()),
&mut items,
);
date_and_sort(&mut items);
let started_ts = items
.iter()
.find(|i| {
matches!(
&i.update,
Update::Entry {
entry: Entry::Started(_),
..
}
)
})
.and_then(|i| i.ts());
let result_ts = items
.iter()
.find(|i| {
matches!(
&i.update,
Update::Entry {
entry: Entry::Result(_),
..
}
)
})
.and_then(|i| i.ts());
assert_eq!(
started_ts,
Some("2026-06-05T10:00:05.000Z".parse::<DateTime<Utc>>().unwrap()),
"journal `started` dates to the agent's FIRST entry"
);
assert_eq!(
result_ts,
Some("2026-06-05T10:00:15.000Z".parse::<DateTime<Utc>>().unwrap()),
"journal `result` dates to the agent's LAST entry"
);
}
#[test]
fn replay_from_jsonl_parses_orders_and_routes_noise() {
let text = concat!(
r#"{"type":"user","uuid":"u1","timestamp":"2026-06-05T10:00:02.000Z","message":{"role":"user","content":"second"}}"#,
"\n",
"\n",
r#"{"type":"user","uuid":"u0","timestamp":"2026-06-05T10:00:01.000Z","message":{"role":"user","content":"first"}}"#,
"\n",
r#"garbage that should be skipped"#,
"\n",
);
let (items, _info) = replay_from_jsonl(text);
assert_eq!(items.len(), 2);
assert!(items[0].ts().unwrap() < items[1].ts().unwrap());
assert!(items.iter().all(|i| matches!(
&i.update,
Update::Entry {
source: Source::Main,
..
}
)));
}
#[test]
fn replay_from_session_emits_subagent_meta_and_sub_entries() {
let main = r#"{"type":"user","uuid":"u1","timestamp":"2026-06-05T10:00:00.000Z","message":{"role":"user","content":"go"}}"#;
let sub = DemoSubagent {
agent_id: "a1000000000000001",
meta: r#"{"agentType":"Explore","description":"map it","toolUseId":"toolu_1"}"#,
transcript: r#"{"type":"user","uuid":"s1","isSidechain":true,"agentId":"a1000000000000001","timestamp":"2026-06-05T10:00:05.000Z","message":{"role":"user","content":"task"}}"#,
workflow: None,
journal: false,
};
let (items, _info) = replay_from_session(main, &[sub]);
assert!(
items
.iter()
.any(|i| matches!(&i.update, Update::SubagentMeta { agent_id, .. } if agent_id == "a1000000000000001")),
"a subagent meta is emitted"
);
assert!(
items.iter().any(|i| matches!(
&i.update,
Update::Entry { source: Source::Sub(id), .. } if id == "a1000000000000001"
)),
"subagent entries are tagged Source::Sub"
);
}
#[test]
fn replay_from_session_tags_workflow_subagents_and_journals() {
let main = r#"{"type":"user","uuid":"u1","timestamp":"2026-06-05T10:00:00.000Z","message":{"role":"user","content":"go"}}"#;
let subs = [
DemoSubagent {
agent_id: "w1000000000000001",
meta: r#"{"agentType":"workflow-subagent","description":"review:bugs"}"#,
transcript: r#"{"type":"user","uuid":"s1","isSidechain":true,"agentId":"w1000000000000001","timestamp":"2026-06-05T10:00:05.000Z","message":{"role":"user","content":"task"}}"#,
workflow: Some("wf-99"),
journal: false,
},
DemoSubagent {
agent_id: "",
meta: "",
transcript: r#"{"type":"started","key":"review","agentId":"w1000000000000001"}"#,
workflow: Some("wf-99"),
journal: true,
},
];
let (items, _info) = replay_from_session(main, &subs);
assert!(
items.iter().any(|i| matches!(
&i.update,
Update::SubagentMeta { agent_id, workflow: Some(wf), .. }
if agent_id == "w1000000000000001" && wf == "wf-99"
)),
"a workflow subagent's meta carries its workflow id"
);
assert!(
items.iter().any(|i| matches!(
&i.update,
Update::Entry { source: Source::Journal(wf), .. } if wf == "wf-99"
)),
"journal lines fold under Source::Journal, not Source::Sub"
);
assert!(
!items.iter().any(|i| matches!(
&i.update,
Update::Entry { source: Source::Sub(id), .. } if id.is_empty()
)),
"the journal is not mistaken for a subagent transcript"
);
}
}