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