1use crate::PiConvo;
14use crate::error::PiError;
15use crate::reader::PiSession;
16use crate::types::{
17 AgentMessage, ContentBlock, Entry, MessageContent, StopReason, ToolResultContent, Usage,
18};
19use chrono::{DateTime, Utc};
20use serde_json::{Value, json};
21use std::collections::HashMap;
22use toolpath_convo::{
23 ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork,
24 EnvironmentSnapshot, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult,
25 Turn,
26};
27
28pub fn classify_tool(name: &str) -> Option<ToolCategory> {
36 let lower = name.to_lowercase();
37 if lower.contains("task") || lower.contains("agent") {
38 return Some(ToolCategory::Delegation);
39 }
40 match lower.as_str() {
41 "read" => Some(ToolCategory::FileRead),
42 "write" | "edit" => Some(ToolCategory::FileWrite),
43 "bash" | "shell" | "run" | "exec" => Some(ToolCategory::Shell),
44 "grep" | "glob" | "find" | "ls" => Some(ToolCategory::FileSearch),
45 "webfetch" | "websearch" | "fetch" => Some(ToolCategory::Network),
46 _ => None,
47 }
48}
49
50pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
60 match category {
61 ToolCategory::Shell => Some("bash"),
62 ToolCategory::FileRead => Some("read"),
63 ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
64 "grep"
65 } else {
66 "glob"
67 }),
68 ToolCategory::FileWrite => Some(
72 if args.get("old_string").is_some() || args.get("edits").is_some() {
73 "edit"
74 } else {
75 "write"
76 },
77 ),
78 ToolCategory::Network => Some(if args.get("url").is_some() {
79 "webfetch"
80 } else {
81 "websearch"
82 }),
83 ToolCategory::Delegation => Some("task"),
84 }
85}
86
87fn extract_prompt(args: &Value) -> String {
88 for key in ["prompt", "input", "instructions"] {
89 if let Some(s) = args.get(key).and_then(|v| v.as_str()) {
90 return s.to_string();
91 }
92 }
93 args.to_string()
94}
95
96fn extract_file_path(args: &Value) -> Option<String> {
97 for key in ["file_path", "path", "filename", "file"] {
98 if let Some(s) = args.get(key).and_then(|v| v.as_str()) {
99 return Some(s.to_string());
100 }
101 }
102 None
103}
104
105fn parse_ts(ts: &str) -> Option<DateTime<Utc>> {
106 DateTime::parse_from_rfc3339(ts)
107 .ok()
108 .map(|dt| dt.with_timezone(&Utc))
109}
110
111fn stop_reason_to_string(sr: &StopReason) -> String {
112 match serde_json::to_value(sr).ok().and_then(|v| match v {
113 Value::String(s) => Some(s),
114 _ => None,
115 }) {
116 Some(s) => s,
117 None => format!("{:?}", sr).to_lowercase(),
118 }
119}
120
121fn extract_user_text(content: &MessageContent) -> String {
122 match content {
123 MessageContent::Text(s) => s.clone(),
124 MessageContent::Blocks(blocks) => {
125 let texts: Vec<&str> = blocks
126 .iter()
127 .filter_map(|b| match b {
128 ContentBlock::Text { text, .. } => Some(text.as_str()),
129 _ => None,
130 })
131 .collect();
132 texts.join("\n")
133 }
134 }
135}
136
137fn extract_assistant_text(blocks: &[ContentBlock]) -> String {
138 let texts: Vec<&str> = blocks
139 .iter()
140 .filter_map(|b| match b {
141 ContentBlock::Text { text, .. } => Some(text.as_str()),
142 _ => None,
143 })
144 .collect();
145 texts.join("\n")
146}
147
148fn extract_assistant_thinking(blocks: &[ContentBlock]) -> Option<String> {
149 let thinking: Vec<&str> = blocks
150 .iter()
151 .filter_map(|b| match b {
152 ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
153 _ => None,
154 })
155 .collect();
156 if thinking.is_empty() {
157 None
158 } else {
159 Some(thinking.join("\n"))
160 }
161}
162
163fn extract_tool_result_text(content: &[ToolResultContent]) -> String {
164 let texts: Vec<&str> = content
165 .iter()
166 .filter_map(|c| match c {
167 ToolResultContent::Text { text, .. } => Some(text.as_str()),
168 _ => None,
169 })
170 .collect();
171 texts.join("\n")
172}
173
174fn usage_to_token_usage(usage: &Usage) -> Option<TokenUsage> {
179 if usage.input == 0 && usage.output == 0 && usage.cache_read == 0 && usage.cache_write == 0 {
180 return None;
181 }
182 Some(TokenUsage {
183 input_tokens: Some(usage.input as u32),
184 output_tokens: Some(usage.output as u32),
185 cache_read_tokens: if usage.cache_read > 0 {
186 Some(usage.cache_read as u32)
187 } else {
188 None
189 },
190 cache_write_tokens: if usage.cache_write > 0 {
191 Some(usage.cache_write as u32)
192 } else {
193 None
194 },
195 ..Default::default()
196 })
197}
198
199fn environment_for(session: &PiSession) -> EnvironmentSnapshot {
200 EnvironmentSnapshot {
201 working_dir: Some(session.header.cwd.clone()),
202 vcs_branch: None,
203 vcs_revision: None,
204 }
205}
206
207fn truncate_output(output: &str, max: usize) -> String {
208 if output.chars().count() <= max {
209 output.to_string()
210 } else {
211 let truncated: String = output.chars().take(max).collect();
212 format!("{}…(truncated)", truncated)
213 }
214}
215
216pub fn session_to_view(session: &PiSession) -> ConversationView {
220 let env = environment_for(session);
221
222 let mut turns: Vec<Turn> = Vec::new();
229 let mut tool_call_locs: HashMap<String, (usize, usize)> = HashMap::new();
231 let mut delegation_locs: HashMap<String, (usize, usize)> = HashMap::new();
233 let mut tool_result_payloads: Vec<(usize, String, String, bool)> = Vec::new();
235
236 for entry in &session.entries {
237 match entry {
238 Entry::Session(_) => continue,
239
240 Entry::ModelChange { .. } | Entry::ThinkingLevelChange { .. } | Entry::Label { .. } => {
241 }
244
245 Entry::Compaction { base, summary, .. } => {
246 turns.push(Turn {
247 id: base.id.clone(),
248 parent_id: base.parent_id.clone(),
249 group_id: None,
250 role: Role::System,
251 timestamp: base.timestamp.clone(),
252 text: format!("Compacted (summary): {}", summary),
253 thinking: None,
254 tool_uses: vec![],
255 model: None,
256 stop_reason: None,
257 token_usage: None,
258 attributed_token_usage: None,
259 environment: Some(env.clone()),
260 delegations: vec![],
261 file_mutations: Vec::new(),
262 });
263 }
264
265 Entry::BranchSummary { base, summary, .. } => {
266 turns.push(Turn {
267 id: base.id.clone(),
268 parent_id: base.parent_id.clone(),
269 group_id: None,
270 role: Role::System,
271 timestamp: base.timestamp.clone(),
272 text: format!("Branch summary: {}", summary),
273 thinking: None,
274 tool_uses: vec![],
275 model: None,
276 stop_reason: None,
277 token_usage: None,
278 attributed_token_usage: None,
279 environment: Some(env.clone()),
280 delegations: vec![],
281 file_mutations: Vec::new(),
282 });
283 }
284
285 Entry::Custom { base, .. } => {
286 turns.push(Turn {
287 id: base.id.clone(),
288 parent_id: base.parent_id.clone(),
289 group_id: None,
290 role: Role::Other("custom".to_string()),
291 timestamp: base.timestamp.clone(),
292 text: String::new(),
293 thinking: None,
294 tool_uses: vec![],
295 model: None,
296 stop_reason: None,
297 token_usage: None,
298 attributed_token_usage: None,
299 environment: Some(env.clone()),
300 delegations: vec![],
301 file_mutations: Vec::new(),
302 });
303 }
304
305 Entry::CustomMessage {
306 base,
307 custom_type,
308 content,
309 ..
310 } => {
311 turns.push(Turn {
312 id: base.id.clone(),
313 parent_id: base.parent_id.clone(),
314 group_id: None,
315 role: Role::Other(format!("custom:{}", custom_type)),
316 timestamp: base.timestamp.clone(),
317 text: extract_user_text(content),
318 thinking: None,
319 tool_uses: vec![],
320 model: None,
321 stop_reason: None,
322 token_usage: None,
323 attributed_token_usage: None,
324 environment: Some(env.clone()),
325 delegations: vec![],
326 file_mutations: Vec::new(),
327 });
328 }
329
330 Entry::Message { base, message, .. } => {
331 let text;
332 let mut thinking = None;
333 let mut tool_uses: Vec<ToolInvocation> = Vec::new();
334 let mut model: Option<String> = None;
335 let mut stop_reason_s: Option<String> = None;
336 let mut token_usage: Option<TokenUsage> = None;
337 let mut delegations: Vec<DelegatedWork> = Vec::new();
338 let role: Role;
339
340 match message {
341 AgentMessage::User { content, .. } => {
342 role = Role::User;
343 text = extract_user_text(content);
344 }
345
346 AgentMessage::Assistant {
347 content,
348 model: m,
349 usage,
350 stop_reason,
351 ..
352 } => {
353 role = Role::Assistant;
354 text = extract_assistant_text(content);
355 thinking = extract_assistant_thinking(content);
356 model = Some(m.clone());
357 stop_reason_s = Some(stop_reason_to_string(stop_reason));
358 token_usage = usage_to_token_usage(usage);
359
360 let turn_idx = turns.len();
361 for block in content {
362 if let ContentBlock::ToolCall {
363 id,
364 name,
365 arguments,
366 ..
367 } = block
368 {
369 let category = classify_tool(name);
370 let tool_idx = tool_uses.len();
371 tool_call_locs.insert(id.clone(), (turn_idx, tool_idx));
372 if category == Some(ToolCategory::Delegation) {
373 let deleg_idx = delegations.len();
374 delegations.push(DelegatedWork {
375 agent_id: id.clone(),
376 prompt: extract_prompt(arguments),
377 turns: vec![],
378 result: None,
379 });
380 delegation_locs.insert(id.clone(), (turn_idx, deleg_idx));
381 }
382 tool_uses.push(ToolInvocation {
383 id: id.clone(),
384 name: name.clone(),
385 input: arguments.clone(),
386 result: None,
387 category,
388 });
389 }
390 }
391 }
392
393 AgentMessage::ToolResult {
394 tool_call_id,
395 content,
396 is_error,
397 ..
398 } => {
399 tool_result_payloads.push((
406 usize::MAX,
407 tool_call_id.clone(),
408 extract_tool_result_text(content),
409 *is_error,
410 ));
411 continue;
412 }
413
414 AgentMessage::BashExecution {
415 command,
416 output,
417 exit_code,
418 ..
419 } => {
420 role = Role::Other("bash".to_string());
421 let out_trunc = truncate_output(output, 4096);
422 text = format!("$ {}\n{}", command, out_trunc);
423 tool_uses.push(ToolInvocation {
425 id: base.id.clone(),
426 name: "bash".to_string(),
427 input: json!({ "command": command }),
428 result: Some(ToolResult {
429 content: output.clone(),
430 is_error: !matches!(exit_code, Some(0)),
431 }),
432 category: Some(ToolCategory::Shell),
433 });
434 }
435
436 AgentMessage::Custom {
437 custom_type,
438 content,
439 ..
440 } => {
441 role = Role::Other(format!("custom:{}", custom_type));
442 text = extract_user_text(content);
443 }
444
445 AgentMessage::BranchSummary { .. } | AgentMessage::CompactionSummary { .. } => {
446 role = Role::System;
447 text = String::new();
448 }
449 }
450
451 turns.push(Turn {
452 id: base.id.clone(),
453 parent_id: base.parent_id.clone(),
454 group_id: None,
455 role,
456 timestamp: base.timestamp.clone(),
457 text,
458 thinking,
459 tool_uses,
460 model,
461 stop_reason: stop_reason_s,
462 token_usage,
463 attributed_token_usage: None,
464 environment: Some(env.clone()),
465 delegations,
466 file_mutations: Vec::new(),
467 });
468 }
469 }
470 }
471
472 for (_tr_turn_idx, tool_call_id, content, is_error) in &tool_result_payloads {
474 if let Some((turn_idx, tool_idx)) = tool_call_locs.get(tool_call_id)
475 && let Some(turn) = turns.get_mut(*turn_idx)
476 && let Some(inv) = turn.tool_uses.get_mut(*tool_idx)
477 {
478 inv.result = Some(ToolResult {
479 content: content.clone(),
480 is_error: *is_error,
481 });
482 }
483 if let Some((turn_idx, deleg_idx)) = delegation_locs.get(tool_call_id)
484 && let Some(turn) = turns.get_mut(*turn_idx)
485 && let Some(d) = turn.delegations.get_mut(*deleg_idx)
486 {
487 d.result = Some(content.clone());
488 }
489 }
490
491 let mut have_any_usage = false;
493 let mut total = TokenUsage::default();
494 for turn in &turns {
495 if let Some(u) = &turn.token_usage {
496 have_any_usage = true;
497 total.input_tokens =
498 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
499 total.output_tokens =
500 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
501 if let Some(r) = u.cache_read_tokens {
502 total.cache_read_tokens = Some(total.cache_read_tokens.unwrap_or(0) + r);
503 }
504 if let Some(w) = u.cache_write_tokens {
505 total.cache_write_tokens = Some(total.cache_write_tokens.unwrap_or(0) + w);
506 }
507 }
508 }
509 let total_usage = if have_any_usage { Some(total) } else { None };
510
511 let mut files_changed: Vec<String> = Vec::new();
513 let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
514 for turn in &turns {
515 for inv in &turn.tool_uses {
516 if inv.category == Some(ToolCategory::FileWrite)
517 && let Some(p) = extract_file_path(&inv.input)
518 && seen_files.insert(p.clone())
519 {
520 files_changed.push(p);
521 }
522 }
523 }
524
525 let mut session_ids: Vec<String> = Vec::new();
527 fn walk_parents(s: &PiSession, out: &mut Vec<String>) {
528 if let Some(p) = &s.parent {
529 walk_parents(p, out);
530 }
531 out.push(s.header.id.clone());
532 }
533 walk_parents(session, &mut session_ids);
534
535 let started_at = parse_ts(&session.header.timestamp);
536 let last_activity = turns.last().and_then(|t| parse_ts(&t.timestamp));
537
538 let base = if session.header.cwd.is_empty() {
539 None
540 } else {
541 Some(SessionBase {
542 working_dir: Some(session.header.cwd.clone()),
543 ..Default::default()
544 })
545 };
546
547 ConversationView {
548 id: session.header.id.clone(),
549 started_at,
550 last_activity,
551 turns,
552 total_usage,
553 provider_id: Some("pi".to_string()),
554 files_changed,
555 session_ids,
556 events: vec![],
557 base,
558 ..Default::default()
559 }
560}
561
562fn to_convo_err(e: PiError) -> ConvoError {
565 ConvoError::Provider(e.to_string())
566}
567
568impl ConversationProvider for PiConvo {
569 fn list_conversations(&self, project: &str) -> Result<Vec<String>, ConvoError> {
570 let metas = self.list_sessions(project).map_err(to_convo_err)?;
571 Ok(metas.into_iter().map(|m| m.id).collect())
572 }
573
574 fn load_conversation(
575 &self,
576 project: &str,
577 conversation_id: &str,
578 ) -> Result<ConversationView, ConvoError> {
579 let session = self
580 .read_session(project, conversation_id)
581 .map_err(to_convo_err)?;
582 Ok(session_to_view(&session))
583 }
584
585 fn load_metadata(
586 &self,
587 project: &str,
588 conversation_id: &str,
589 ) -> Result<ConversationMeta, ConvoError> {
590 let metas = self.list_sessions(project).map_err(to_convo_err)?;
591 let meta = metas
592 .into_iter()
593 .find(|m| m.id == conversation_id)
594 .ok_or_else(|| {
595 ConvoError::Provider(format!("session not found: {}", conversation_id))
596 })?;
597 Ok(meta_to_conversation_meta(meta))
598 }
599
600 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>, ConvoError> {
601 let metas = self.list_sessions(project).map_err(to_convo_err)?;
602 Ok(metas.into_iter().map(meta_to_conversation_meta).collect())
603 }
604}
605
606fn meta_to_conversation_meta(meta: crate::reader::SessionMeta) -> ConversationMeta {
607 let ts = parse_ts(&meta.timestamp);
608 ConversationMeta {
609 id: meta.id,
610 started_at: ts,
611 last_activity: ts,
613 message_count: meta.entry_count,
616 file_path: Some(meta.file_path),
617 predecessor: None,
618 successor: None,
619 }
620}
621
622#[cfg(test)]
625mod tests {
626 use super::*;
627 use crate::paths::PathResolver;
628 use crate::reader::PiSession;
629 use crate::types::{
630 AgentMessage, ContentBlock, CostBreakdown, Entry, EntryBase, KnownStopReason,
631 MessageContent, SessionHeader, StopReason, ToolResultContent, Usage,
632 };
633 use std::collections::HashMap;
634 use std::path::PathBuf;
635
636 #[test]
637 fn test_all_zero_usage_decodes_as_none() {
638 let zero = Usage {
641 input: 0,
642 output: 0,
643 cache_read: 0,
644 cache_write: 0,
645 total_tokens: 0,
646 cost: CostBreakdown::default(),
647 };
648 assert!(usage_to_token_usage(&zero).is_none());
649
650 let real = Usage {
651 input: 10,
652 output: 5,
653 cache_read: 0,
654 cache_write: 0,
655 total_tokens: 15,
656 cost: CostBreakdown::default(),
657 };
658 let u = usage_to_token_usage(&real).unwrap();
659 assert_eq!(u.input_tokens, Some(10));
660 assert_eq!(u.output_tokens, Some(5));
661 assert_eq!(u.cache_read_tokens, None);
662 }
663
664 fn header(id: &str, cwd: &str) -> SessionHeader {
665 SessionHeader {
666 version: 3,
667 id: id.into(),
668 timestamp: "2026-04-16T00:00:00Z".into(),
669 cwd: cwd.into(),
670 parent_session: None,
671 extra: HashMap::new(),
672 }
673 }
674
675 fn base(id: &str, parent: Option<&str>, ts: &str) -> EntryBase {
676 EntryBase {
677 id: id.into(),
678 parent_id: parent.map(String::from),
679 timestamp: ts.into(),
680 }
681 }
682
683 fn user_text_entry(id: &str, parent: Option<&str>, text: &str) -> Entry {
684 Entry::Message {
685 base: base(id, parent, "2026-04-16T00:00:01Z"),
686 message: AgentMessage::User {
687 content: MessageContent::Text(text.into()),
688 timestamp: 1,
689 extra: HashMap::new(),
690 },
691 extra: HashMap::new(),
692 }
693 }
694
695 fn assistant_entry(
696 id: &str,
697 parent: Option<&str>,
698 content: Vec<ContentBlock>,
699 usage: Usage,
700 stop_reason: StopReason,
701 model: &str,
702 ) -> Entry {
703 Entry::Message {
704 base: base(id, parent, "2026-04-16T00:00:02Z"),
705 message: AgentMessage::Assistant {
706 content,
707 api: "anthropic".into(),
708 provider: "anthropic".into(),
709 model: model.into(),
710 usage,
711 stop_reason,
712 error_message: None,
713 timestamp: 2,
714 extra: HashMap::new(),
715 },
716 extra: HashMap::new(),
717 }
718 }
719
720 fn usage(input: u64, output: u64) -> Usage {
721 Usage {
722 input,
723 output,
724 cache_read: 0,
725 cache_write: 0,
726 total_tokens: input + output,
727 cost: CostBreakdown::default(),
728 }
729 }
730
731 fn session_from(entries: Vec<Entry>, cwd: &str) -> PiSession {
732 let h = header("sess-1", cwd);
733 let mut all = vec![Entry::Session(h.clone())];
734 all.extend(entries);
735 PiSession {
736 header: h,
737 entries: all,
738 file_path: PathBuf::from("/tmp/fake.jsonl"),
739 parent: None,
740 }
741 }
742
743 #[test]
744 fn test_empty_session_produces_view() {
745 let session = session_from(vec![], "/tmp/p");
746 let v = session_to_view(&session);
747 assert_eq!(v.turns.len(), 0);
748 assert_eq!(v.provider_id.as_deref(), Some("pi"));
749 assert_eq!(v.id, "sess-1");
750 }
751
752 #[test]
753 fn test_user_message_becomes_user_turn() {
754 let session = session_from(vec![user_text_entry("a", None, "hello")], "/tmp/p");
755 let v = session_to_view(&session);
756 assert_eq!(v.turns.len(), 1);
757 assert_eq!(v.turns[0].role, Role::User);
758 assert_eq!(v.turns[0].text, "hello");
759 }
760
761 #[test]
762 fn test_user_message_with_blocks_extracts_text() {
763 let entry = Entry::Message {
764 base: base("a", None, "t"),
765 message: AgentMessage::User {
766 content: MessageContent::Blocks(vec![
767 ContentBlock::Text {
768 text: "first".into(),
769 extra: HashMap::new(),
770 },
771 ContentBlock::Image {
772 data: "xx".into(),
773 mime_type: "image/png".into(),
774 extra: HashMap::new(),
775 },
776 ContentBlock::Text {
777 text: "second".into(),
778 extra: HashMap::new(),
779 },
780 ]),
781 timestamp: 1,
782 extra: HashMap::new(),
783 },
784 extra: HashMap::new(),
785 };
786 let session = session_from(vec![entry], "/tmp/p");
787 let v = session_to_view(&session);
788 assert_eq!(v.turns[0].text, "first\nsecond");
789 }
790
791 #[test]
792 fn test_assistant_message_becomes_assistant_turn() {
793 let entry = assistant_entry(
794 "a",
795 None,
796 vec![ContentBlock::Text {
797 text: "ok".into(),
798 extra: HashMap::new(),
799 }],
800 usage(10, 20),
801 StopReason::Known(KnownStopReason::Stop),
802 "claude-opus",
803 );
804 let v = session_to_view(&session_from(vec![entry], "/tmp/p"));
805 assert_eq!(v.turns[0].role, Role::Assistant);
806 assert_eq!(v.turns[0].model.as_deref(), Some("claude-opus"));
807 assert_eq!(v.turns[0].stop_reason.as_deref(), Some("stop"));
808 let u = v.turns[0].token_usage.as_ref().unwrap();
809 assert_eq!(u.input_tokens, Some(10));
810 assert_eq!(u.output_tokens, Some(20));
811 }
812
813 #[test]
814 fn test_assistant_text_and_thinking_separated() {
815 let entry = assistant_entry(
816 "a",
817 None,
818 vec![
819 ContentBlock::Text {
820 text: "one".into(),
821 extra: HashMap::new(),
822 },
823 ContentBlock::Thinking {
824 thinking: "mmm".into(),
825 extra: HashMap::new(),
826 },
827 ContentBlock::Text {
828 text: "two".into(),
829 extra: HashMap::new(),
830 },
831 ],
832 usage(1, 2),
833 StopReason::Known(KnownStopReason::Stop),
834 "m",
835 );
836 let v = session_to_view(&session_from(vec![entry], "/tmp/p"));
837 assert_eq!(v.turns[0].text, "one\ntwo");
838 assert_eq!(v.turns[0].thinking.as_deref(), Some("mmm"));
839 }
840
841 #[test]
842 fn test_assistant_tool_call_becomes_tool_invocation() {
843 let entry = assistant_entry(
844 "a",
845 None,
846 vec![ContentBlock::ToolCall {
847 id: "tc1".into(),
848 name: "Read".into(),
849 arguments: json!({"path": "/x"}),
850 extra: HashMap::new(),
851 }],
852 usage(1, 1),
853 StopReason::Known(KnownStopReason::ToolUse),
854 "m",
855 );
856 let v = session_to_view(&session_from(vec![entry], "/tmp/p"));
857 assert_eq!(v.turns[0].tool_uses.len(), 1);
858 let inv = &v.turns[0].tool_uses[0];
859 assert_eq!(inv.id, "tc1");
860 assert_eq!(inv.name, "Read");
861 assert_eq!(inv.category, Some(ToolCategory::FileRead));
862 }
863
864 #[test]
865 fn test_tool_classification() {
866 assert_eq!(classify_tool("read"), Some(ToolCategory::FileRead));
867 assert_eq!(classify_tool("write"), Some(ToolCategory::FileWrite));
868 assert_eq!(classify_tool("bash"), Some(ToolCategory::Shell));
869 assert_eq!(classify_tool("grep"), Some(ToolCategory::FileSearch));
870 assert_eq!(classify_tool("webfetch"), Some(ToolCategory::Network));
871 assert_eq!(classify_tool("Task"), Some(ToolCategory::Delegation));
872 assert_eq!(
873 classify_tool("some-agent-run"),
874 Some(ToolCategory::Delegation)
875 );
876 assert_eq!(classify_tool("obscure"), None);
877 }
878
879 #[test]
880 fn test_tool_result_correlates_back_to_invocation() {
881 let assistant = assistant_entry(
882 "a1",
883 None,
884 vec![ContentBlock::ToolCall {
885 id: "t1".into(),
886 name: "read".into(),
887 arguments: json!({}),
888 extra: HashMap::new(),
889 }],
890 usage(1, 1),
891 StopReason::Known(KnownStopReason::ToolUse),
892 "m",
893 );
894 let tr = Entry::Message {
895 base: base("a2", Some("a1"), "t"),
896 message: AgentMessage::ToolResult {
897 tool_call_id: "t1".into(),
898 tool_name: "read".into(),
899 content: vec![ToolResultContent::Text {
900 text: "result".into(),
901 extra: HashMap::new(),
902 }],
903 details: None,
904 is_error: false,
905 timestamp: 3,
906 extra: HashMap::new(),
907 },
908 extra: HashMap::new(),
909 };
910 let v = session_to_view(&session_from(vec![assistant, tr], "/tmp/p"));
911 let inv = &v.turns[0].tool_uses[0];
912 let res = inv.result.as_ref().unwrap();
913 assert_eq!(res.content, "result");
914 assert!(!res.is_error);
915 }
916
917 #[test]
918 fn test_orphan_tool_result_is_dropped() {
919 let tr = Entry::Message {
922 base: base("a", None, "t"),
923 message: AgentMessage::ToolResult {
924 tool_call_id: "t1".into(),
925 tool_name: "x".into(),
926 content: vec![ToolResultContent::Text {
927 text: "r".into(),
928 extra: HashMap::new(),
929 }],
930 details: None,
931 is_error: false,
932 timestamp: 1,
933 extra: HashMap::new(),
934 },
935 extra: HashMap::new(),
936 };
937 let v = session_to_view(&session_from(vec![tr], "/tmp/p"));
938 assert_eq!(v.turns.len(), 0);
939 }
940
941 #[test]
942 fn test_bash_execution_turn() {
943 let e = Entry::Message {
944 base: base("a", None, "t"),
945 message: AgentMessage::BashExecution {
946 command: "ls".into(),
947 output: "a\nb".into(),
948 exit_code: Some(0),
949 cancelled: false,
950 truncated: false,
951 full_output_path: None,
952 exclude_from_context: None,
953 timestamp: 1,
954 extra: HashMap::new(),
955 },
956 extra: HashMap::new(),
957 };
958 let v = session_to_view(&session_from(vec![e], "/tmp/p"));
959 assert_eq!(v.turns[0].role, Role::Other("bash".to_string()));
960 assert!(v.turns[0].text.starts_with("$ ls"));
961 assert_eq!(v.turns[0].tool_uses.len(), 1);
962 assert_eq!(v.turns[0].tool_uses[0].category, Some(ToolCategory::Shell));
963 }
964
965 #[test]
966 fn test_parent_id_preserved() {
967 let v = session_to_view(&session_from(
968 vec![
969 user_text_entry("a", None, "x"),
970 user_text_entry("b", Some("a"), "y"),
971 ],
972 "/tmp/p",
973 ));
974 assert_eq!(v.turns[1].parent_id.as_deref(), Some("a"));
975 }
976
977 #[test]
978 fn test_compaction_produces_system_turn() {
979 let c = Entry::Compaction {
980 base: base("c", None, "t"),
981 summary: "sum".into(),
982 first_kept_entry_id: "x".into(),
983 tokens_before: 100,
984 details: None,
985 from_hook: Some(false),
986 extra: HashMap::new(),
987 };
988 let v = session_to_view(&session_from(vec![c], "/tmp/p"));
989 assert_eq!(v.turns[0].role, Role::System);
990 assert!(v.turns[0].text.starts_with("Compacted"));
991 }
992
993 #[test]
994 fn test_branch_summary_produces_system_turn() {
995 let bs = Entry::BranchSummary {
996 base: base("bs", None, "t"),
997 from_id: "fromX".into(),
998 summary: "branched".into(),
999 details: None,
1000 from_hook: None,
1001 extra: HashMap::new(),
1002 };
1003 let v = session_to_view(&session_from(vec![bs], "/tmp/p"));
1004 assert_eq!(v.turns[0].role, Role::System);
1005 assert!(v.turns[0].text.starts_with("Branch summary"));
1006 }
1007
1008 #[test]
1009 fn test_model_change_drops_silently() {
1010 let mc = Entry::ModelChange {
1011 base: base("mc", None, "t"),
1012 provider: "anthropic".into(),
1013 model_id: "claude-opus".into(),
1014 extra: HashMap::new(),
1015 };
1016 let msg = user_text_entry("u", None, "hi");
1017 let v = session_to_view(&session_from(vec![mc, msg], "/tmp/p"));
1018 assert_eq!(v.turns.len(), 1);
1019 }
1020
1021 #[test]
1022 fn test_environment_populated_on_every_turn() {
1023 let v = session_to_view(&session_from(
1024 vec![
1025 user_text_entry("a", None, "x"),
1026 user_text_entry("b", Some("a"), "y"),
1027 ],
1028 "/Users/alex/p",
1029 ));
1030 for t in &v.turns {
1031 assert_eq!(
1032 t.environment.as_ref().unwrap().working_dir.as_deref(),
1033 Some("/Users/alex/p")
1034 );
1035 }
1036 }
1037
1038 #[test]
1039 fn test_total_usage_aggregates_assistant_turns() {
1040 let a1 = assistant_entry(
1041 "a1",
1042 None,
1043 vec![],
1044 usage(10, 20),
1045 StopReason::Known(KnownStopReason::Stop),
1046 "m",
1047 );
1048 let a2 = assistant_entry(
1049 "a2",
1050 Some("a1"),
1051 vec![],
1052 usage(10, 20),
1053 StopReason::Known(KnownStopReason::Stop),
1054 "m",
1055 );
1056 let v = session_to_view(&session_from(vec![a1, a2], "/tmp/p"));
1057 let tu = v.total_usage.unwrap();
1058 assert_eq!(tu.input_tokens, Some(20));
1059 assert_eq!(tu.output_tokens, Some(40));
1060 }
1061
1062 #[test]
1063 fn test_files_changed_extracted_from_filewrite_tools() {
1064 let a = assistant_entry(
1065 "a",
1066 None,
1067 vec![
1068 ContentBlock::ToolCall {
1069 id: "t1".into(),
1070 name: "write".into(),
1071 arguments: json!({"path": "a.rs"}),
1072 extra: HashMap::new(),
1073 },
1074 ContentBlock::ToolCall {
1075 id: "t2".into(),
1076 name: "edit".into(),
1077 arguments: json!({"file_path": "b.rs"}),
1078 extra: HashMap::new(),
1079 },
1080 ],
1081 usage(1, 1),
1082 StopReason::Known(KnownStopReason::ToolUse),
1083 "m",
1084 );
1085 let v = session_to_view(&session_from(vec![a], "/tmp/p"));
1086 assert_eq!(v.files_changed, vec!["a.rs", "b.rs"]);
1087 }
1088
1089 #[test]
1090 fn test_files_changed_deduplicated() {
1091 let a = assistant_entry(
1092 "a",
1093 None,
1094 vec![
1095 ContentBlock::ToolCall {
1096 id: "t1".into(),
1097 name: "write".into(),
1098 arguments: json!({"path": "a.rs"}),
1099 extra: HashMap::new(),
1100 },
1101 ContentBlock::ToolCall {
1102 id: "t2".into(),
1103 name: "write".into(),
1104 arguments: json!({"path": "a.rs"}),
1105 extra: HashMap::new(),
1106 },
1107 ],
1108 usage(1, 1),
1109 StopReason::Known(KnownStopReason::ToolUse),
1110 "m",
1111 );
1112 let v = session_to_view(&session_from(vec![a], "/tmp/p"));
1113 assert_eq!(v.files_changed, vec!["a.rs"]);
1114 }
1115
1116 #[test]
1117 fn test_session_ids_includes_self_when_no_parent() {
1118 let v = session_to_view(&session_from(vec![], "/tmp/p"));
1119 assert_eq!(v.session_ids, vec!["sess-1"]);
1120 }
1121
1122 #[test]
1123 fn test_session_ids_chains_with_parent() {
1124 let parent_header = SessionHeader {
1125 version: 3,
1126 id: "parent".into(),
1127 timestamp: "2026-04-16T00:00:00Z".into(),
1128 cwd: "/tmp/p".into(),
1129 parent_session: None,
1130 extra: HashMap::new(),
1131 };
1132 let parent = PiSession {
1133 header: parent_header.clone(),
1134 entries: vec![Entry::Session(parent_header)],
1135 file_path: PathBuf::from("/tmp/p.jsonl"),
1136 parent: None,
1137 };
1138 let mut child = session_from(vec![], "/tmp/p");
1139 child.parent = Some(Box::new(parent));
1140 let v = session_to_view(&child);
1141 assert_eq!(v.session_ids, vec!["parent", "sess-1"]);
1142 }
1143
1144 #[test]
1145 fn test_started_at_from_header_timestamp() {
1146 let session = session_from(vec![], "/tmp/p");
1147 let v = session_to_view(&session);
1148 assert!(v.started_at.is_some());
1149
1150 let mut bad = session;
1151 bad.header.timestamp = "not-a-timestamp".into();
1152 let v = session_to_view(&bad);
1153 assert!(v.started_at.is_none());
1154 }
1155
1156 fn write_session_file(dir: &std::path::Path, id: &str, ts: &str) -> PathBuf {
1159 let path = dir.join(format!("{}.jsonl", id));
1160 let line = format!(
1161 r#"{{"type":"session","version":3,"id":"{id}","timestamp":"{ts}","cwd":"/tmp/p"}}
1162{{"type":"message","id":"u","parentId":null,"timestamp":"{ts}","message":{{"role":"user","content":"hi","timestamp":1}}}}"#,
1163 id = id,
1164 ts = ts
1165 );
1166 std::fs::write(&path, line).unwrap();
1167 path
1168 }
1169
1170 #[test]
1171 fn test_provider_list_conversations_delegates_to_manager() {
1172 let tmp = tempfile::TempDir::new().unwrap();
1173 let sessions = tmp.path().join("sessions");
1174 std::fs::create_dir_all(&sessions).unwrap();
1175 let resolver = PathResolver::new().with_sessions_dir(&sessions);
1176 let proj = resolver.project_dir("/tmp/p");
1177 std::fs::create_dir_all(&proj).unwrap();
1178 write_session_file(&proj, "s1", "2026-04-16T00:00:00Z");
1179
1180 let pi = PiConvo::with_resolver(resolver);
1181 let ids = ConversationProvider::list_conversations(&pi, "/tmp/p").unwrap();
1182 assert_eq!(ids, vec!["s1".to_string()]);
1183 }
1184
1185 #[test]
1186 fn test_provider_load_conversation_returns_view() {
1187 let tmp = tempfile::TempDir::new().unwrap();
1188 let sessions = tmp.path().join("sessions");
1189 std::fs::create_dir_all(&sessions).unwrap();
1190 let resolver = PathResolver::new().with_sessions_dir(&sessions);
1191 let proj = resolver.project_dir("/tmp/p");
1192 std::fs::create_dir_all(&proj).unwrap();
1193 write_session_file(&proj, "s1", "2026-04-16T00:00:00Z");
1194
1195 let pi = PiConvo::with_resolver(resolver);
1196 let v = ConversationProvider::load_conversation(&pi, "/tmp/p", "s1").unwrap();
1197 assert_eq!(v.id, "s1");
1198 assert_eq!(v.turns.len(), 1);
1199 assert_eq!(v.turns[0].role, Role::User);
1200 }
1201
1202 #[test]
1203 fn test_provider_load_metadata_has_expected_fields() {
1204 let tmp = tempfile::TempDir::new().unwrap();
1205 let sessions = tmp.path().join("sessions");
1206 std::fs::create_dir_all(&sessions).unwrap();
1207 let resolver = PathResolver::new().with_sessions_dir(&sessions);
1208 let proj = resolver.project_dir("/tmp/p");
1209 std::fs::create_dir_all(&proj).unwrap();
1210 let path = write_session_file(&proj, "s1", "2026-04-16T00:00:00Z");
1211
1212 let pi = PiConvo::with_resolver(resolver);
1213 let m = ConversationProvider::load_metadata(&pi, "/tmp/p", "s1").unwrap();
1214 assert_eq!(m.id, "s1");
1215 assert!(m.started_at.is_some());
1216 assert_eq!(m.file_path.as_ref(), Some(&path));
1217 }
1218
1219 #[test]
1220 fn test_provider_list_metadata_returns_all() {
1221 let tmp = tempfile::TempDir::new().unwrap();
1222 let sessions = tmp.path().join("sessions");
1223 std::fs::create_dir_all(&sessions).unwrap();
1224 let resolver = PathResolver::new().with_sessions_dir(&sessions);
1225 let proj = resolver.project_dir("/tmp/p");
1226 std::fs::create_dir_all(&proj).unwrap();
1227 write_session_file(&proj, "older", "2026-04-16T00:00:00Z");
1228 std::thread::sleep(std::time::Duration::from_millis(30));
1229 write_session_file(&proj, "newer", "2026-04-16T01:00:00Z");
1230
1231 let pi = PiConvo::with_resolver(resolver);
1232 let all = ConversationProvider::list_metadata(&pi, "/tmp/p").unwrap();
1233 assert_eq!(all.len(), 2);
1234 assert_eq!(all[0].id, "newer");
1236 }
1237
1238 #[test]
1239 fn test_delegation_builds_delegated_work() {
1240 let a = assistant_entry(
1241 "a",
1242 None,
1243 vec![ContentBlock::ToolCall {
1244 id: "d1".into(),
1245 name: "Task".into(),
1246 arguments: json!({"prompt": "do the thing"}),
1247 extra: HashMap::new(),
1248 }],
1249 usage(1, 1),
1250 StopReason::Known(KnownStopReason::ToolUse),
1251 "m",
1252 );
1253 let v = session_to_view(&session_from(vec![a], "/tmp/p"));
1254 assert_eq!(v.turns[0].delegations.len(), 1);
1255 assert_eq!(v.turns[0].delegations[0].prompt, "do the thing");
1256 assert_eq!(v.turns[0].delegations[0].agent_id, "d1");
1257 }
1258
1259 #[test]
1260 fn test_stop_reason_string_form() {
1261 let a = assistant_entry(
1262 "a",
1263 None,
1264 vec![],
1265 usage(1, 1),
1266 StopReason::Known(KnownStopReason::ToolUse),
1267 "m",
1268 );
1269 let v = session_to_view(&session_from(vec![a], "/tmp/p"));
1270 let sr = v.turns[0].stop_reason.as_deref().unwrap();
1271 assert!(sr.to_lowercase().contains("tool"), "got: {}", sr);
1272 }
1273
1274 #[test]
1275 fn test_custom_message_becomes_other_role_turn() {
1276 let cm = Entry::CustomMessage {
1277 base: base("cm", None, "t"),
1278 custom_type: "foo".into(),
1279 content: MessageContent::Text("body".into()),
1280 display: true,
1281 details: None,
1282 extra: HashMap::new(),
1283 };
1284 let v = session_to_view(&session_from(vec![cm], "/tmp/p"));
1285 assert_eq!(v.turns[0].role, Role::Other("custom:foo".to_string()));
1286 assert_eq!(v.turns[0].text, "body");
1287 }
1288}