1use 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#[derive(Debug, Clone, Default)]
59pub struct PiProjector {
60 pub cwd: Option<String>,
64 pub default_api: Option<String>,
67 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
101fn 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 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 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
179fn pi_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
184 None
185}
186
187fn 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
274fn 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 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 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 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 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 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 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 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
665fn 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
675fn 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
685fn 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
711fn 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
725fn 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
743fn 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#[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 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 assert_eq!(session.entries.len(), 3);
862 match &session.entries[1] {
863 Entry::Message {
864 message: AgentMessage::Assistant { content, .. },
865 ..
866 } => {
867 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 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 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 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}