Skip to main content

toolpath_pi/
project.rs

1//! [`PiProjector`] — maps a [`ConversationView`] back to a Pi
2//! [`PiSession`].
3//!
4//! This is the inverse of [`crate::provider::session_to_view`]: where
5//! `session_to_view` reads a Pi JSONL session into a provider-agnostic
6//! view, `PiProjector` serializes that view back into Pi's on-disk
7//! shape (a [`SessionHeader`] plus a list of [`Entry`]).
8//!
9//! The projector consumes provider-specific data the forward path
10//! stashed under `Turn.extra["pi"]` — `api`/`provider`, `stopReason`,
11//! `toolCallId`, bash-execution metadata, custom-message markers, and
12//! synthetic-turn structures (`compaction`, `branchSummary`, `custom`,
13//! `customMessage`). For `ConversationView`s from non-Pi sources, the
14//! projector synthesizes sensible defaults (api: "anthropic",
15//! stop_reason: "stop", etc.).
16//!
17//! Foreign-namespace extras (`Turn.extra["claude"]`,
18//! `Turn.extra["gemini"]`, …) are dropped — they have no meaning in
19//! Pi's format and would pollute the JSONL.
20
21use std::collections::HashMap;
22
23use serde_json::{Map, Value, json};
24use toolpath_convo::{
25    ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
26};
27
28use crate::reader::PiSession;
29use crate::types::{
30    AgentMessage, ContentBlock, CostBreakdown, Entry, EntryBase, KnownStopReason, MessageContent,
31    SessionHeader, StopReason, ToolResultContent, Usage,
32};
33
34// ── PiProjector ───────────────────────────────────────────────────────
35
36/// Project a [`ConversationView`] into a Pi [`PiSession`].
37///
38/// Config fields are optional. `cwd` overrides the source view's
39/// working directory (which is otherwise pulled from
40/// `Turn.environment.working_dir`). Default API metadata fills in
41/// `api`/`provider` for assistant turns coming from a non-Pi source.
42///
43/// # Example
44///
45/// ```rust
46/// use toolpath_convo::{ConversationProjector, ConversationView};
47/// use toolpath_pi::project::PiProjector;
48///
49/// let view = ConversationView {
50///     id: "session-uuid".into(),
51///     provider_id: Some("pi".into()),
52///     ..Default::default()
53/// };
54///
55/// let session = PiProjector::default().project(&view).unwrap();
56/// assert_eq!(session.header.id, "session-uuid");
57/// ```
58#[derive(Debug, Clone, Default)]
59pub struct PiProjector {
60    /// Override the session header's `cwd`. When `None`, the projector
61    /// pulls it from the first turn's environment (or falls back to
62    /// `"/"` if absent).
63    pub cwd: Option<String>,
64    /// Default `api` field for assistant turns when not present in
65    /// `Turn.extra["pi"]["api"]`. Defaults to `"anthropic"`.
66    pub default_api: Option<String>,
67    /// Default `provider` field for assistant turns when not present
68    /// in `Turn.extra["pi"]["api"]`. Defaults to `"anthropic"`.
69    pub default_provider: Option<String>,
70}
71
72impl PiProjector {
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
78        self.cwd = Some(cwd.into());
79        self
80    }
81
82    pub fn with_default_api(mut self, api: impl Into<String>) -> Self {
83        self.default_api = Some(api.into());
84        self
85    }
86
87    pub fn with_default_provider(mut self, provider: impl Into<String>) -> Self {
88        self.default_provider = Some(provider.into());
89        self
90    }
91}
92
93impl ConversationProjector for PiProjector {
94    type Output = PiSession;
95
96    fn project(&self, view: &ConversationView) -> Result<PiSession> {
97        project_view(self, view).map_err(ConvoError::Provider)
98    }
99}
100
101// ── Projection logic ─────────────────────────────────────────────────
102
103fn project_view(
104    cfg: &PiProjector,
105    view: &ConversationView,
106) -> std::result::Result<PiSession, String> {
107    let cwd = cfg
108        .cwd
109        .clone()
110        .or_else(|| {
111            view.turns
112                .iter()
113                .find_map(|t| t.environment.as_ref()?.working_dir.clone())
114        })
115        .unwrap_or_else(|| "/".to_string());
116
117    let timestamp = view
118        .started_at
119        .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
120        .or_else(|| view.turns.first().map(|t| t.timestamp.clone()))
121        .unwrap_or_default();
122
123    // Pi's session header optionally carries `parentSession` — the
124    // forward path stashed it on the first turn's extras. Round-trip
125    // it when present.
126    let parent_session = view
127        .turns
128        .first()
129        .and_then(|t| pi_extras(t))
130        .and_then(|pi| pi.get("parentSession").and_then(Value::as_str))
131        .map(str::to_string);
132
133    let header = SessionHeader {
134        version: 3,
135        id: view.id.clone(),
136        timestamp,
137        cwd,
138        parent_session,
139        extra: HashMap::new(),
140    };
141
142    let mut entries: Vec<Entry> = Vec::new();
143    entries.push(Entry::Session(header.clone()));
144
145    // Pre-pass: collect tool_call_ids that are *already* covered by a
146    // dedicated `Role::Other("tool")` turn elsewhere in the view. For
147    // those, we must NOT synthesize an extra tool-result entry from
148    // the owning assistant's `tool_uses[i].result` — otherwise we'd
149    // emit two `toolResult` entries for the same call (which is what
150    // a Pi → View → Pi round-trip would produce, since forward path
151    // both populates `tool_uses[i].result` AND keeps the original
152    // tool-result message as a separate turn).
153    let covered: std::collections::HashSet<String> = view
154        .turns
155        .iter()
156        .filter(|t| matches!(t.role, Role::Other(ref s) if s == "tool"))
157        .filter_map(|t| {
158            pi_extras(t)
159                .and_then(|m| m.get("toolCallId"))
160                .and_then(Value::as_str)
161                .map(str::to_string)
162        })
163        .collect();
164
165    for turn in &view.turns {
166        let pi = pi_extras(turn).cloned().unwrap_or_default();
167        emit_pending_meta(&mut entries, turn, &pi);
168        emit_turn_entries(cfg, turn, &pi, &covered, &mut entries);
169    }
170
171    Ok(PiSession {
172        header,
173        entries,
174        file_path: std::path::PathBuf::new(),
175        parent: None,
176    })
177}
178
179/// Used to return `Turn.extra["pi"]`; the IR no longer carries
180/// provider-namespaced extras. Always `None`. Callers fall back to
181/// reconstructing source-format details from typed IR fields and
182/// reasonable defaults.
183fn pi_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
184    None
185}
186
187/// Emit `ModelChange` / `ThinkingLevelChange` / `Label` entries that the
188/// forward path buffered into the next turn's `extra["pi"]`.
189fn emit_pending_meta(entries: &mut Vec<Entry>, turn: &Turn, pi: &Map<String, Value>) {
190    if let Some(mc) = pi.get("modelChange").and_then(Value::as_object) {
191        let id = mc
192            .get("id")
193            .and_then(Value::as_str)
194            .map(str::to_string)
195            .unwrap_or_else(|| format!("{}-mc", turn.id));
196        let timestamp = mc
197            .get("timestamp")
198            .and_then(Value::as_str)
199            .map(str::to_string)
200            .unwrap_or_else(|| turn.timestamp.clone());
201        let provider = mc
202            .get("provider")
203            .and_then(Value::as_str)
204            .unwrap_or("")
205            .to_string();
206        let model_id = mc
207            .get("modelId")
208            .and_then(Value::as_str)
209            .unwrap_or("")
210            .to_string();
211        entries.push(Entry::ModelChange {
212            base: EntryBase {
213                id,
214                parent_id: None,
215                timestamp,
216            },
217            provider,
218            model_id,
219            extra: extra_map_from(mc.get("rawExtra")),
220        });
221    }
222    if let Some(tlc) = pi.get("thinkingLevelChange").and_then(Value::as_object) {
223        let id = tlc
224            .get("id")
225            .and_then(Value::as_str)
226            .map(str::to_string)
227            .unwrap_or_else(|| format!("{}-tlc", turn.id));
228        let timestamp = tlc
229            .get("timestamp")
230            .and_then(Value::as_str)
231            .map(str::to_string)
232            .unwrap_or_else(|| turn.timestamp.clone());
233        let thinking_level = tlc
234            .get("thinkingLevel")
235            .and_then(Value::as_str)
236            .unwrap_or("")
237            .to_string();
238        entries.push(Entry::ThinkingLevelChange {
239            base: EntryBase {
240                id,
241                parent_id: None,
242                timestamp,
243            },
244            thinking_level,
245            extra: extra_map_from(tlc.get("rawExtra")),
246        });
247    }
248    if let Some(labels) = pi.get("labels").and_then(Value::as_array) {
249        for (i, label) in labels.iter().enumerate() {
250            let lo = label.as_object();
251            let id = lo
252                .and_then(|m| m.get("id"))
253                .and_then(Value::as_str)
254                .map(str::to_string)
255                .unwrap_or_else(|| format!("{}-lbl-{}", turn.id, i));
256            let timestamp = lo
257                .and_then(|m| m.get("timestamp"))
258                .and_then(Value::as_str)
259                .map(str::to_string)
260                .unwrap_or_else(|| turn.timestamp.clone());
261            let extra = extra_map_from(lo.and_then(|m| m.get("rawExtra")));
262            entries.push(Entry::Label {
263                base: EntryBase {
264                    id,
265                    parent_id: None,
266                    timestamp,
267                },
268                extra,
269            });
270        }
271    }
272}
273
274/// Emit the entry (or entries) corresponding to a single turn's role
275/// and content. Most turns produce a single `Entry::Message`; a turn
276/// with assistant-side tool calls that have results produces both the
277/// assistant message AND one tool-result message per result.
278fn emit_turn_entries(
279    cfg: &PiProjector,
280    turn: &Turn,
281    pi: &Map<String, Value>,
282    covered_tool_ids: &std::collections::HashSet<String>,
283    entries: &mut Vec<Entry>,
284) {
285    // Synthetic compaction / branch_summary / custom turns map to
286    // their own Entry variants rather than `Entry::Message`.
287    if let Some(comp) = pi.get("compaction").and_then(Value::as_object) {
288        emit_compaction(turn, comp, entries);
289        return;
290    }
291    if let Some(bs) = pi.get("branchSummary").and_then(Value::as_object) {
292        emit_branch_summary(turn, bs, entries);
293        return;
294    }
295    if let Some(c) = pi.get("custom").and_then(Value::as_object) {
296        emit_custom(turn, c, entries);
297        return;
298    }
299    if let Some(cm) = pi.get("customMessage").and_then(Value::as_object) {
300        emit_custom_message(turn, cm, entries);
301        return;
302    }
303
304    match &turn.role {
305        Role::User => emit_user(turn, entries),
306        Role::Assistant => emit_assistant(cfg, turn, pi, covered_tool_ids, entries),
307        Role::System => {
308            // System turns from non-Pi sources don't have a direct
309            // analog; fold them into a custom-system message.
310            emit_system_as_custom(turn, entries);
311        }
312        Role::Other(other) => match other.as_str() {
313            "tool" => emit_tool_result(turn, pi, entries),
314            "bash" => emit_bash_execution(turn, pi, entries),
315            o if o.starts_with("custom:") => {
316                let custom_type = o.strip_prefix("custom:").unwrap_or(o).to_string();
317                emit_custom_role_message(turn, &custom_type, entries);
318            }
319            _ => {
320                // Unknown role — best-effort: store as user-role custom
321                // message so the text survives in the log.
322                emit_custom_role_message(turn, other, entries);
323            }
324        },
325    }
326}
327
328fn emit_user(turn: &Turn, entries: &mut Vec<Entry>) {
329    let content = MessageContent::Text(turn.text.clone());
330    let timestamp = ts_millis(&turn.timestamp);
331    entries.push(Entry::Message {
332        base: base_for(turn),
333        message: AgentMessage::User {
334            content,
335            timestamp,
336            extra: HashMap::new(),
337        },
338        extra: HashMap::new(),
339    });
340}
341
342fn emit_assistant(
343    cfg: &PiProjector,
344    turn: &Turn,
345    pi: &Map<String, Value>,
346    covered_tool_ids: &std::collections::HashSet<String>,
347    entries: &mut Vec<Entry>,
348) {
349    // Build the content blocks: optional thinking, then text, then
350    // each tool call. Real Pi assistant turns interleave these in
351    // arbitrary order, but for projection a thinking-then-text-then-
352    // tool-calls layout reads cleanly.
353    let mut blocks: Vec<ContentBlock> = Vec::new();
354    if let Some(t) = &turn.thinking
355        && !t.is_empty()
356    {
357        blocks.push(ContentBlock::Thinking {
358            thinking: t.clone(),
359            extra: HashMap::new(),
360        });
361    }
362    if !turn.text.is_empty() {
363        blocks.push(ContentBlock::Text {
364            text: turn.text.clone(),
365            extra: HashMap::new(),
366        });
367    }
368    for tu in &turn.tool_uses {
369        blocks.push(ContentBlock::ToolCall {
370            id: tu.id.clone(),
371            name: tool_native_name(tu),
372            arguments: tu.input.clone(),
373            extra: HashMap::new(),
374        });
375    }
376
377    let api_obj = pi.get("api").and_then(Value::as_object);
378    let api = api_obj
379        .and_then(|m| m.get("api"))
380        .and_then(Value::as_str)
381        .map(str::to_string)
382        .unwrap_or_else(|| {
383            cfg.default_api
384                .clone()
385                .unwrap_or_else(|| "anthropic".to_string())
386        });
387    let provider = api_obj
388        .and_then(|m| m.get("provider"))
389        .and_then(Value::as_str)
390        .map(str::to_string)
391        .unwrap_or_else(|| {
392            cfg.default_provider
393                .clone()
394                .unwrap_or_else(|| "anthropic".to_string())
395        });
396    let model = turn.model.clone().unwrap_or_default();
397    let usage = build_usage(turn);
398    let stop_reason = parse_stop_reason(turn.stop_reason.as_deref(), pi.get("stopReason"));
399    let error_message = pi
400        .get("errorMessage")
401        .and_then(Value::as_str)
402        .map(str::to_string);
403    let timestamp = ts_millis(&turn.timestamp);
404
405    let assistant_id = turn.id.clone();
406    let assistant_parent = turn.parent_id.clone();
407
408    entries.push(Entry::Message {
409        base: EntryBase {
410            id: assistant_id.clone(),
411            parent_id: assistant_parent,
412            timestamp: turn.timestamp.clone(),
413        },
414        message: AgentMessage::Assistant {
415            content: blocks,
416            api,
417            provider,
418            model,
419            usage,
420            stop_reason,
421            error_message,
422            timestamp,
423            extra: HashMap::new(),
424        },
425        extra: HashMap::new(),
426    });
427
428    // Each tool invocation with a result produces a separate
429    // `toolResult` entry parented to the assistant entry, mirroring
430    // how Pi separates calls from results in the JSONL stream. Skip
431    // calls that are already covered by an explicit `Role::Other(
432    // "tool")` turn elsewhere in the view (Pi → View → Pi sources
433    // have both forms; emitting both would duplicate the result).
434    let mut prev_id = assistant_id;
435    let mut suffix = 0usize;
436    for tu in &turn.tool_uses {
437        let Some(result) = &tu.result else { continue };
438        if covered_tool_ids.contains(&tu.id) {
439            continue;
440        }
441        suffix += 1;
442        let tr_id = format!("{}-tr-{}", turn.id, suffix);
443        let entry = Entry::Message {
444            base: EntryBase {
445                id: tr_id.clone(),
446                parent_id: Some(prev_id.clone()),
447                timestamp: turn.timestamp.clone(),
448            },
449            message: AgentMessage::ToolResult {
450                tool_call_id: tu.id.clone(),
451                tool_name: tool_native_name(tu),
452                content: vec![ToolResultContent::Text {
453                    text: result.content.clone(),
454                    extra: HashMap::new(),
455                }],
456                details: None,
457                is_error: result.is_error,
458                timestamp: ts_millis(&turn.timestamp),
459                extra: HashMap::new(),
460            },
461            extra: HashMap::new(),
462        };
463        entries.push(entry);
464        prev_id = tr_id;
465    }
466}
467
468fn emit_tool_result(turn: &Turn, pi: &Map<String, Value>, entries: &mut Vec<Entry>) {
469    let tool_call_id = pi
470        .get("toolCallId")
471        .and_then(Value::as_str)
472        .map(str::to_string)
473        .unwrap_or_default();
474    let tool_name = pi
475        .get("toolName")
476        .and_then(Value::as_str)
477        .map(str::to_string)
478        .unwrap_or_default();
479    let is_error = pi.get("isError").and_then(Value::as_bool).unwrap_or(false);
480    let details = pi.get("details").cloned();
481    let content = vec![ToolResultContent::Text {
482        text: turn.text.clone(),
483        extra: HashMap::new(),
484    }];
485    entries.push(Entry::Message {
486        base: base_for(turn),
487        message: AgentMessage::ToolResult {
488            tool_call_id,
489            tool_name,
490            content,
491            details,
492            is_error,
493            timestamp: ts_millis(&turn.timestamp),
494            extra: HashMap::new(),
495        },
496        extra: HashMap::new(),
497    });
498}
499
500fn emit_bash_execution(turn: &Turn, pi: &Map<String, Value>, entries: &mut Vec<Entry>) {
501    let command = pi
502        .get("command")
503        .and_then(Value::as_str)
504        .map(str::to_string)
505        .unwrap_or_default();
506    let exit_code = pi.get("exitCode").and_then(Value::as_i64);
507    let cancelled = pi
508        .get("cancelled")
509        .and_then(Value::as_bool)
510        .unwrap_or(false);
511    let truncated = pi
512        .get("truncated")
513        .and_then(Value::as_bool)
514        .unwrap_or(false);
515    let full_output_path = pi
516        .get("fullOutputPath")
517        .and_then(Value::as_str)
518        .map(str::to_string);
519
520    // The forward path put `$ <command>\n<truncated_output>` in
521    // turn.text; if we can recover the output, we use it. Otherwise
522    // we strip the leading `$ <command>\n` to get the output.
523    let output = if let Some(rest) = turn
524        .text
525        .strip_prefix(&format!("$ {}\n", command))
526        .map(str::to_string)
527    {
528        rest.trim_end_matches("…(truncated)").to_string()
529    } else {
530        turn.text.clone()
531    };
532
533    entries.push(Entry::Message {
534        base: base_for(turn),
535        message: AgentMessage::BashExecution {
536            command,
537            output,
538            exit_code,
539            cancelled,
540            truncated,
541            full_output_path,
542            exclude_from_context: None,
543            timestamp: ts_millis(&turn.timestamp),
544            extra: HashMap::new(),
545        },
546        extra: HashMap::new(),
547    });
548}
549
550fn emit_compaction(turn: &Turn, comp: &Map<String, Value>, entries: &mut Vec<Entry>) {
551    let summary = comp
552        .get("summary")
553        .and_then(Value::as_str)
554        .map(str::to_string)
555        .unwrap_or_else(|| {
556            // Fall back to extracting from the text the forward path
557            // wrote ("Compacted (summary): X").
558            turn.text
559                .strip_prefix("Compacted (summary): ")
560                .unwrap_or(&turn.text)
561                .to_string()
562        });
563    let first_kept_entry_id = comp
564        .get("firstKeptEntryId")
565        .and_then(Value::as_str)
566        .map(str::to_string)
567        .unwrap_or_default();
568    let tokens_before = comp
569        .get("tokensBefore")
570        .and_then(Value::as_u64)
571        .unwrap_or(0);
572    let details = comp.get("details").cloned();
573    let from_hook = comp.get("fromHook").and_then(Value::as_bool);
574    entries.push(Entry::Compaction {
575        base: base_for(turn),
576        summary,
577        first_kept_entry_id,
578        tokens_before,
579        details,
580        from_hook,
581        extra: HashMap::new(),
582    });
583}
584
585fn emit_branch_summary(turn: &Turn, bs: &Map<String, Value>, entries: &mut Vec<Entry>) {
586    let from_id = bs
587        .get("fromId")
588        .and_then(Value::as_str)
589        .map(str::to_string)
590        .unwrap_or_default();
591    let summary = turn
592        .text
593        .strip_prefix("Branch summary: ")
594        .unwrap_or(&turn.text)
595        .to_string();
596    let details = bs.get("details").cloned();
597    let from_hook = bs.get("fromHook").and_then(Value::as_bool);
598    entries.push(Entry::BranchSummary {
599        base: base_for(turn),
600        from_id,
601        summary,
602        details,
603        from_hook,
604        extra: HashMap::new(),
605    });
606}
607
608fn emit_custom(turn: &Turn, c: &Map<String, Value>, entries: &mut Vec<Entry>) {
609    let custom_type = c
610        .get("customType")
611        .and_then(Value::as_str)
612        .map(str::to_string)
613        .unwrap_or_else(|| "custom".to_string());
614    let data = c
615        .get("data")
616        .and_then(|v| v.as_object().cloned())
617        .unwrap_or_default();
618    entries.push(Entry::Custom {
619        base: base_for(turn),
620        custom_type,
621        data,
622        extra: HashMap::new(),
623    });
624}
625
626fn emit_custom_message(turn: &Turn, cm: &Map<String, Value>, entries: &mut Vec<Entry>) {
627    let custom_type = cm
628        .get("customType")
629        .and_then(Value::as_str)
630        .map(str::to_string)
631        .unwrap_or_else(|| "custom".to_string());
632    let display = cm.get("display").and_then(Value::as_bool).unwrap_or(true);
633    let details = cm.get("details").cloned();
634    let content = MessageContent::Text(turn.text.clone());
635    entries.push(Entry::CustomMessage {
636        base: base_for(turn),
637        custom_type,
638        content,
639        display,
640        details,
641        extra: HashMap::new(),
642    });
643}
644
645fn emit_custom_role_message(turn: &Turn, custom_type: &str, entries: &mut Vec<Entry>) {
646    let timestamp = ts_millis(&turn.timestamp);
647    entries.push(Entry::Message {
648        base: base_for(turn),
649        message: AgentMessage::Custom {
650            custom_type: custom_type.to_string(),
651            content: MessageContent::Text(turn.text.clone()),
652            display: true,
653            details: None,
654            timestamp,
655            extra: HashMap::new(),
656        },
657        extra: HashMap::new(),
658    });
659}
660
661fn emit_system_as_custom(turn: &Turn, entries: &mut Vec<Entry>) {
662    emit_custom_role_message(turn, "system", entries);
663}
664
665// ── Helpers ──────────────────────────────────────────────────────────
666
667fn base_for(turn: &Turn) -> EntryBase {
668    EntryBase {
669        id: turn.id.clone(),
670        parent_id: turn.parent_id.clone(),
671        timestamp: turn.timestamp.clone(),
672    }
673}
674
675/// Convert an RFC3339 timestamp to Pi's `timestamp: u64` (epoch ms on
676/// the inner message). Returns `0` if the timestamp is unparseable —
677/// non-fatal since the outer `EntryBase.timestamp` keeps the original
678/// string.
679fn ts_millis(rfc3339: &str) -> u64 {
680    chrono::DateTime::parse_from_rfc3339(rfc3339)
681        .map(|dt| dt.timestamp_millis().max(0) as u64)
682        .unwrap_or(0)
683}
684
685/// Build a `Usage` from `Turn.token_usage` and any `pi.cost` extras.
686/// Non-Pi sources won't have cost information; default to zeros.
687fn build_usage(turn: &Turn) -> Usage {
688    let (input, output, cache_read, cache_write) = turn
689        .token_usage
690        .as_ref()
691        .map(|u| {
692            (
693                u.input_tokens.unwrap_or(0) as u64,
694                u.output_tokens.unwrap_or(0) as u64,
695                u.cache_read_tokens.unwrap_or(0) as u64,
696                u.cache_write_tokens.unwrap_or(0) as u64,
697            )
698        })
699        .unwrap_or((0, 0, 0, 0));
700    let total_tokens = input + output;
701    Usage {
702        input,
703        output,
704        cache_read,
705        cache_write,
706        total_tokens,
707        cost: CostBreakdown::default(),
708    }
709}
710
711/// Resolve the assistant's `stopReason`. Prefer the structured
712/// `pi.stopReason` (preserves any Pi-specific values verbatim); fall
713/// back to `Turn.stop_reason` (a string), then to `Stop`.
714fn parse_stop_reason(turn_stop: Option<&str>, pi_stop: Option<&Value>) -> StopReason {
715    if let Some(v) = pi_stop
716        && let Ok(sr) = serde_json::from_value::<StopReason>(v.clone())
717    {
718        return sr;
719    }
720    let s = turn_stop.unwrap_or("stop");
721    serde_json::from_value::<StopReason>(json!(s))
722        .unwrap_or(StopReason::Known(KnownStopReason::Stop))
723}
724
725/// Pick Pi's native tool name.
726///
727/// If the source tool has a category, route it through `native_name`
728/// to land on Pi's canonical lowercase name (`bash`, `read`, `edit`,
729/// etc.). This handles both same-harness pass-through (Pi's `read`
730/// stays `read`) and cross-harness remapping (Claude's `Bash` becomes
731/// `bash`). When the category is unknown, fall through to the source
732/// name verbatim — Pi's format accepts any string here, so a custom
733/// MCP tool name passes through cleanly.
734fn tool_native_name(tu: &ToolInvocation) -> String {
735    if let Some(cat) = tu.category
736        && let Some(remap) = crate::provider::native_name(cat, &tu.input)
737    {
738        return remap.to_string();
739    }
740    tu.name.clone()
741}
742
743/// Coerce a `Value` (expected to be a map) into Pi's `extra:
744/// HashMap<String, Value>` shape.
745fn extra_map_from(v: Option<&Value>) -> HashMap<String, Value> {
746    match v {
747        Some(Value::Object(m)) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
748        _ => HashMap::new(),
749    }
750}
751
752// ── Tests ─────────────────────────────────────────────────────────────
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use toolpath_convo::{TokenUsage, ToolCategory, ToolInvocation, ToolResult};
758
759    fn user_turn(id: &str, text: &str) -> Turn {
760        Turn {
761            id: id.into(),
762            parent_id: None,
763            role: Role::User,
764            timestamp: "2026-04-16T10:00:00Z".into(),
765            text: text.into(),
766            thinking: None,
767            tool_uses: vec![],
768            model: None,
769            stop_reason: None,
770            token_usage: None,
771            environment: None,
772            delegations: vec![],
773            file_mutations: Vec::new(),
774        }
775    }
776
777    fn assistant_turn(id: &str, text: &str) -> Turn {
778        Turn {
779            id: id.into(),
780            parent_id: None,
781            role: Role::Assistant,
782            timestamp: "2026-04-16T10:00:01Z".into(),
783            text: text.into(),
784            thinking: None,
785            tool_uses: vec![],
786            model: Some("claude-sonnet-4-5".into()),
787            stop_reason: Some("stop".into()),
788            token_usage: Some(TokenUsage {
789                input_tokens: Some(100),
790                output_tokens: Some(50),
791                cache_read_tokens: None,
792                cache_write_tokens: None,
793            }),
794            environment: None,
795            delegations: vec![],
796            file_mutations: Vec::new(),
797        }
798    }
799
800    fn view_with(turns: Vec<Turn>) -> ConversationView {
801        ConversationView {
802            id: "session-uuid".into(),
803            started_at: None,
804            last_activity: None,
805            turns,
806            total_usage: None,
807            provider_id: Some("pi".into()),
808            files_changed: vec![],
809            session_ids: vec![],
810            events: vec![],
811            ..Default::default()
812        }
813    }
814
815    #[test]
816    fn test_empty_view_projects_session_with_just_header() {
817        let session = PiProjector::default().project(&view_with(vec![])).unwrap();
818        assert_eq!(session.header.id, "session-uuid");
819        // Just the session header, no message entries.
820        assert_eq!(session.entries.len(), 1);
821        assert!(matches!(session.entries[0], Entry::Session(_)));
822    }
823
824    #[test]
825    fn test_user_turn_becomes_user_message() {
826        let session = PiProjector::default()
827            .project(&view_with(vec![user_turn("u1", "hello")]))
828            .unwrap();
829        assert_eq!(session.entries.len(), 2);
830        match &session.entries[1] {
831            Entry::Message {
832                base,
833                message: AgentMessage::User { content, .. },
834                ..
835            } => {
836                assert_eq!(base.id, "u1");
837                match content {
838                    MessageContent::Text(t) => assert_eq!(t, "hello"),
839                    other => panic!("expected Text, got {:?}", other),
840                }
841            }
842            other => panic!("expected User message, got {:?}", other),
843        }
844    }
845
846    #[test]
847    fn test_assistant_turn_with_tool_call_and_result() {
848        let mut t = assistant_turn("a1", "I'll read it.");
849        t.tool_uses = vec![ToolInvocation {
850            id: "tc1".into(),
851            name: "read".into(),
852            input: serde_json::json!({"path": "x.rs"}),
853            result: Some(ToolResult {
854                content: "fn main(){}".into(),
855                is_error: false,
856            }),
857            category: Some(ToolCategory::FileRead),
858        }];
859        let session = PiProjector::default().project(&view_with(vec![t])).unwrap();
860        // session header + assistant + tool-result = 3 entries
861        assert_eq!(session.entries.len(), 3);
862        match &session.entries[1] {
863            Entry::Message {
864                message: AgentMessage::Assistant { content, .. },
865                ..
866            } => {
867                // text + tool call = 2 blocks
868                assert_eq!(content.len(), 2);
869                assert!(
870                    matches!(&content[0], ContentBlock::Text { text, .. } if text == "I'll read it.")
871                );
872                assert!(
873                    matches!(&content[1], ContentBlock::ToolCall { id, name, .. } if id == "tc1" && name == "read")
874                );
875            }
876            other => panic!("expected Assistant, got {:?}", other),
877        }
878        match &session.entries[2] {
879            Entry::Message {
880                message:
881                    AgentMessage::ToolResult {
882                        tool_call_id,
883                        tool_name,
884                        content,
885                        is_error,
886                        ..
887                    },
888                ..
889            } => {
890                assert_eq!(tool_call_id, "tc1");
891                assert_eq!(tool_name, "read");
892                assert!(!is_error);
893                assert_eq!(content.len(), 1);
894                let ToolResultContent::Text { text, .. } = &content[0] else {
895                    panic!("expected text content");
896                };
897                assert_eq!(text, "fn main(){}");
898            }
899            other => panic!("expected ToolResult, got {:?}", other),
900        }
901    }
902
903    #[test]
904    fn test_foreign_tool_name_remaps_via_category() {
905        // Claude's `Bash` should land as Pi's `bash` because the category
906        // routes it through `native_name(Shell, _)`.
907        let mut t = assistant_turn("a1", "");
908        t.tool_uses = vec![ToolInvocation {
909            id: "tc1".into(),
910            name: "Bash".into(),
911            input: serde_json::json!({"command": "ls"}),
912            result: None,
913            category: Some(ToolCategory::Shell),
914        }];
915        let session = PiProjector::default().project(&view_with(vec![t])).unwrap();
916        match &session.entries[1] {
917            Entry::Message {
918                message: AgentMessage::Assistant { content, .. },
919                ..
920            } => match &content[0] {
921                ContentBlock::ToolCall { name, .. } => assert_eq!(name, "bash"),
922                other => panic!("expected ToolCall, got {:?}", other),
923            },
924            other => panic!("expected Assistant, got {:?}", other),
925        }
926    }
927
928    #[test]
929    fn test_assistant_thinking_becomes_thinking_block() {
930        let mut t = assistant_turn("a1", "Done.");
931        t.thinking = Some("hmm".into());
932        let session = PiProjector::default().project(&view_with(vec![t])).unwrap();
933        match &session.entries[1] {
934            Entry::Message {
935                message: AgentMessage::Assistant { content, .. },
936                ..
937            } => {
938                assert_eq!(content.len(), 2);
939                assert!(
940                    matches!(&content[0], ContentBlock::Thinking { thinking, .. } if thinking == "hmm")
941                );
942                assert!(matches!(&content[1], ContentBlock::Text { text, .. } if text == "Done."));
943            }
944            _ => panic!("expected Assistant"),
945        }
946    }
947
948    #[test]
949    fn test_session_header_uses_view_id_and_first_turn_cwd() {
950        use toolpath_convo::EnvironmentSnapshot;
951        let mut t = user_turn("u1", "hi");
952        t.environment = Some(EnvironmentSnapshot {
953            working_dir: Some("/tmp/proj".into()),
954            vcs_branch: None,
955            vcs_revision: None,
956        });
957        let session = PiProjector::default().project(&view_with(vec![t])).unwrap();
958        assert_eq!(session.header.cwd, "/tmp/proj");
959    }
960
961    #[test]
962    fn test_cwd_override_wins_over_turn_environment() {
963        use toolpath_convo::EnvironmentSnapshot;
964        let mut t = user_turn("u1", "hi");
965        t.environment = Some(EnvironmentSnapshot {
966            working_dir: Some("/tmp/proj".into()),
967            vcs_branch: None,
968            vcs_revision: None,
969        });
970        let session = PiProjector::new()
971            .with_cwd("/abs/override")
972            .project(&view_with(vec![t]))
973            .unwrap();
974        assert_eq!(session.header.cwd, "/abs/override");
975    }
976
977    #[test]
978    fn test_assistant_default_api_provider_for_non_pi_source() {
979        // Non-pi source has no Turn.extra["pi"]["api"] — defaults
980        // should kick in.
981        let session = PiProjector::default()
982            .project(&view_with(vec![assistant_turn("a1", "hi")]))
983            .unwrap();
984        match &session.entries[1] {
985            Entry::Message {
986                message:
987                    AgentMessage::Assistant {
988                        api,
989                        provider,
990                        usage,
991                        ..
992                    },
993                ..
994            } => {
995                assert_eq!(api, "anthropic");
996                assert_eq!(provider, "anthropic");
997                assert_eq!(usage.input, 100);
998                assert_eq!(usage.output, 50);
999                assert_eq!(usage.total_tokens, 150);
1000            }
1001            _ => panic!("expected Assistant"),
1002        }
1003    }
1004
1005    #[test]
1006    fn test_jsonl_serializes_per_entry_one_per_line() {
1007        // Sanity: each emitted Entry should serialize as a single
1008        // JSON object, suitable for line-by-line writes.
1009        let session = PiProjector::default()
1010            .project(&view_with(vec![user_turn("u1", "hi")]))
1011            .unwrap();
1012        for entry in &session.entries {
1013            let s = serde_json::to_string(entry).unwrap();
1014            assert!(
1015                !s.contains('\n'),
1016                "entry serialized with embedded newline: {}",
1017                s
1018            );
1019        }
1020    }
1021}