use crate::PiConvo;
use crate::error::PiError;
use crate::reader::PiSession;
use crate::types::{
AgentMessage, ContentBlock, Entry, MessageContent, StopReason, ToolResultContent, Usage,
};
use chrono::{DateTime, Utc};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use toolpath_convo::{
ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork,
EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
};
fn classify_tool(name: &str) -> Option<ToolCategory> {
let lower = name.to_lowercase();
if lower.contains("task") || lower.contains("agent") {
return Some(ToolCategory::Delegation);
}
match lower.as_str() {
"read" => Some(ToolCategory::FileRead),
"write" | "edit" => Some(ToolCategory::FileWrite),
"bash" | "shell" | "run" | "exec" => Some(ToolCategory::Shell),
"grep" | "glob" | "find" | "ls" => Some(ToolCategory::FileSearch),
"webfetch" | "websearch" | "fetch" => Some(ToolCategory::Network),
_ => None,
}
}
fn extract_prompt(args: &Value) -> String {
for key in ["prompt", "input", "instructions"] {
if let Some(s) = args.get(key).and_then(|v| v.as_str()) {
return s.to_string();
}
}
args.to_string()
}
fn extract_file_path(args: &Value) -> Option<String> {
for key in ["file_path", "path", "filename", "file"] {
if let Some(s) = args.get(key).and_then(|v| v.as_str()) {
return Some(s.to_string());
}
}
None
}
fn parse_ts(ts: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn stop_reason_to_string(sr: &StopReason) -> String {
match serde_json::to_value(sr).ok().and_then(|v| match v {
Value::String(s) => Some(s),
_ => None,
}) {
Some(s) => s,
None => format!("{:?}", sr).to_lowercase(),
}
}
fn extract_user_text(content: &MessageContent) -> String {
match content {
MessageContent::Text(s) => s.clone(),
MessageContent::Blocks(blocks) => {
let texts: Vec<&str> = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
texts.join("\n")
}
}
}
fn extract_assistant_text(blocks: &[ContentBlock]) -> String {
let texts: Vec<&str> = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
texts.join("\n")
}
fn extract_assistant_thinking(blocks: &[ContentBlock]) -> Option<String> {
let thinking: Vec<&str> = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
_ => None,
})
.collect();
if thinking.is_empty() {
None
} else {
Some(thinking.join("\n"))
}
}
fn extract_tool_result_text(content: &[ToolResultContent]) -> String {
let texts: Vec<&str> = content
.iter()
.filter_map(|c| match c {
ToolResultContent::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
texts.join("\n")
}
fn usage_to_token_usage(usage: &Usage) -> TokenUsage {
TokenUsage {
input_tokens: Some(usage.input as u32),
output_tokens: Some(usage.output as u32),
cache_read_tokens: if usage.cache_read > 0 {
Some(usage.cache_read as u32)
} else {
None
},
cache_write_tokens: if usage.cache_write > 0 {
Some(usage.cache_write as u32)
} else {
None
},
}
}
fn environment_for(session: &PiSession) -> EnvironmentSnapshot {
EnvironmentSnapshot {
working_dir: Some(session.header.cwd.clone()),
vcs_branch: None,
vcs_revision: None,
}
}
fn truncate_output(output: &str, max: usize) -> String {
if output.chars().count() <= max {
output.to_string()
} else {
let truncated: String = output.chars().take(max).collect();
format!("{}…(truncated)", truncated)
}
}
#[derive(Default)]
struct PendingMeta {
model_change: Option<Value>,
thinking_level_change: Option<Value>,
labels: Vec<Value>,
}
impl PendingMeta {
fn drain_into(&mut self, pi: &mut Map<String, Value>) {
if let Some(v) = self.model_change.take() {
pi.insert("modelChange".to_string(), v);
}
if let Some(v) = self.thinking_level_change.take() {
pi.insert("thinkingLevelChange".to_string(), v);
}
if !self.labels.is_empty() {
let labels = std::mem::take(&mut self.labels);
pi.insert("labels".to_string(), Value::Array(labels));
}
}
}
pub fn session_to_view(session: &PiSession) -> ConversationView {
let env = environment_for(session);
let mut turns: Vec<Turn> = Vec::new();
let mut tool_call_locs: HashMap<String, (usize, usize)> = HashMap::new();
let mut delegation_locs: HashMap<String, (usize, usize)> = HashMap::new();
let mut tool_result_payloads: Vec<(usize, String, String, bool)> = Vec::new();
let mut pending = PendingMeta::default();
let mut is_first_turn = true;
for entry in &session.entries {
match entry {
Entry::Session(_) => continue,
Entry::ModelChange {
base,
provider,
model_id,
extra,
..
} => {
let mut m = Map::new();
m.insert("id".to_string(), json!(base.id));
m.insert("timestamp".to_string(), json!(base.timestamp));
m.insert("provider".to_string(), json!(provider));
m.insert("modelId".to_string(), json!(model_id));
if !extra.is_empty() {
m.insert("rawExtra".to_string(), json!(extra));
}
pending.model_change = Some(Value::Object(m));
}
Entry::ThinkingLevelChange {
base,
thinking_level,
extra,
..
} => {
let mut m = Map::new();
m.insert("id".to_string(), json!(base.id));
m.insert("timestamp".to_string(), json!(base.timestamp));
m.insert("thinkingLevel".to_string(), json!(thinking_level));
if !extra.is_empty() {
m.insert("rawExtra".to_string(), json!(extra));
}
pending.thinking_level_change = Some(Value::Object(m));
}
Entry::Label { base, extra, .. } => {
let mut m = Map::new();
m.insert("id".to_string(), json!(base.id));
m.insert("timestamp".to_string(), json!(base.timestamp));
if !extra.is_empty() {
m.insert("rawExtra".to_string(), json!(extra));
}
pending.labels.push(Value::Object(m));
}
Entry::Compaction {
base,
summary,
first_kept_entry_id,
tokens_before,
details,
from_hook,
extra,
..
} => {
let mut pi = Map::new();
let mut comp = Map::new();
comp.insert("summary".to_string(), json!(summary));
comp.insert("firstKeptEntryId".to_string(), json!(first_kept_entry_id));
comp.insert("tokensBefore".to_string(), json!(tokens_before));
if let Some(d) = details {
comp.insert("details".to_string(), d.clone());
}
if let Some(fh) = from_hook {
comp.insert("fromHook".to_string(), json!(fh));
}
pi.insert("compaction".to_string(), Value::Object(comp));
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
pending.drain_into(&mut pi);
attach_first_turn_meta(&mut pi, &mut is_first_turn, session);
let mut extra_map = HashMap::new();
extra_map.insert("pi".to_string(), Value::Object(pi));
turns.push(Turn {
id: base.id.clone(),
parent_id: base.parent_id.clone(),
role: Role::System,
timestamp: base.timestamp.clone(),
text: format!("Compacted (summary): {}", summary),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: Some(env.clone()),
delegations: vec![],
extra: extra_map,
});
}
Entry::BranchSummary {
base,
from_id,
summary,
details,
from_hook,
extra,
..
} => {
let mut pi = Map::new();
let mut bs = Map::new();
bs.insert("fromId".to_string(), json!(from_id));
if let Some(d) = details {
bs.insert("details".to_string(), d.clone());
}
if let Some(fh) = from_hook {
bs.insert("fromHook".to_string(), json!(fh));
}
pi.insert("branchSummary".to_string(), Value::Object(bs));
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
pending.drain_into(&mut pi);
attach_first_turn_meta(&mut pi, &mut is_first_turn, session);
let mut extra_map = HashMap::new();
extra_map.insert("pi".to_string(), Value::Object(pi));
turns.push(Turn {
id: base.id.clone(),
parent_id: base.parent_id.clone(),
role: Role::System,
timestamp: base.timestamp.clone(),
text: format!("Branch summary: {}", summary),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: Some(env.clone()),
delegations: vec![],
extra: extra_map,
});
}
Entry::Custom {
base,
custom_type,
data,
extra,
..
} => {
let mut pi = Map::new();
let mut c = Map::new();
c.insert("customType".to_string(), json!(custom_type));
c.insert("data".to_string(), Value::Object(data.clone()));
pi.insert("custom".to_string(), Value::Object(c));
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
pending.drain_into(&mut pi);
attach_first_turn_meta(&mut pi, &mut is_first_turn, session);
let mut extra_map = HashMap::new();
extra_map.insert("pi".to_string(), Value::Object(pi));
turns.push(Turn {
id: base.id.clone(),
parent_id: base.parent_id.clone(),
role: Role::Other("custom".to_string()),
timestamp: base.timestamp.clone(),
text: String::new(),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: Some(env.clone()),
delegations: vec![],
extra: extra_map,
});
}
Entry::CustomMessage {
base,
custom_type,
content,
display,
details,
extra,
..
} => {
let mut pi = Map::new();
let mut cm = Map::new();
cm.insert("customType".to_string(), json!(custom_type));
cm.insert("display".to_string(), json!(display));
if let Some(d) = details {
cm.insert("details".to_string(), d.clone());
}
pi.insert("customMessage".to_string(), Value::Object(cm));
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
pending.drain_into(&mut pi);
attach_first_turn_meta(&mut pi, &mut is_first_turn, session);
let mut extra_map = HashMap::new();
extra_map.insert("pi".to_string(), Value::Object(pi));
turns.push(Turn {
id: base.id.clone(),
parent_id: base.parent_id.clone(),
role: Role::Other(format!("custom:{}", custom_type)),
timestamp: base.timestamp.clone(),
text: extract_user_text(content),
thinking: None,
tool_uses: vec![],
model: None,
stop_reason: None,
token_usage: None,
environment: Some(env.clone()),
delegations: vec![],
extra: extra_map,
});
}
Entry::Message {
base,
message,
extra: entry_extra,
..
} => {
let mut pi = Map::new();
let text;
let mut thinking = None;
let mut tool_uses: Vec<ToolInvocation> = Vec::new();
let mut model: Option<String> = None;
let mut stop_reason_s: Option<String> = None;
let mut token_usage: Option<TokenUsage> = None;
let mut delegations: Vec<DelegatedWork> = Vec::new();
let role: Role;
match message {
AgentMessage::User { content, extra, .. } => {
role = Role::User;
text = extract_user_text(content);
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
}
AgentMessage::Assistant {
content,
api,
provider,
model: m,
usage,
stop_reason,
error_message,
extra,
..
} => {
role = Role::Assistant;
text = extract_assistant_text(content);
thinking = extract_assistant_thinking(content);
model = Some(m.clone());
stop_reason_s = Some(stop_reason_to_string(stop_reason));
token_usage = Some(usage_to_token_usage(usage));
let turn_idx = turns.len();
for block in content {
if let ContentBlock::ToolCall {
id,
name,
arguments,
..
} = block
{
let category = classify_tool(name);
let tool_idx = tool_uses.len();
tool_call_locs.insert(id.clone(), (turn_idx, tool_idx));
if category == Some(ToolCategory::Delegation) {
let deleg_idx = delegations.len();
delegations.push(DelegatedWork {
agent_id: id.clone(),
prompt: extract_prompt(arguments),
turns: vec![],
result: None,
});
delegation_locs.insert(id.clone(), (turn_idx, deleg_idx));
}
tool_uses.push(ToolInvocation {
id: id.clone(),
name: name.clone(),
input: arguments.clone(),
result: None,
category,
});
}
}
let mut api_obj = Map::new();
api_obj.insert("provider".to_string(), json!(provider));
api_obj.insert("api".to_string(), json!(api));
pi.insert("api".to_string(), Value::Object(api_obj));
pi.insert(
"stopReason".to_string(),
serde_json::to_value(stop_reason).unwrap_or(Value::Null),
);
if let Some(err) = error_message {
pi.insert("errorMessage".to_string(), json!(err));
}
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
}
AgentMessage::ToolResult {
tool_call_id,
tool_name,
content,
is_error,
details,
extra,
..
} => {
role = Role::Other("tool".to_string());
text = extract_tool_result_text(content);
pi.insert("toolCallId".to_string(), json!(tool_call_id));
pi.insert("toolName".to_string(), json!(tool_name));
pi.insert("isError".to_string(), json!(is_error));
if let Some(d) = details {
pi.insert("details".to_string(), d.clone());
}
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
tool_result_payloads.push((
turns.len(),
tool_call_id.clone(),
text.clone(),
*is_error,
));
}
AgentMessage::BashExecution {
command,
output,
exit_code,
cancelled,
truncated,
full_output_path,
extra,
..
} => {
role = Role::Other("bash".to_string());
let out_trunc = truncate_output(output, 4096);
text = format!("$ {}\n{}", command, out_trunc);
pi.insert("command".to_string(), json!(command));
pi.insert("exitCode".to_string(), json!(exit_code));
pi.insert("cancelled".to_string(), json!(cancelled));
pi.insert("truncated".to_string(), json!(truncated));
if let Some(fop) = full_output_path {
pi.insert("fullOutputPath".to_string(), json!(fop));
}
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
tool_uses.push(ToolInvocation {
id: base.id.clone(),
name: "bash".to_string(),
input: json!({ "command": command }),
result: Some(ToolResult {
content: output.clone(),
is_error: !matches!(exit_code, Some(0)),
}),
category: Some(ToolCategory::Shell),
});
}
AgentMessage::Custom {
custom_type,
content,
display,
details,
extra,
..
} => {
role = Role::Other(format!("custom:{}", custom_type));
text = extract_user_text(content);
pi.insert("customType".to_string(), json!(custom_type));
pi.insert("display".to_string(), json!(display));
if let Some(d) = details {
pi.insert("details".to_string(), d.clone());
}
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
}
AgentMessage::BranchSummary { extra, .. } => {
role = Role::System;
text = String::new();
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
}
AgentMessage::CompactionSummary { extra, .. } => {
role = Role::System;
text = String::new();
if !extra.is_empty() {
pi.insert("rawExtra".to_string(), json!(extra));
}
}
}
if !entry_extra.is_empty() {
pi.insert("entryExtra".to_string(), json!(entry_extra));
}
pending.drain_into(&mut pi);
attach_first_turn_meta(&mut pi, &mut is_first_turn, session);
let mut extra_map = HashMap::new();
extra_map.insert("pi".to_string(), Value::Object(pi));
turns.push(Turn {
id: base.id.clone(),
parent_id: base.parent_id.clone(),
role,
timestamp: base.timestamp.clone(),
text,
thinking,
tool_uses,
model,
stop_reason: stop_reason_s,
token_usage,
environment: Some(env.clone()),
delegations,
extra: extra_map,
});
}
}
}
for (_tr_turn_idx, tool_call_id, content, is_error) in &tool_result_payloads {
if let Some((turn_idx, tool_idx)) = tool_call_locs.get(tool_call_id)
&& let Some(turn) = turns.get_mut(*turn_idx)
&& let Some(inv) = turn.tool_uses.get_mut(*tool_idx)
{
inv.result = Some(ToolResult {
content: content.clone(),
is_error: *is_error,
});
}
if let Some((turn_idx, deleg_idx)) = delegation_locs.get(tool_call_id)
&& let Some(turn) = turns.get_mut(*turn_idx)
&& let Some(d) = turn.delegations.get_mut(*deleg_idx)
{
d.result = Some(content.clone());
}
}
let mut have_any_usage = false;
let mut total = TokenUsage::default();
for turn in &turns {
if let Some(u) = &turn.token_usage {
have_any_usage = true;
total.input_tokens =
Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
total.output_tokens =
Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
if let Some(r) = u.cache_read_tokens {
total.cache_read_tokens = Some(total.cache_read_tokens.unwrap_or(0) + r);
}
if let Some(w) = u.cache_write_tokens {
total.cache_write_tokens = Some(total.cache_write_tokens.unwrap_or(0) + w);
}
}
}
let total_usage = if have_any_usage { Some(total) } else { None };
let mut files_changed: Vec<String> = Vec::new();
let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
for turn in &turns {
for inv in &turn.tool_uses {
if inv.category == Some(ToolCategory::FileWrite)
&& let Some(p) = extract_file_path(&inv.input)
&& seen_files.insert(p.clone())
{
files_changed.push(p);
}
}
}
let mut session_ids: Vec<String> = Vec::new();
fn walk_parents(s: &PiSession, out: &mut Vec<String>) {
if let Some(p) = &s.parent {
walk_parents(p, out);
}
out.push(s.header.id.clone());
}
walk_parents(session, &mut session_ids);
let started_at = parse_ts(&session.header.timestamp);
let last_activity = turns.last().and_then(|t| parse_ts(&t.timestamp));
ConversationView {
id: session.header.id.clone(),
started_at,
last_activity,
turns,
total_usage,
provider_id: Some("pi".to_string()),
files_changed,
session_ids,
events: vec![],
}
}
fn attach_first_turn_meta(pi: &mut Map<String, Value>, is_first: &mut bool, session: &PiSession) {
if *is_first {
if let Some(parent) = &session.header.parent_session {
pi.insert("parentSession".to_string(), json!(parent));
}
*is_first = false;
}
}
fn to_convo_err(e: PiError) -> ConvoError {
ConvoError::Provider(e.to_string())
}
impl ConversationProvider for PiConvo {
fn list_conversations(&self, project: &str) -> Result<Vec<String>, ConvoError> {
let metas = self.list_sessions(project).map_err(to_convo_err)?;
Ok(metas.into_iter().map(|m| m.id).collect())
}
fn load_conversation(
&self,
project: &str,
conversation_id: &str,
) -> Result<ConversationView, ConvoError> {
let session = self
.read_session(project, conversation_id)
.map_err(to_convo_err)?;
Ok(session_to_view(&session))
}
fn load_metadata(
&self,
project: &str,
conversation_id: &str,
) -> Result<ConversationMeta, ConvoError> {
let metas = self.list_sessions(project).map_err(to_convo_err)?;
let meta = metas
.into_iter()
.find(|m| m.id == conversation_id)
.ok_or_else(|| {
ConvoError::Provider(format!("session not found: {}", conversation_id))
})?;
Ok(meta_to_conversation_meta(meta))
}
fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>, ConvoError> {
let metas = self.list_sessions(project).map_err(to_convo_err)?;
Ok(metas.into_iter().map(meta_to_conversation_meta).collect())
}
}
fn meta_to_conversation_meta(meta: crate::reader::SessionMeta) -> ConversationMeta {
let ts = parse_ts(&meta.timestamp);
ConversationMeta {
id: meta.id,
started_at: ts,
last_activity: ts,
message_count: meta.entry_count,
file_path: Some(meta.file_path),
predecessor: None,
successor: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::PathResolver;
use crate::reader::PiSession;
use crate::types::{
AgentMessage, ContentBlock, CostBreakdown, Entry, EntryBase, KnownStopReason,
MessageContent, SessionHeader, StopReason, ToolResultContent, Usage,
};
use std::collections::HashMap;
use std::path::PathBuf;
fn header(id: &str, cwd: &str) -> SessionHeader {
SessionHeader {
version: 3,
id: id.into(),
timestamp: "2026-04-16T00:00:00Z".into(),
cwd: cwd.into(),
parent_session: None,
extra: HashMap::new(),
}
}
fn base(id: &str, parent: Option<&str>, ts: &str) -> EntryBase {
EntryBase {
id: id.into(),
parent_id: parent.map(String::from),
timestamp: ts.into(),
}
}
fn user_text_entry(id: &str, parent: Option<&str>, text: &str) -> Entry {
Entry::Message {
base: base(id, parent, "2026-04-16T00:00:01Z"),
message: AgentMessage::User {
content: MessageContent::Text(text.into()),
timestamp: 1,
extra: HashMap::new(),
},
extra: HashMap::new(),
}
}
fn assistant_entry(
id: &str,
parent: Option<&str>,
content: Vec<ContentBlock>,
usage: Usage,
stop_reason: StopReason,
model: &str,
) -> Entry {
Entry::Message {
base: base(id, parent, "2026-04-16T00:00:02Z"),
message: AgentMessage::Assistant {
content,
api: "anthropic".into(),
provider: "anthropic".into(),
model: model.into(),
usage,
stop_reason,
error_message: None,
timestamp: 2,
extra: HashMap::new(),
},
extra: HashMap::new(),
}
}
fn usage(input: u64, output: u64) -> Usage {
Usage {
input,
output,
cache_read: 0,
cache_write: 0,
total_tokens: input + output,
cost: CostBreakdown::default(),
}
}
fn session_from(entries: Vec<Entry>, cwd: &str) -> PiSession {
let h = header("sess-1", cwd);
let mut all = vec![Entry::Session(h.clone())];
all.extend(entries);
PiSession {
header: h,
entries: all,
file_path: PathBuf::from("/tmp/fake.jsonl"),
parent: None,
}
}
#[test]
fn test_empty_session_produces_view() {
let session = session_from(vec![], "/tmp/p");
let v = session_to_view(&session);
assert_eq!(v.turns.len(), 0);
assert_eq!(v.provider_id.as_deref(), Some("pi"));
assert_eq!(v.id, "sess-1");
}
#[test]
fn test_user_message_becomes_user_turn() {
let session = session_from(vec![user_text_entry("a", None, "hello")], "/tmp/p");
let v = session_to_view(&session);
assert_eq!(v.turns.len(), 1);
assert_eq!(v.turns[0].role, Role::User);
assert_eq!(v.turns[0].text, "hello");
}
#[test]
fn test_user_message_with_blocks_extracts_text() {
let entry = Entry::Message {
base: base("a", None, "t"),
message: AgentMessage::User {
content: MessageContent::Blocks(vec![
ContentBlock::Text {
text: "first".into(),
extra: HashMap::new(),
},
ContentBlock::Image {
data: "xx".into(),
mime_type: "image/png".into(),
extra: HashMap::new(),
},
ContentBlock::Text {
text: "second".into(),
extra: HashMap::new(),
},
]),
timestamp: 1,
extra: HashMap::new(),
},
extra: HashMap::new(),
};
let session = session_from(vec![entry], "/tmp/p");
let v = session_to_view(&session);
assert_eq!(v.turns[0].text, "first\nsecond");
}
#[test]
fn test_assistant_message_becomes_assistant_turn() {
let entry = assistant_entry(
"a",
None,
vec![ContentBlock::Text {
text: "ok".into(),
extra: HashMap::new(),
}],
usage(10, 20),
StopReason::Known(KnownStopReason::Stop),
"claude-opus",
);
let v = session_to_view(&session_from(vec![entry], "/tmp/p"));
assert_eq!(v.turns[0].role, Role::Assistant);
assert_eq!(v.turns[0].model.as_deref(), Some("claude-opus"));
assert_eq!(v.turns[0].stop_reason.as_deref(), Some("stop"));
let u = v.turns[0].token_usage.as_ref().unwrap();
assert_eq!(u.input_tokens, Some(10));
assert_eq!(u.output_tokens, Some(20));
}
#[test]
fn test_assistant_text_and_thinking_separated() {
let entry = assistant_entry(
"a",
None,
vec![
ContentBlock::Text {
text: "one".into(),
extra: HashMap::new(),
},
ContentBlock::Thinking {
thinking: "mmm".into(),
extra: HashMap::new(),
},
ContentBlock::Text {
text: "two".into(),
extra: HashMap::new(),
},
],
usage(1, 2),
StopReason::Known(KnownStopReason::Stop),
"m",
);
let v = session_to_view(&session_from(vec![entry], "/tmp/p"));
assert_eq!(v.turns[0].text, "one\ntwo");
assert_eq!(v.turns[0].thinking.as_deref(), Some("mmm"));
}
#[test]
fn test_assistant_tool_call_becomes_tool_invocation() {
let entry = assistant_entry(
"a",
None,
vec![ContentBlock::ToolCall {
id: "tc1".into(),
name: "Read".into(),
arguments: json!({"path": "/x"}),
extra: HashMap::new(),
}],
usage(1, 1),
StopReason::Known(KnownStopReason::ToolUse),
"m",
);
let v = session_to_view(&session_from(vec![entry], "/tmp/p"));
assert_eq!(v.turns[0].tool_uses.len(), 1);
let inv = &v.turns[0].tool_uses[0];
assert_eq!(inv.id, "tc1");
assert_eq!(inv.name, "Read");
assert_eq!(inv.category, Some(ToolCategory::FileRead));
}
#[test]
fn test_tool_classification() {
assert_eq!(classify_tool("read"), Some(ToolCategory::FileRead));
assert_eq!(classify_tool("write"), Some(ToolCategory::FileWrite));
assert_eq!(classify_tool("bash"), Some(ToolCategory::Shell));
assert_eq!(classify_tool("grep"), Some(ToolCategory::FileSearch));
assert_eq!(classify_tool("webfetch"), Some(ToolCategory::Network));
assert_eq!(classify_tool("Task"), Some(ToolCategory::Delegation));
assert_eq!(
classify_tool("some-agent-run"),
Some(ToolCategory::Delegation)
);
assert_eq!(classify_tool("obscure"), None);
}
#[test]
fn test_tool_result_correlates_back_to_invocation() {
let assistant = assistant_entry(
"a1",
None,
vec![ContentBlock::ToolCall {
id: "t1".into(),
name: "read".into(),
arguments: json!({}),
extra: HashMap::new(),
}],
usage(1, 1),
StopReason::Known(KnownStopReason::ToolUse),
"m",
);
let tr = Entry::Message {
base: base("a2", Some("a1"), "t"),
message: AgentMessage::ToolResult {
tool_call_id: "t1".into(),
tool_name: "read".into(),
content: vec![ToolResultContent::Text {
text: "result".into(),
extra: HashMap::new(),
}],
details: None,
is_error: false,
timestamp: 3,
extra: HashMap::new(),
},
extra: HashMap::new(),
};
let v = session_to_view(&session_from(vec![assistant, tr], "/tmp/p"));
let inv = &v.turns[0].tool_uses[0];
let res = inv.result.as_ref().unwrap();
assert_eq!(res.content, "result");
assert!(!res.is_error);
}
#[test]
fn test_tool_result_appears_as_own_turn() {
let tr = Entry::Message {
base: base("a", None, "t"),
message: AgentMessage::ToolResult {
tool_call_id: "t1".into(),
tool_name: "x".into(),
content: vec![ToolResultContent::Text {
text: "r".into(),
extra: HashMap::new(),
}],
details: None,
is_error: false,
timestamp: 1,
extra: HashMap::new(),
},
extra: HashMap::new(),
};
let v = session_to_view(&session_from(vec![tr], "/tmp/p"));
assert_eq!(v.turns.len(), 1);
assert_eq!(v.turns[0].role, Role::Other("tool".to_string()));
}
#[test]
fn test_bash_execution_turn() {
let e = Entry::Message {
base: base("a", None, "t"),
message: AgentMessage::BashExecution {
command: "ls".into(),
output: "a\nb".into(),
exit_code: Some(0),
cancelled: false,
truncated: false,
full_output_path: None,
exclude_from_context: None,
timestamp: 1,
extra: HashMap::new(),
},
extra: HashMap::new(),
};
let v = session_to_view(&session_from(vec![e], "/tmp/p"));
assert_eq!(v.turns[0].role, Role::Other("bash".to_string()));
assert!(v.turns[0].text.starts_with("$ ls"));
assert_eq!(v.turns[0].tool_uses.len(), 1);
assert_eq!(v.turns[0].tool_uses[0].category, Some(ToolCategory::Shell));
}
#[test]
fn test_parent_id_preserved() {
let v = session_to_view(&session_from(
vec![
user_text_entry("a", None, "x"),
user_text_entry("b", Some("a"), "y"),
],
"/tmp/p",
));
assert_eq!(v.turns[1].parent_id.as_deref(), Some("a"));
}
#[test]
fn test_compaction_produces_system_turn() {
let c = Entry::Compaction {
base: base("c", None, "t"),
summary: "sum".into(),
first_kept_entry_id: "x".into(),
tokens_before: 100,
details: None,
from_hook: Some(false),
extra: HashMap::new(),
};
let v = session_to_view(&session_from(vec![c], "/tmp/p"));
assert_eq!(v.turns[0].role, Role::System);
assert!(v.turns[0].text.starts_with("Compacted"));
let pi = v.turns[0].extra.get("pi").unwrap();
assert!(pi.get("compaction").is_some());
}
#[test]
fn test_branch_summary_produces_system_turn() {
let bs = Entry::BranchSummary {
base: base("bs", None, "t"),
from_id: "fromX".into(),
summary: "branched".into(),
details: None,
from_hook: None,
extra: HashMap::new(),
};
let v = session_to_view(&session_from(vec![bs], "/tmp/p"));
assert_eq!(v.turns[0].role, Role::System);
assert!(v.turns[0].text.starts_with("Branch summary"));
let pi = v.turns[0].extra.get("pi").unwrap();
assert!(pi.get("branchSummary").is_some());
}
#[test]
fn test_model_change_attaches_to_next_message() {
let mc = Entry::ModelChange {
base: base("mc", None, "t"),
provider: "anthropic".into(),
model_id: "claude-opus".into(),
extra: HashMap::new(),
};
let msg = user_text_entry("u", None, "hi");
let v = session_to_view(&session_from(vec![mc, msg], "/tmp/p"));
assert_eq!(v.turns.len(), 1);
let pi = v.turns[0].extra.get("pi").unwrap();
assert!(pi.get("modelChange").is_some());
}
#[test]
fn test_environment_populated_on_every_turn() {
let v = session_to_view(&session_from(
vec![
user_text_entry("a", None, "x"),
user_text_entry("b", Some("a"), "y"),
],
"/Users/alex/p",
));
for t in &v.turns {
assert_eq!(
t.environment.as_ref().unwrap().working_dir.as_deref(),
Some("/Users/alex/p")
);
}
}
#[test]
fn test_total_usage_aggregates_assistant_turns() {
let a1 = assistant_entry(
"a1",
None,
vec![],
usage(10, 20),
StopReason::Known(KnownStopReason::Stop),
"m",
);
let a2 = assistant_entry(
"a2",
Some("a1"),
vec![],
usage(10, 20),
StopReason::Known(KnownStopReason::Stop),
"m",
);
let v = session_to_view(&session_from(vec![a1, a2], "/tmp/p"));
let tu = v.total_usage.unwrap();
assert_eq!(tu.input_tokens, Some(20));
assert_eq!(tu.output_tokens, Some(40));
}
#[test]
fn test_files_changed_extracted_from_filewrite_tools() {
let a = assistant_entry(
"a",
None,
vec![
ContentBlock::ToolCall {
id: "t1".into(),
name: "write".into(),
arguments: json!({"path": "a.rs"}),
extra: HashMap::new(),
},
ContentBlock::ToolCall {
id: "t2".into(),
name: "edit".into(),
arguments: json!({"file_path": "b.rs"}),
extra: HashMap::new(),
},
],
usage(1, 1),
StopReason::Known(KnownStopReason::ToolUse),
"m",
);
let v = session_to_view(&session_from(vec![a], "/tmp/p"));
assert_eq!(v.files_changed, vec!["a.rs", "b.rs"]);
}
#[test]
fn test_files_changed_deduplicated() {
let a = assistant_entry(
"a",
None,
vec![
ContentBlock::ToolCall {
id: "t1".into(),
name: "write".into(),
arguments: json!({"path": "a.rs"}),
extra: HashMap::new(),
},
ContentBlock::ToolCall {
id: "t2".into(),
name: "write".into(),
arguments: json!({"path": "a.rs"}),
extra: HashMap::new(),
},
],
usage(1, 1),
StopReason::Known(KnownStopReason::ToolUse),
"m",
);
let v = session_to_view(&session_from(vec![a], "/tmp/p"));
assert_eq!(v.files_changed, vec!["a.rs"]);
}
#[test]
fn test_session_ids_includes_self_when_no_parent() {
let v = session_to_view(&session_from(vec![], "/tmp/p"));
assert_eq!(v.session_ids, vec!["sess-1"]);
}
#[test]
fn test_session_ids_chains_with_parent() {
let parent_header = SessionHeader {
version: 3,
id: "parent".into(),
timestamp: "2026-04-16T00:00:00Z".into(),
cwd: "/tmp/p".into(),
parent_session: None,
extra: HashMap::new(),
};
let parent = PiSession {
header: parent_header.clone(),
entries: vec![Entry::Session(parent_header)],
file_path: PathBuf::from("/tmp/p.jsonl"),
parent: None,
};
let mut child = session_from(vec![], "/tmp/p");
child.parent = Some(Box::new(parent));
let v = session_to_view(&child);
assert_eq!(v.session_ids, vec!["parent", "sess-1"]);
}
#[test]
fn test_started_at_from_header_timestamp() {
let session = session_from(vec![], "/tmp/p");
let v = session_to_view(&session);
assert!(v.started_at.is_some());
let mut bad = session;
bad.header.timestamp = "not-a-timestamp".into();
let v = session_to_view(&bad);
assert!(v.started_at.is_none());
}
fn write_session_file(dir: &std::path::Path, id: &str, ts: &str) -> PathBuf {
let path = dir.join(format!("{}.jsonl", id));
let line = format!(
r#"{{"type":"session","version":3,"id":"{id}","timestamp":"{ts}","cwd":"/tmp/p"}}
{{"type":"message","id":"u","parentId":null,"timestamp":"{ts}","message":{{"role":"user","content":"hi","timestamp":1}}}}"#,
id = id,
ts = ts
);
std::fs::write(&path, line).unwrap();
path
}
#[test]
fn test_provider_list_conversations_delegates_to_manager() {
let tmp = tempfile::TempDir::new().unwrap();
let sessions = tmp.path().join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let resolver = PathResolver::new().with_sessions_dir(&sessions);
let proj = resolver.project_dir("/tmp/p");
std::fs::create_dir_all(&proj).unwrap();
write_session_file(&proj, "s1", "2026-04-16T00:00:00Z");
let pi = PiConvo::with_resolver(resolver);
let ids = ConversationProvider::list_conversations(&pi, "/tmp/p").unwrap();
assert_eq!(ids, vec!["s1".to_string()]);
}
#[test]
fn test_provider_load_conversation_returns_view() {
let tmp = tempfile::TempDir::new().unwrap();
let sessions = tmp.path().join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let resolver = PathResolver::new().with_sessions_dir(&sessions);
let proj = resolver.project_dir("/tmp/p");
std::fs::create_dir_all(&proj).unwrap();
write_session_file(&proj, "s1", "2026-04-16T00:00:00Z");
let pi = PiConvo::with_resolver(resolver);
let v = ConversationProvider::load_conversation(&pi, "/tmp/p", "s1").unwrap();
assert_eq!(v.id, "s1");
assert_eq!(v.turns.len(), 1);
assert_eq!(v.turns[0].role, Role::User);
}
#[test]
fn test_provider_load_metadata_has_expected_fields() {
let tmp = tempfile::TempDir::new().unwrap();
let sessions = tmp.path().join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let resolver = PathResolver::new().with_sessions_dir(&sessions);
let proj = resolver.project_dir("/tmp/p");
std::fs::create_dir_all(&proj).unwrap();
let path = write_session_file(&proj, "s1", "2026-04-16T00:00:00Z");
let pi = PiConvo::with_resolver(resolver);
let m = ConversationProvider::load_metadata(&pi, "/tmp/p", "s1").unwrap();
assert_eq!(m.id, "s1");
assert!(m.started_at.is_some());
assert_eq!(m.file_path.as_ref(), Some(&path));
}
#[test]
fn test_provider_list_metadata_returns_all() {
let tmp = tempfile::TempDir::new().unwrap();
let sessions = tmp.path().join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
let resolver = PathResolver::new().with_sessions_dir(&sessions);
let proj = resolver.project_dir("/tmp/p");
std::fs::create_dir_all(&proj).unwrap();
write_session_file(&proj, "older", "2026-04-16T00:00:00Z");
std::thread::sleep(std::time::Duration::from_millis(30));
write_session_file(&proj, "newer", "2026-04-16T01:00:00Z");
let pi = PiConvo::with_resolver(resolver);
let all = ConversationProvider::list_metadata(&pi, "/tmp/p").unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, "newer");
}
#[test]
fn test_delegation_builds_delegated_work() {
let a = assistant_entry(
"a",
None,
vec![ContentBlock::ToolCall {
id: "d1".into(),
name: "Task".into(),
arguments: json!({"prompt": "do the thing"}),
extra: HashMap::new(),
}],
usage(1, 1),
StopReason::Known(KnownStopReason::ToolUse),
"m",
);
let v = session_to_view(&session_from(vec![a], "/tmp/p"));
assert_eq!(v.turns[0].delegations.len(), 1);
assert_eq!(v.turns[0].delegations[0].prompt, "do the thing");
assert_eq!(v.turns[0].delegations[0].agent_id, "d1");
}
#[test]
fn test_stop_reason_string_form() {
let a = assistant_entry(
"a",
None,
vec![],
usage(1, 1),
StopReason::Known(KnownStopReason::ToolUse),
"m",
);
let v = session_to_view(&session_from(vec![a], "/tmp/p"));
let sr = v.turns[0].stop_reason.as_deref().unwrap();
assert!(sr.to_lowercase().contains("tool"), "got: {}", sr);
}
#[test]
fn test_custom_message_becomes_other_role_turn() {
let cm = Entry::CustomMessage {
base: base("cm", None, "t"),
custom_type: "foo".into(),
content: MessageContent::Text("body".into()),
display: true,
details: None,
extra: HashMap::new(),
};
let v = session_to_view(&session_from(vec![cm], "/tmp/p"));
assert_eq!(v.turns[0].role, Role::Other("custom:foo".to_string()));
assert_eq!(v.turns[0].text, "body");
}
}