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