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