Skip to main content

toolpath_pi/
provider.rs

1//! ConversationProvider bridge: map Pi sessions to `toolpath_convo::ConversationView`.
2//!
3//! Walks `PiSession.entries` in file order. Each `Entry::Message` becomes a
4//! `Turn`; metadata-only entries like `ModelChange` / `ThinkingLevelChange` /
5//! `Label` buffer and attach to the next message's `extra["pi"]`. `Compaction`,
6//! `BranchSummary`, `Custom`, and `CustomMessage` emit synthetic turns with
7//! appropriate roles.
8//!
9//! Tool-result correlation is a two-pass process: we record tool-call ids as
10//! assistant turns are built, then in a second pass populate matching tool
11//! invocations' `.result` fields (and any sibling `DelegatedWork.result`).
12
13use 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
28// ── Classification helpers ───────────────────────────────────────────
29
30/// Classify a Pi tool name into toolpath's category ontology.
31///
32/// Pi tool names are case-insensitive in the wild; we lowercase before
33/// matching and let names containing `task` / `agent` collapse to
34/// [`ToolCategory::Delegation`]. Unknown names return `None`.
35pub 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
50/// Reverse of [`classify_tool`]: pick Pi's preferred native tool name
51/// for a generic [`ToolCategory`], disambiguating by call args when
52/// multiple Pi tools share the same category.
53///
54/// Used by [`crate::project::PiProjector`] when projecting tool calls
55/// from foreign harnesses (Claude, Gemini, etc.) — we know the
56/// category from the source's classifier and need to pick a Pi name
57/// whose arg shape matches the call's actual args. Returns `None` for
58/// categories with no obvious Pi analog.
59pub 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        // Edit-shape calls carry old_string/new_string (or `edits[]` for
69        // multi-edit); whole-file writes carry `content`. Pi has both
70        // `write` and `edit`; pick `edit` for in-place mutations.
71        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
174/// Pi's wire requires a `usage` object on every assistant message, so
175/// foreign-source projections fill it with zeros when the spend is
176/// unknown. A real API message can never cost zero tokens, so an
177/// all-zero `usage` decodes as "no usage recorded", not `Some(zeros)`.
178fn 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        // Same absence rule as the cache fields: the wire can't express
184        // "unknown", so a zero written by a foreign-source projection decodes
185        // back to `None` (a real API message never has zero input tokens).
186        // Output stays as-is — it's the field pi genuinely reports.
187        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
224// ── Main conversion ──────────────────────────────────────────────────
225
226/// Convert a PiSession into a provider-agnostic ConversationView.
227pub fn session_to_view(session: &PiSession) -> ConversationView {
228    let env = environment_for(session);
229
230    // Two-pass strategy:
231    //  Pass 1: walk entries, emit turns. Track tool-call invocation locations
232    //          (turn_idx, tool_idx) by id for later correlation.
233    //  Pass 2: walk turns again for tool-result roles; find the matching
234    //          invocation by id and populate `.result` (and any delegation
235    //          result).
236    let mut turns: Vec<Turn> = Vec::new();
237    // Map tool-call id → (turn_idx, tool_idx).
238    let mut tool_call_locs: HashMap<String, (usize, usize)> = HashMap::new();
239    // Map tool-call id → delegation index within the turn (if any).
240    let mut delegation_locs: HashMap<String, (usize, usize)> = HashMap::new();
241    // Per-turn tool-result info: (tool_call_id, content, is_error).
242    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                // Discarded — these influence rendering only and don't map onto
250                // a cross-harness IR field.
251            }
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 results fold onto the matching assistant
408                        // turn's `tool_uses[i].result` via pass 2. We don't
409                        // emit them as standalone turns — that mirrors how
410                        // claude/gemini/codex/opencode derive treats tool
411                        // results, and keeps Pi → Pi idempotent without
412                        // smuggling tool_call_id through Turn.extra.
413                        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                        // Synthetic ToolInvocation representing the bash run itself.
432                        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    // Pass 2: tool-result correlation.
481    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    // Aggregate token usage from Assistant turns.
500    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    // files_changed: dedup-in-order from FileWrite tool inputs.
520    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    // session_ids: walk parent chain, oldest first.
534    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
570// ── ConversationProvider impl for PiConvo ────────────────────────────
571
572fn 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        // Without reading the full file we can't distinguish; use header ts.
620        last_activity: ts,
621        // `entry_count` counts all non-header entries (including non-message
622        // metadata). Treat it as an approximation of message_count.
623        message_count: meta.entry_count,
624        file_path: Some(meta.file_path),
625        predecessor: None,
626        successor: None,
627    }
628}
629
630// ── Tests ────────────────────────────────────────────────────────────
631
632#[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        // Pi's wire requires `usage`; foreign projections zero-fill it
647        // when spend is unknown. Zero is not a real spend.
648        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        // A ToolResult entry without a matching assistant turn folds
928        // into nothing — the IR doesn't model standalone tool turns.
929        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    // ── Provider tests ───────────────────────────────────────────────
1165
1166    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        // Newest first (via mtime)
1243        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}