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