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