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