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(
572 "output".into(),
573 BTreeMap::from([("reasoning".into(), 243u32)]),
574 );
575 let usage = TokenUsage {
576 output_tokens: Some(337),
577 breakdowns,
578 ..Default::default()
579 };
580
581 let tokens = tokens_from_common(&usage);
582 assert_eq!(tokens.output, Some(94));
583 assert_eq!(tokens.thoughts, Some(243));
584 }
585
586 #[test]
587 fn tokens_from_common_without_breakdown_leaves_output_unchanged() {
588 let usage = TokenUsage {
589 output_tokens: Some(337),
590 ..Default::default()
591 };
592
593 let tokens = tokens_from_common(&usage);
594 assert_eq!(tokens.output, Some(337));
595 assert_eq!(tokens.thoughts, None);
596 }
597
598 fn user_turn(id: &str, text: &str) -> Turn {
599 Turn {
600 id: id.into(),
601 parent_id: None,
602 group_id: None,
603 role: Role::User,
604 timestamp: "2026-04-17T15:00:00Z".into(),
605 text: text.into(),
606 thinking: None,
607 tool_uses: vec![],
608 model: None,
609 stop_reason: None,
610 token_usage: None,
611 attributed_token_usage: None,
612 environment: None,
613 delegations: vec![],
614 file_mutations: Vec::new(),
615 }
616 }
617
618 fn assistant_turn(id: &str, text: &str) -> Turn {
619 Turn {
620 id: id.into(),
621 parent_id: None,
622 group_id: None,
623 role: Role::Assistant,
624 timestamp: "2026-04-17T15:00:01Z".into(),
625 text: text.into(),
626 thinking: None,
627 tool_uses: vec![],
628 model: Some("gemini-3-flash-preview".into()),
629 stop_reason: None,
630 token_usage: None,
631 attributed_token_usage: None,
632 environment: None,
633 delegations: vec![],
634 file_mutations: Vec::new(),
635 }
636 }
637
638 fn view_with(turns: Vec<Turn>) -> ConversationView {
639 ConversationView {
640 id: "session-uuid".into(),
641 started_at: None,
642 last_activity: None,
643 turns,
644 total_usage: None,
645 provider_id: Some("gemini-cli".into()),
646 files_changed: vec![],
647 session_ids: vec![],
648 events: vec![],
649 ..Default::default()
650 }
651 }
652
653 #[test]
654 fn test_empty_view_projects_cleanly() {
655 let view = view_with(vec![]);
656 let convo = GeminiProjector::default().project(&view).unwrap();
657 assert_eq!(convo.session_uuid, "session-uuid");
658 assert!(convo.main.messages.is_empty());
659 assert!(convo.sub_agents.is_empty());
660 }
661
662 #[test]
663 fn test_user_content_becomes_parts() {
664 let view = view_with(vec![user_turn("u1", "Hello")]);
665 let convo = GeminiProjector::default().project(&view).unwrap();
666 let msg = &convo.main.messages[0];
667 assert_eq!(msg.role, GeminiRole::User);
668 match &msg.content {
669 GeminiContent::Parts(parts) => {
670 assert_eq!(parts.len(), 1);
671 assert_eq!(parts[0].text.as_deref(), Some("Hello"));
672 }
673 other => panic!("expected Parts, got {:?}", other),
674 }
675 }
676
677 #[test]
678 fn test_assistant_content_becomes_text() {
679 let view = view_with(vec![assistant_turn("a1", "Hi")]);
680 let convo = GeminiProjector::default().project(&view).unwrap();
681 let msg = &convo.main.messages[0];
682 assert_eq!(msg.role, GeminiRole::Gemini);
683 assert_eq!(msg.model.as_deref(), Some("gemini-3-flash-preview"));
684 match &msg.content {
685 GeminiContent::Text(s) => assert_eq!(s, "Hi"),
686 other => panic!("expected Text, got {:?}", other),
687 }
688 }
689
690 #[test]
691 fn test_system_role_maps_to_info() {
692 let mut t = user_turn("s1", "cancelled");
693 t.role = Role::System;
694 let convo = GeminiProjector::default()
695 .project(&view_with(vec![t]))
696 .unwrap();
697 assert_eq!(convo.main.messages[0].role, GeminiRole::Info);
698 }
699
700 #[test]
701 fn test_thoughts_fallback_from_flattened_string() {
702 let mut t = assistant_turn("a1", "");
704 t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
705 let convo = GeminiProjector::default()
706 .project(&view_with(vec![t]))
707 .unwrap();
708 let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
709 assert_eq!(thoughts.len(), 2);
710 assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
711 assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
712 }
713
714 #[test]
715 fn test_tokens_fallback_from_common_token_usage() {
716 let mut t = assistant_turn("a1", "Done.");
717 t.token_usage = Some(TokenUsage {
718 input_tokens: Some(100),
719 output_tokens: Some(50),
720 cache_read_tokens: Some(20),
721 cache_write_tokens: None,
722 ..Default::default()
723 });
724 let convo = GeminiProjector::default()
725 .project(&view_with(vec![t]))
726 .unwrap();
727 let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
728 assert_eq!(tokens.input, Some(100));
729 assert_eq!(tokens.output, Some(50));
730 assert_eq!(tokens.cached, Some(20));
731 assert!(tokens.total.is_none());
733 }
734
735 #[test]
736 fn test_tool_call_with_success_result_wraps_into_function_response() {
737 let mut t = assistant_turn("a1", "Reading.");
738 t.tool_uses = vec![ToolInvocation {
739 id: "tc1".into(),
740 name: "read_file".into(),
741 input: serde_json::json!({"path": "src/main.rs"}),
742 result: Some(ToolResult {
743 content: "fn main(){}".into(),
744 is_error: false,
745 }),
746 category: Some(ToolCategory::FileRead),
747 }];
748 let convo = GeminiProjector::default()
749 .project(&view_with(vec![t]))
750 .unwrap();
751 let calls = convo.main.messages[0].tool_calls.as_ref().unwrap();
752 assert_eq!(calls.len(), 1);
753 let call = &calls[0];
754 assert_eq!(call.name, "read_file");
755 assert_eq!(call.status, "success");
756 assert_eq!(call.result.len(), 1);
757 assert_eq!(call.result[0].function_response.id, "tc1");
758 assert_eq!(call.result[0].function_response.name, "read_file");
759 assert_eq!(
760 call.result[0].function_response.response["output"],
761 serde_json::json!("fn main(){}")
762 );
763 }
764
765 #[test]
766 fn test_tool_call_with_error_result_sets_error_status() {
767 let mut t = assistant_turn("a1", "");
768 t.tool_uses = vec![ToolInvocation {
769 id: "tc1".into(),
770 name: "run_shell_command".into(),
771 input: serde_json::json!({"command": "nope"}),
772 result: Some(ToolResult {
773 content: "boom".into(),
774 is_error: true,
775 }),
776 category: Some(ToolCategory::Shell),
777 }];
778 let convo = GeminiProjector::default()
779 .project(&view_with(vec![t]))
780 .unwrap();
781 let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
782 assert_eq!(call.status, "error");
783 }
784
785 #[test]
786 fn test_delegation_becomes_subagent_chat_file() {
787 let mut t = assistant_turn("a1", "delegating");
788 t.delegations = vec![DelegatedWork {
789 agent_id: "helper-session".into(),
790 prompt: "search for the bug".into(),
791 turns: vec![user_turn("su1", "search for the bug"), {
792 let mut r = assistant_turn("sa1", "found it");
793 r.timestamp = "2026-04-17T15:10:00Z".into();
794 r
795 }],
796 result: Some("fixed line 42".into()),
797 }];
798 let convo = GeminiProjector::default()
799 .project(&view_with(vec![t]))
800 .unwrap();
801 assert_eq!(convo.sub_agents.len(), 1);
802 let sub = &convo.sub_agents[0];
803 assert_eq!(sub.session_id, "helper-session");
804 assert_eq!(sub.kind.as_deref(), Some("subagent"));
805 assert_eq!(sub.summary.as_deref(), Some("fixed line 42"));
806 assert_eq!(sub.messages.len(), 2);
807 }
808
809 #[test]
810 fn test_environment_does_not_appear_on_message() {
811 let mut t = user_turn("u1", "hi");
815 t.environment = Some(EnvironmentSnapshot {
816 working_dir: Some("/abs/myrepo".into()),
817 vcs_branch: Some("main".into()),
818 vcs_revision: None,
819 });
820 let convo = GeminiProjector::default()
821 .project(&view_with(vec![t]))
822 .unwrap();
823 assert!(convo.main.directories.is_none());
825 }
826
827 #[test]
828 fn test_project_hash_and_path_propagate() {
829 let view = view_with(vec![user_turn("u1", "hi")]);
830 let projector = GeminiProjector::new()
831 .with_project_hash("deadbeef")
832 .with_project_path("/abs/myrepo");
833 let convo = projector.project(&view).unwrap();
834 assert_eq!(convo.main.project_hash, "deadbeef");
835 assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
836 }
837
838 #[test]
839 fn test_output_chat_file_serde_roundtrip() {
840 let mut t = assistant_turn("a1", "Hi there.");
843 t.token_usage = Some(TokenUsage {
844 input_tokens: Some(10),
845 output_tokens: Some(5),
846 cache_read_tokens: None,
847 cache_write_tokens: None,
848 ..Default::default()
849 });
850 t.tool_uses = vec![ToolInvocation {
851 id: "tc1".into(),
852 name: "read_file".into(),
853 input: serde_json::json!({"path": "src/a.rs"}),
854 result: Some(ToolResult {
855 content: "fn a(){}".into(),
856 is_error: false,
857 }),
858 category: Some(ToolCategory::FileRead),
859 }];
860
861 let convo = GeminiProjector::default()
862 .project(&view_with(vec![user_turn("u1", "Read src/a.rs"), t]))
863 .unwrap();
864
865 let json = serde_json::to_string(&convo.main).unwrap();
866 let back: ChatFile = serde_json::from_str(&json).unwrap();
867 assert_eq!(back.messages.len(), 2);
868 assert_eq!(back.messages[1].tool_calls().len(), 1);
869 assert_eq!(back.messages[1].tool_calls()[0].result_text(), "fn a(){}");
870 }
871}