1use std::collections::HashMap;
12
13use serde_json::{Map, Value};
14use toolpath_convo::{
15 ConversationProjector, ConversationView, ConvoError, DelegatedWork, Result, Role, TokenUsage,
16 ToolCategory, ToolInvocation, Turn,
17};
18
19use crate::types::{
20 ChatFile, Conversation, FunctionResponse, FunctionResponseBody, GeminiContent, GeminiMessage,
21 GeminiRole, TextPart, Thought, Tokens, ToolCall,
22};
23
24#[derive(Debug, Clone, Default)]
50pub struct GeminiProjector {
51 pub project_hash: Option<String>,
54 pub project_path: Option<String>,
56}
57
58impl GeminiProjector {
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 pub fn with_project_hash(mut self, hash: impl Into<String>) -> Self {
64 self.project_hash = Some(hash.into());
65 self
66 }
67
68 pub fn with_project_path(mut self, path: impl Into<String>) -> Self {
69 self.project_path = Some(path.into());
70 self
71 }
72}
73
74impl ConversationProjector for GeminiProjector {
75 type Output = Conversation;
76
77 fn project(&self, view: &ConversationView) -> Result<Conversation> {
78 project_view(self, view).map_err(ConvoError::Provider)
79 }
80}
81
82fn project_view(
85 cfg: &GeminiProjector,
86 view: &ConversationView,
87) -> std::result::Result<Conversation, String> {
88 let project_hash = cfg.project_hash.clone().unwrap_or_default();
89
90 let mut main_messages: Vec<GeminiMessage> = Vec::with_capacity(view.turns.len());
91 let mut sub_agents: Vec<ChatFile> = Vec::new();
92
93 for turn in &view.turns {
94 main_messages.push(turn_to_message(turn));
95
96 for delegation in &turn.delegations {
97 sub_agents.push(delegation_to_chat_file(delegation, &project_hash));
98 }
99 }
100
101 let directories = cfg
107 .project_path
108 .as_ref()
109 .map(|p| vec![std::path::PathBuf::from(p)]);
110
111 let main = ChatFile {
112 session_id: view.id.clone(),
113 project_hash: project_hash.clone(),
114 start_time: view.started_at,
115 last_updated: view.last_activity,
116 directories,
117 kind: Some("main".to_string()),
118 summary: None,
119 messages: main_messages,
120 extra: HashMap::new(),
121 };
122
123 Ok(Conversation {
124 session_uuid: view.id.clone(),
125 project_path: cfg.project_path.clone(),
126 main,
127 sub_agents,
128 started_at: view.started_at,
129 last_activity: view.last_activity,
130 })
131}
132
133fn turn_to_message(turn: &Turn) -> GeminiMessage {
136 let gemini_extras: Map<String, Value> = Map::new();
142 let msg_extras: HashMap<String, Value> = HashMap::new();
143
144 GeminiMessage {
145 id: turn.id.clone(),
146 timestamp: turn.timestamp.clone(),
147 role: role_to_gemini_role(&turn.role),
148 content: build_content(turn),
149 thoughts: build_thoughts(turn, &gemini_extras),
150 tokens: build_tokens(turn, &gemini_extras),
151 model: turn.model.clone(),
152 tool_calls: build_tool_calls(turn, &gemini_extras),
153 extra: msg_extras,
154 }
155}
156
157fn role_to_gemini_role(role: &Role) -> GeminiRole {
158 match role {
159 Role::User => GeminiRole::User,
160 Role::Assistant => GeminiRole::Gemini,
161 Role::System => GeminiRole::Info,
162 Role::Other(s) => GeminiRole::Other(s.clone()),
163 }
164}
165
166fn build_content(turn: &Turn) -> GeminiContent {
172 match turn.role {
173 Role::User => GeminiContent::Parts(vec![TextPart {
174 text: Some(turn.text.clone()),
175 extra: HashMap::new(),
176 }]),
177 _ => GeminiContent::Text(turn.text.clone()),
178 }
179}
180
181fn build_thoughts(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<Thought>> {
189 if let Some(Value::Array(arr)) = gemini_extras.get("thoughts_meta") {
190 let thoughts: Vec<Thought> = arr
191 .iter()
192 .filter_map(|v| {
193 let obj = v.as_object()?;
194 Some(Thought {
195 subject: obj
196 .get("subject")
197 .and_then(Value::as_str)
198 .map(str::to_string),
199 description: obj
200 .get("description")
201 .and_then(Value::as_str)
202 .map(str::to_string),
203 timestamp: obj
204 .get("timestamp")
205 .and_then(Value::as_str)
206 .map(str::to_string),
207 })
208 })
209 .collect();
210 return if thoughts.is_empty() {
211 None
212 } else {
213 Some(thoughts)
214 };
215 }
216
217 let thinking = turn.thinking.as_deref()?;
219 let chunks: Vec<&str> = thinking.split("\n\n").collect();
220 if chunks.is_empty() {
221 return None;
222 }
223 let thoughts: Vec<Thought> = chunks
224 .iter()
225 .filter(|c| !c.is_empty())
226 .map(|chunk| split_flattened_thought(chunk))
227 .collect();
228 if thoughts.is_empty() {
229 None
230 } else {
231 Some(thoughts)
232 }
233}
234
235fn split_flattened_thought(chunk: &str) -> Thought {
236 if let Some(rest) = chunk.strip_prefix("**")
238 && let Some(end) = rest.find("**")
239 {
240 let subject = &rest[..end];
241 let after = &rest[end + 2..];
242 let description = after.strip_prefix('\n').unwrap_or(after);
243 return Thought {
244 subject: Some(subject.to_string()),
245 description: if description.is_empty() {
246 None
247 } else {
248 Some(description.to_string())
249 },
250 timestamp: None,
251 };
252 }
253 Thought {
254 subject: None,
255 description: Some(chunk.to_string()),
256 timestamp: None,
257 }
258}
259
260fn build_tokens(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Tokens> {
265 if let Some(v) = gemini_extras.get("tokens")
266 && let Ok(t) = serde_json::from_value::<Tokens>(v.clone())
267 {
268 return Some(t);
269 }
270 turn.token_usage.as_ref().map(tokens_from_common)
271}
272
273fn tokens_from_common(u: &TokenUsage) -> Tokens {
274 let thoughts = u
278 .breakdowns
279 .get("output")
280 .and_then(|m| m.get("reasoning"))
281 .copied();
282 Tokens {
283 input: u.input_tokens,
284 output: match (u.output_tokens, thoughts) {
285 (Some(o), Some(r)) => Some(o.saturating_sub(r)),
286 (o, _) => o,
287 },
288 cached: u.cache_read_tokens,
289 thoughts,
290 tool: None,
291 total: None,
292 }
293}
294
295fn build_tool_calls(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<ToolCall>> {
299 if turn.tool_uses.is_empty() {
300 return None;
301 }
302
303 let meta_by_id: HashMap<String, &Value> = gemini_extras
304 .get("tool_call_meta")
305 .and_then(Value::as_array)
306 .map(|arr| {
307 arr.iter()
308 .filter_map(|v| {
309 let id = v.get("id")?.as_str()?.to_string();
310 Some((id, v))
311 })
312 .collect()
313 })
314 .unwrap_or_default();
315
316 let calls: Vec<ToolCall> = turn
317 .tool_uses
318 .iter()
319 .map(|tu| {
320 tool_invocation_to_tool_call(tu, meta_by_id.get(&tu.id).copied(), &turn.timestamp)
321 })
322 .collect();
323
324 Some(calls)
325}
326
327fn tool_invocation_to_tool_call(
328 tu: &ToolInvocation,
329 meta: Option<&Value>,
330 fallback_timestamp: &str,
331) -> ToolCall {
332 let meta_obj = meta.and_then(Value::as_object);
333
334 let name = if crate::provider::tool_category(&tu.name).is_some() {
342 tu.name.clone()
343 } else if let Some(cat) = tu.category
344 && let Some(remapped) = crate::provider::native_name(cat, &tu.input)
345 {
346 remapped.to_string()
347 } else {
348 tu.name.clone()
349 };
350
351 let status = meta_obj
352 .and_then(|m| m.get("status").and_then(Value::as_str))
353 .map(str::to_string)
354 .unwrap_or_else(|| match &tu.result {
355 Some(r) if r.is_error => "error".to_string(),
356 Some(_) => "success".to_string(),
357 None => "pending".to_string(),
358 });
359
360 let description = meta_obj
361 .and_then(|m| m.get("description").and_then(Value::as_str))
362 .map(str::to_string)
363 .or_else(|| synthesize_description(&name, &tu.input));
364
365 let display_name = meta_obj
366 .and_then(|m| m.get("display_name").and_then(Value::as_str))
367 .map(str::to_string)
368 .or_else(|| synthesize_display_name(&name, tu.category));
369
370 let result_display = meta_obj
371 .and_then(|m| m.get("result_display"))
372 .and_then(|v| if v.is_null() { None } else { Some(v.clone()) })
373 .or_else(|| synthesize_result_display(tu.result.as_ref()));
374
375 let result = tu
376 .result
377 .as_ref()
378 .map(|r| {
379 vec![FunctionResponse {
380 function_response: FunctionResponseBody {
381 id: tu.id.clone(),
384 name: name.clone(),
385 response: serde_json::json!({ "output": r.content }),
386 },
387 }]
388 })
389 .unwrap_or_default();
390
391 let mut extra = HashMap::new();
395 extra.insert("renderOutputAsMarkdown".to_string(), Value::Bool(true));
396
397 ToolCall {
398 id: tu.id.clone(),
399 name,
400 args: tu.input.clone(),
401 status,
402 timestamp: fallback_timestamp.to_string(),
403 result,
404 result_display,
405 description,
406 display_name,
407 extra,
408 }
409}
410
411fn synthesize_description(name: &str, args: &Value) -> Option<String> {
416 let pick = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
417 let by_name = match name {
418 "run_shell_command" => pick("description").or_else(|| pick("command")),
419 "read_file" | "list_directory" | "get_internal_docs" => {
420 pick("file_path").or_else(|| pick("path"))
421 }
422 "read_many_files" => args
423 .get("file_paths")
424 .and_then(Value::as_array)
425 .map(|a| {
426 a.iter()
427 .filter_map(Value::as_str)
428 .collect::<Vec<_>>()
429 .join(", ")
430 })
431 .filter(|s| !s.is_empty()),
432 "write_file" | "replace" | "edit" => pick("file_path"),
433 "glob" | "grep_search" | "search_file_content" => pick("pattern"),
434 "web_fetch" => pick("url"),
435 "google_web_search" => pick("query"),
436 "task" | "activate_skill" => pick("description")
437 .or_else(|| pick("prompt"))
438 .or_else(|| pick("subagent_type")),
439 _ => None,
440 };
441 by_name.or_else(|| generic_description_fallback(args))
442}
443
444fn generic_description_fallback(args: &Value) -> Option<String> {
449 static FALLBACK_KEYS: &[&str] = &[
450 "description",
451 "subject",
452 "summary",
453 "title",
454 "prompt",
455 "command",
456 "query",
457 "pattern",
458 "url",
459 "path",
460 "file_path",
461 "task_id",
462 "taskId",
463 "id",
464 "name",
465 ];
466 for key in FALLBACK_KEYS {
467 if let Some(s) = args.get(*key).and_then(Value::as_str)
468 && !s.is_empty()
469 {
470 return Some(s.to_string());
471 }
472 }
473 None
474}
475
476fn synthesize_display_name(name: &str, category: Option<ToolCategory>) -> Option<String> {
479 let by_name = match name {
480 "run_shell_command" => Some("Shell"),
481 "read_file" => Some("ReadFile"),
482 "read_many_files" => Some("ReadManyFiles"),
483 "list_directory" => Some("ListDirectory"),
484 "get_internal_docs" => Some("GetInternalDocs"),
485 "write_file" => Some("WriteFile"),
486 "replace" => Some("Replace"),
487 "edit" => Some("Edit"),
488 "glob" => Some("Glob"),
489 "grep_search" | "search_file_content" => Some("SearchText"),
490 "web_fetch" => Some("WebFetch"),
491 "google_web_search" => Some("GoogleSearch"),
492 "task" => Some("Task"),
493 "activate_skill" => Some("ActivateSkill"),
494 _ => None,
495 };
496 if let Some(s) = by_name {
497 return Some(s.to_string());
498 }
499 if let Some(c) = category {
502 return Some(
503 match c {
504 ToolCategory::Shell => "Shell",
505 ToolCategory::FileRead => "ReadFile",
506 ToolCategory::FileSearch => "Search",
507 ToolCategory::FileWrite => "WriteFile",
508 ToolCategory::Network => "Web",
509 ToolCategory::Delegation => "Task",
510 }
511 .to_string(),
512 );
513 }
514 if !name.is_empty() {
517 Some(name.to_string())
518 } else {
519 None
520 }
521}
522
523fn synthesize_result_display(result: Option<&toolpath_convo::ToolResult>) -> Option<Value> {
528 result.map(|r| Value::String(r.content.clone()))
529}
530
531fn delegation_to_chat_file(d: &DelegatedWork, project_hash: &str) -> ChatFile {
534 let messages: Vec<GeminiMessage> = d.turns.iter().map(turn_to_message).collect();
535
536 let start_time = d
537 .turns
538 .first()
539 .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
540 .map(|dt| dt.with_timezone(&chrono::Utc));
541 let last_updated = d
542 .turns
543 .last()
544 .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
545 .map(|dt| dt.with_timezone(&chrono::Utc));
546
547 ChatFile {
548 session_id: d.agent_id.clone(),
549 project_hash: project_hash.to_string(),
550 start_time,
551 last_updated,
552 directories: None,
553 kind: Some("subagent".to_string()),
554 summary: d.result.clone(),
555 messages,
556 extra: HashMap::new(),
557 }
558}
559
560#[cfg(test)]
563mod tests {
564 use super::*;
565 use std::collections::BTreeMap;
566 use toolpath_convo::{EnvironmentSnapshot, ToolCategory, ToolResult};
567
568 #[test]
569 fn tokens_from_common_unfolds_reasoning_out_of_output() {
570 let mut breakdowns: BTreeMap<String, BTreeMap<String, u32>> = BTreeMap::new();
571 breakdowns.insert("output".into(), BTreeMap::from([("reasoning".into(), 243u32)]));
572 let usage = TokenUsage {
573 output_tokens: Some(337),
574 breakdowns,
575 ..Default::default()
576 };
577
578 let tokens = tokens_from_common(&usage);
579 assert_eq!(tokens.output, Some(94));
580 assert_eq!(tokens.thoughts, Some(243));
581 }
582
583 #[test]
584 fn tokens_from_common_without_breakdown_leaves_output_unchanged() {
585 let usage = TokenUsage {
586 output_tokens: Some(337),
587 ..Default::default()
588 };
589
590 let tokens = tokens_from_common(&usage);
591 assert_eq!(tokens.output, Some(337));
592 assert_eq!(tokens.thoughts, None);
593 }
594
595 fn user_turn(id: &str, text: &str) -> Turn {
596 Turn {
597 id: id.into(),
598 parent_id: None,
599 group_id: None,
600 role: Role::User,
601 timestamp: "2026-04-17T15:00:00Z".into(),
602 text: text.into(),
603 thinking: None,
604 tool_uses: vec![],
605 model: None,
606 stop_reason: None,
607 token_usage: None,
608 attributed_token_usage: None,
609 environment: None,
610 delegations: vec![],
611 file_mutations: Vec::new(),
612 }
613 }
614
615 fn assistant_turn(id: &str, text: &str) -> Turn {
616 Turn {
617 id: id.into(),
618 parent_id: None,
619 group_id: None,
620 role: Role::Assistant,
621 timestamp: "2026-04-17T15:00:01Z".into(),
622 text: text.into(),
623 thinking: None,
624 tool_uses: vec![],
625 model: Some("gemini-3-flash-preview".into()),
626 stop_reason: None,
627 token_usage: None,
628 attributed_token_usage: None,
629 environment: None,
630 delegations: vec![],
631 file_mutations: Vec::new(),
632 }
633 }
634
635 fn view_with(turns: Vec<Turn>) -> ConversationView {
636 ConversationView {
637 id: "session-uuid".into(),
638 started_at: None,
639 last_activity: None,
640 turns,
641 total_usage: None,
642 provider_id: Some("gemini-cli".into()),
643 files_changed: vec![],
644 session_ids: vec![],
645 events: vec![],
646 ..Default::default()
647 }
648 }
649
650 #[test]
651 fn test_empty_view_projects_cleanly() {
652 let view = view_with(vec![]);
653 let convo = GeminiProjector::default().project(&view).unwrap();
654 assert_eq!(convo.session_uuid, "session-uuid");
655 assert!(convo.main.messages.is_empty());
656 assert!(convo.sub_agents.is_empty());
657 }
658
659 #[test]
660 fn test_user_content_becomes_parts() {
661 let view = view_with(vec![user_turn("u1", "Hello")]);
662 let convo = GeminiProjector::default().project(&view).unwrap();
663 let msg = &convo.main.messages[0];
664 assert_eq!(msg.role, GeminiRole::User);
665 match &msg.content {
666 GeminiContent::Parts(parts) => {
667 assert_eq!(parts.len(), 1);
668 assert_eq!(parts[0].text.as_deref(), Some("Hello"));
669 }
670 other => panic!("expected Parts, got {:?}", other),
671 }
672 }
673
674 #[test]
675 fn test_assistant_content_becomes_text() {
676 let view = view_with(vec![assistant_turn("a1", "Hi")]);
677 let convo = GeminiProjector::default().project(&view).unwrap();
678 let msg = &convo.main.messages[0];
679 assert_eq!(msg.role, GeminiRole::Gemini);
680 assert_eq!(msg.model.as_deref(), Some("gemini-3-flash-preview"));
681 match &msg.content {
682 GeminiContent::Text(s) => assert_eq!(s, "Hi"),
683 other => panic!("expected Text, got {:?}", other),
684 }
685 }
686
687 #[test]
688 fn test_system_role_maps_to_info() {
689 let mut t = user_turn("s1", "cancelled");
690 t.role = Role::System;
691 let convo = GeminiProjector::default()
692 .project(&view_with(vec![t]))
693 .unwrap();
694 assert_eq!(convo.main.messages[0].role, GeminiRole::Info);
695 }
696
697 #[test]
698 fn test_thoughts_fallback_from_flattened_string() {
699 let mut t = assistant_turn("a1", "");
701 t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
702 let convo = GeminiProjector::default()
703 .project(&view_with(vec![t]))
704 .unwrap();
705 let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
706 assert_eq!(thoughts.len(), 2);
707 assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
708 assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
709 }
710
711 #[test]
712 fn test_tokens_fallback_from_common_token_usage() {
713 let mut t = assistant_turn("a1", "Done.");
714 t.token_usage = Some(TokenUsage {
715 input_tokens: Some(100),
716 output_tokens: Some(50),
717 cache_read_tokens: Some(20),
718 cache_write_tokens: None,
719 ..Default::default()
720 });
721 let convo = GeminiProjector::default()
722 .project(&view_with(vec![t]))
723 .unwrap();
724 let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
725 assert_eq!(tokens.input, Some(100));
726 assert_eq!(tokens.output, Some(50));
727 assert_eq!(tokens.cached, Some(20));
728 assert!(tokens.total.is_none());
730 }
731
732 #[test]
733 fn test_tool_call_with_success_result_wraps_into_function_response() {
734 let mut t = assistant_turn("a1", "Reading.");
735 t.tool_uses = vec![ToolInvocation {
736 id: "tc1".into(),
737 name: "read_file".into(),
738 input: serde_json::json!({"path": "src/main.rs"}),
739 result: Some(ToolResult {
740 content: "fn main(){}".into(),
741 is_error: false,
742 }),
743 category: Some(ToolCategory::FileRead),
744 }];
745 let convo = GeminiProjector::default()
746 .project(&view_with(vec![t]))
747 .unwrap();
748 let calls = convo.main.messages[0].tool_calls.as_ref().unwrap();
749 assert_eq!(calls.len(), 1);
750 let call = &calls[0];
751 assert_eq!(call.name, "read_file");
752 assert_eq!(call.status, "success");
753 assert_eq!(call.result.len(), 1);
754 assert_eq!(call.result[0].function_response.id, "tc1");
755 assert_eq!(call.result[0].function_response.name, "read_file");
756 assert_eq!(
757 call.result[0].function_response.response["output"],
758 serde_json::json!("fn main(){}")
759 );
760 }
761
762 #[test]
763 fn test_tool_call_with_error_result_sets_error_status() {
764 let mut t = assistant_turn("a1", "");
765 t.tool_uses = vec![ToolInvocation {
766 id: "tc1".into(),
767 name: "run_shell_command".into(),
768 input: serde_json::json!({"command": "nope"}),
769 result: Some(ToolResult {
770 content: "boom".into(),
771 is_error: true,
772 }),
773 category: Some(ToolCategory::Shell),
774 }];
775 let convo = GeminiProjector::default()
776 .project(&view_with(vec![t]))
777 .unwrap();
778 let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
779 assert_eq!(call.status, "error");
780 }
781
782 #[test]
783 fn test_delegation_becomes_subagent_chat_file() {
784 let mut t = assistant_turn("a1", "delegating");
785 t.delegations = vec![DelegatedWork {
786 agent_id: "helper-session".into(),
787 prompt: "search for the bug".into(),
788 turns: vec![user_turn("su1", "search for the bug"), {
789 let mut r = assistant_turn("sa1", "found it");
790 r.timestamp = "2026-04-17T15:10:00Z".into();
791 r
792 }],
793 result: Some("fixed line 42".into()),
794 }];
795 let convo = GeminiProjector::default()
796 .project(&view_with(vec![t]))
797 .unwrap();
798 assert_eq!(convo.sub_agents.len(), 1);
799 let sub = &convo.sub_agents[0];
800 assert_eq!(sub.session_id, "helper-session");
801 assert_eq!(sub.kind.as_deref(), Some("subagent"));
802 assert_eq!(sub.summary.as_deref(), Some("fixed line 42"));
803 assert_eq!(sub.messages.len(), 2);
804 }
805
806 #[test]
807 fn test_environment_does_not_appear_on_message() {
808 let mut t = user_turn("u1", "hi");
812 t.environment = Some(EnvironmentSnapshot {
813 working_dir: Some("/abs/myrepo".into()),
814 vcs_branch: Some("main".into()),
815 vcs_revision: None,
816 });
817 let convo = GeminiProjector::default()
818 .project(&view_with(vec![t]))
819 .unwrap();
820 assert!(convo.main.directories.is_none());
822 }
823
824 #[test]
825 fn test_project_hash_and_path_propagate() {
826 let view = view_with(vec![user_turn("u1", "hi")]);
827 let projector = GeminiProjector::new()
828 .with_project_hash("deadbeef")
829 .with_project_path("/abs/myrepo");
830 let convo = projector.project(&view).unwrap();
831 assert_eq!(convo.main.project_hash, "deadbeef");
832 assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
833 }
834
835 #[test]
836 fn test_output_chat_file_serde_roundtrip() {
837 let mut t = assistant_turn("a1", "Hi there.");
840 t.token_usage = Some(TokenUsage {
841 input_tokens: Some(10),
842 output_tokens: Some(5),
843 cache_read_tokens: None,
844 cache_write_tokens: None,
845 ..Default::default()
846 });
847 t.tool_uses = vec![ToolInvocation {
848 id: "tc1".into(),
849 name: "read_file".into(),
850 input: serde_json::json!({"path": "src/a.rs"}),
851 result: Some(ToolResult {
852 content: "fn a(){}".into(),
853 is_error: false,
854 }),
855 category: Some(ToolCategory::FileRead),
856 }];
857
858 let convo = GeminiProjector::default()
859 .project(&view_with(vec![user_turn("u1", "Read src/a.rs"), t]))
860 .unwrap();
861
862 let json = serde_json::to_string(&convo.main).unwrap();
863 let back: ChatFile = serde_json::from_str(&json).unwrap();
864 assert_eq!(back.messages.len(), 2);
865 assert_eq!(back.messages[1].tool_calls().len(), 1);
866 assert_eq!(back.messages[1].tool_calls()[0].result_text(), "fn a(){}");
867 }
868}