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 Tokens {
275 input: u.input_tokens,
276 output: u.output_tokens,
277 cached: u.cache_read_tokens,
278 thoughts: None,
279 tool: None,
280 total: None,
281 }
282}
283
284fn build_tool_calls(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<ToolCall>> {
288 if turn.tool_uses.is_empty() {
289 return None;
290 }
291
292 let meta_by_id: HashMap<String, &Value> = gemini_extras
293 .get("tool_call_meta")
294 .and_then(Value::as_array)
295 .map(|arr| {
296 arr.iter()
297 .filter_map(|v| {
298 let id = v.get("id")?.as_str()?.to_string();
299 Some((id, v))
300 })
301 .collect()
302 })
303 .unwrap_or_default();
304
305 let calls: Vec<ToolCall> = turn
306 .tool_uses
307 .iter()
308 .map(|tu| {
309 tool_invocation_to_tool_call(tu, meta_by_id.get(&tu.id).copied(), &turn.timestamp)
310 })
311 .collect();
312
313 Some(calls)
314}
315
316fn tool_invocation_to_tool_call(
317 tu: &ToolInvocation,
318 meta: Option<&Value>,
319 fallback_timestamp: &str,
320) -> ToolCall {
321 let meta_obj = meta.and_then(Value::as_object);
322
323 let name = if crate::provider::tool_category(&tu.name).is_some() {
331 tu.name.clone()
332 } else if let Some(cat) = tu.category
333 && let Some(remapped) = crate::provider::native_name(cat, &tu.input)
334 {
335 remapped.to_string()
336 } else {
337 tu.name.clone()
338 };
339
340 let status = meta_obj
341 .and_then(|m| m.get("status").and_then(Value::as_str))
342 .map(str::to_string)
343 .unwrap_or_else(|| match &tu.result {
344 Some(r) if r.is_error => "error".to_string(),
345 Some(_) => "success".to_string(),
346 None => "pending".to_string(),
347 });
348
349 let description = meta_obj
350 .and_then(|m| m.get("description").and_then(Value::as_str))
351 .map(str::to_string)
352 .or_else(|| synthesize_description(&name, &tu.input));
353
354 let display_name = meta_obj
355 .and_then(|m| m.get("display_name").and_then(Value::as_str))
356 .map(str::to_string)
357 .or_else(|| synthesize_display_name(&name, tu.category));
358
359 let result_display = meta_obj
360 .and_then(|m| m.get("result_display"))
361 .and_then(|v| if v.is_null() { None } else { Some(v.clone()) })
362 .or_else(|| synthesize_result_display(tu.result.as_ref()));
363
364 let result = tu
365 .result
366 .as_ref()
367 .map(|r| {
368 vec![FunctionResponse {
369 function_response: FunctionResponseBody {
370 id: tu.id.clone(),
373 name: name.clone(),
374 response: serde_json::json!({ "output": r.content }),
375 },
376 }]
377 })
378 .unwrap_or_default();
379
380 let mut extra = HashMap::new();
384 extra.insert("renderOutputAsMarkdown".to_string(), Value::Bool(true));
385
386 ToolCall {
387 id: tu.id.clone(),
388 name,
389 args: tu.input.clone(),
390 status,
391 timestamp: fallback_timestamp.to_string(),
392 result,
393 result_display,
394 description,
395 display_name,
396 extra,
397 }
398}
399
400fn synthesize_description(name: &str, args: &Value) -> Option<String> {
405 let pick = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
406 let by_name = match name {
407 "run_shell_command" => pick("description").or_else(|| pick("command")),
408 "read_file" | "list_directory" | "get_internal_docs" => {
409 pick("file_path").or_else(|| pick("path"))
410 }
411 "read_many_files" => args
412 .get("file_paths")
413 .and_then(Value::as_array)
414 .map(|a| {
415 a.iter()
416 .filter_map(Value::as_str)
417 .collect::<Vec<_>>()
418 .join(", ")
419 })
420 .filter(|s| !s.is_empty()),
421 "write_file" | "replace" | "edit" => pick("file_path"),
422 "glob" | "grep_search" | "search_file_content" => pick("pattern"),
423 "web_fetch" => pick("url"),
424 "google_web_search" => pick("query"),
425 "task" | "activate_skill" => pick("description")
426 .or_else(|| pick("prompt"))
427 .or_else(|| pick("subagent_type")),
428 _ => None,
429 };
430 by_name.or_else(|| generic_description_fallback(args))
431}
432
433fn generic_description_fallback(args: &Value) -> Option<String> {
438 static FALLBACK_KEYS: &[&str] = &[
439 "description",
440 "subject",
441 "summary",
442 "title",
443 "prompt",
444 "command",
445 "query",
446 "pattern",
447 "url",
448 "path",
449 "file_path",
450 "task_id",
451 "taskId",
452 "id",
453 "name",
454 ];
455 for key in FALLBACK_KEYS {
456 if let Some(s) = args.get(*key).and_then(Value::as_str)
457 && !s.is_empty()
458 {
459 return Some(s.to_string());
460 }
461 }
462 None
463}
464
465fn synthesize_display_name(name: &str, category: Option<ToolCategory>) -> Option<String> {
468 let by_name = match name {
469 "run_shell_command" => Some("Shell"),
470 "read_file" => Some("ReadFile"),
471 "read_many_files" => Some("ReadManyFiles"),
472 "list_directory" => Some("ListDirectory"),
473 "get_internal_docs" => Some("GetInternalDocs"),
474 "write_file" => Some("WriteFile"),
475 "replace" => Some("Replace"),
476 "edit" => Some("Edit"),
477 "glob" => Some("Glob"),
478 "grep_search" | "search_file_content" => Some("SearchText"),
479 "web_fetch" => Some("WebFetch"),
480 "google_web_search" => Some("GoogleSearch"),
481 "task" => Some("Task"),
482 "activate_skill" => Some("ActivateSkill"),
483 _ => None,
484 };
485 if let Some(s) = by_name {
486 return Some(s.to_string());
487 }
488 if let Some(c) = category {
491 return Some(
492 match c {
493 ToolCategory::Shell => "Shell",
494 ToolCategory::FileRead => "ReadFile",
495 ToolCategory::FileSearch => "Search",
496 ToolCategory::FileWrite => "WriteFile",
497 ToolCategory::Network => "Web",
498 ToolCategory::Delegation => "Task",
499 }
500 .to_string(),
501 );
502 }
503 if !name.is_empty() {
506 Some(name.to_string())
507 } else {
508 None
509 }
510}
511
512fn synthesize_result_display(result: Option<&toolpath_convo::ToolResult>) -> Option<Value> {
517 result.map(|r| Value::String(r.content.clone()))
518}
519
520fn delegation_to_chat_file(d: &DelegatedWork, project_hash: &str) -> ChatFile {
523 let messages: Vec<GeminiMessage> = d.turns.iter().map(turn_to_message).collect();
524
525 let start_time = d
526 .turns
527 .first()
528 .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
529 .map(|dt| dt.with_timezone(&chrono::Utc));
530 let last_updated = d
531 .turns
532 .last()
533 .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
534 .map(|dt| dt.with_timezone(&chrono::Utc));
535
536 ChatFile {
537 session_id: d.agent_id.clone(),
538 project_hash: project_hash.to_string(),
539 start_time,
540 last_updated,
541 directories: None,
542 kind: Some("subagent".to_string()),
543 summary: d.result.clone(),
544 messages,
545 extra: HashMap::new(),
546 }
547}
548
549#[cfg(test)]
552mod tests {
553 use super::*;
554 use toolpath_convo::{EnvironmentSnapshot, ToolCategory, ToolResult};
555
556 fn user_turn(id: &str, text: &str) -> Turn {
557 Turn {
558 id: id.into(),
559 parent_id: None,
560 role: Role::User,
561 timestamp: "2026-04-17T15:00:00Z".into(),
562 text: text.into(),
563 thinking: None,
564 tool_uses: vec![],
565 model: None,
566 stop_reason: None,
567 token_usage: None,
568 environment: None,
569 delegations: vec![],
570 file_mutations: Vec::new(),
571 }
572 }
573
574 fn assistant_turn(id: &str, text: &str) -> Turn {
575 Turn {
576 id: id.into(),
577 parent_id: None,
578 role: Role::Assistant,
579 timestamp: "2026-04-17T15:00:01Z".into(),
580 text: text.into(),
581 thinking: None,
582 tool_uses: vec![],
583 model: Some("gemini-3-flash-preview".into()),
584 stop_reason: None,
585 token_usage: None,
586 environment: None,
587 delegations: vec![],
588 file_mutations: Vec::new(),
589 }
590 }
591
592 fn view_with(turns: Vec<Turn>) -> ConversationView {
593 ConversationView {
594 id: "session-uuid".into(),
595 started_at: None,
596 last_activity: None,
597 turns,
598 total_usage: None,
599 provider_id: Some("gemini-cli".into()),
600 files_changed: vec![],
601 session_ids: vec![],
602 events: vec![],
603 ..Default::default()
604 }
605 }
606
607 #[test]
608 fn test_empty_view_projects_cleanly() {
609 let view = view_with(vec![]);
610 let convo = GeminiProjector::default().project(&view).unwrap();
611 assert_eq!(convo.session_uuid, "session-uuid");
612 assert!(convo.main.messages.is_empty());
613 assert!(convo.sub_agents.is_empty());
614 }
615
616 #[test]
617 fn test_user_content_becomes_parts() {
618 let view = view_with(vec![user_turn("u1", "Hello")]);
619 let convo = GeminiProjector::default().project(&view).unwrap();
620 let msg = &convo.main.messages[0];
621 assert_eq!(msg.role, GeminiRole::User);
622 match &msg.content {
623 GeminiContent::Parts(parts) => {
624 assert_eq!(parts.len(), 1);
625 assert_eq!(parts[0].text.as_deref(), Some("Hello"));
626 }
627 other => panic!("expected Parts, got {:?}", other),
628 }
629 }
630
631 #[test]
632 fn test_assistant_content_becomes_text() {
633 let view = view_with(vec![assistant_turn("a1", "Hi")]);
634 let convo = GeminiProjector::default().project(&view).unwrap();
635 let msg = &convo.main.messages[0];
636 assert_eq!(msg.role, GeminiRole::Gemini);
637 assert_eq!(msg.model.as_deref(), Some("gemini-3-flash-preview"));
638 match &msg.content {
639 GeminiContent::Text(s) => assert_eq!(s, "Hi"),
640 other => panic!("expected Text, got {:?}", other),
641 }
642 }
643
644 #[test]
645 fn test_system_role_maps_to_info() {
646 let mut t = user_turn("s1", "cancelled");
647 t.role = Role::System;
648 let convo = GeminiProjector::default()
649 .project(&view_with(vec![t]))
650 .unwrap();
651 assert_eq!(convo.main.messages[0].role, GeminiRole::Info);
652 }
653
654 #[test]
655 fn test_thoughts_fallback_from_flattened_string() {
656 let mut t = assistant_turn("a1", "");
658 t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
659 let convo = GeminiProjector::default()
660 .project(&view_with(vec![t]))
661 .unwrap();
662 let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
663 assert_eq!(thoughts.len(), 2);
664 assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
665 assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
666 }
667
668 #[test]
669 fn test_tokens_fallback_from_common_token_usage() {
670 let mut t = assistant_turn("a1", "Done.");
671 t.token_usage = Some(TokenUsage {
672 input_tokens: Some(100),
673 output_tokens: Some(50),
674 cache_read_tokens: Some(20),
675 cache_write_tokens: None,
676 });
677 let convo = GeminiProjector::default()
678 .project(&view_with(vec![t]))
679 .unwrap();
680 let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
681 assert_eq!(tokens.input, Some(100));
682 assert_eq!(tokens.output, Some(50));
683 assert_eq!(tokens.cached, Some(20));
684 assert!(tokens.total.is_none());
686 }
687
688 #[test]
689 fn test_tool_call_with_success_result_wraps_into_function_response() {
690 let mut t = assistant_turn("a1", "Reading.");
691 t.tool_uses = vec![ToolInvocation {
692 id: "tc1".into(),
693 name: "read_file".into(),
694 input: serde_json::json!({"path": "src/main.rs"}),
695 result: Some(ToolResult {
696 content: "fn main(){}".into(),
697 is_error: false,
698 }),
699 category: Some(ToolCategory::FileRead),
700 }];
701 let convo = GeminiProjector::default()
702 .project(&view_with(vec![t]))
703 .unwrap();
704 let calls = convo.main.messages[0].tool_calls.as_ref().unwrap();
705 assert_eq!(calls.len(), 1);
706 let call = &calls[0];
707 assert_eq!(call.name, "read_file");
708 assert_eq!(call.status, "success");
709 assert_eq!(call.result.len(), 1);
710 assert_eq!(call.result[0].function_response.id, "tc1");
711 assert_eq!(call.result[0].function_response.name, "read_file");
712 assert_eq!(
713 call.result[0].function_response.response["output"],
714 serde_json::json!("fn main(){}")
715 );
716 }
717
718 #[test]
719 fn test_tool_call_with_error_result_sets_error_status() {
720 let mut t = assistant_turn("a1", "");
721 t.tool_uses = vec![ToolInvocation {
722 id: "tc1".into(),
723 name: "run_shell_command".into(),
724 input: serde_json::json!({"command": "nope"}),
725 result: Some(ToolResult {
726 content: "boom".into(),
727 is_error: true,
728 }),
729 category: Some(ToolCategory::Shell),
730 }];
731 let convo = GeminiProjector::default()
732 .project(&view_with(vec![t]))
733 .unwrap();
734 let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
735 assert_eq!(call.status, "error");
736 }
737
738 #[test]
739 fn test_delegation_becomes_subagent_chat_file() {
740 let mut t = assistant_turn("a1", "delegating");
741 t.delegations = vec![DelegatedWork {
742 agent_id: "helper-session".into(),
743 prompt: "search for the bug".into(),
744 turns: vec![user_turn("su1", "search for the bug"), {
745 let mut r = assistant_turn("sa1", "found it");
746 r.timestamp = "2026-04-17T15:10:00Z".into();
747 r
748 }],
749 result: Some("fixed line 42".into()),
750 }];
751 let convo = GeminiProjector::default()
752 .project(&view_with(vec![t]))
753 .unwrap();
754 assert_eq!(convo.sub_agents.len(), 1);
755 let sub = &convo.sub_agents[0];
756 assert_eq!(sub.session_id, "helper-session");
757 assert_eq!(sub.kind.as_deref(), Some("subagent"));
758 assert_eq!(sub.summary.as_deref(), Some("fixed line 42"));
759 assert_eq!(sub.messages.len(), 2);
760 }
761
762 #[test]
763 fn test_environment_does_not_appear_on_message() {
764 let mut t = user_turn("u1", "hi");
768 t.environment = Some(EnvironmentSnapshot {
769 working_dir: Some("/abs/myrepo".into()),
770 vcs_branch: Some("main".into()),
771 vcs_revision: None,
772 });
773 let convo = GeminiProjector::default()
774 .project(&view_with(vec![t]))
775 .unwrap();
776 assert!(convo.main.directories.is_none());
778 }
779
780 #[test]
781 fn test_project_hash_and_path_propagate() {
782 let view = view_with(vec![user_turn("u1", "hi")]);
783 let projector = GeminiProjector::new()
784 .with_project_hash("deadbeef")
785 .with_project_path("/abs/myrepo");
786 let convo = projector.project(&view).unwrap();
787 assert_eq!(convo.main.project_hash, "deadbeef");
788 assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
789 }
790
791 #[test]
792 fn test_output_chat_file_serde_roundtrip() {
793 let mut t = assistant_turn("a1", "Hi there.");
796 t.token_usage = Some(TokenUsage {
797 input_tokens: Some(10),
798 output_tokens: Some(5),
799 cache_read_tokens: None,
800 cache_write_tokens: None,
801 });
802 t.tool_uses = vec![ToolInvocation {
803 id: "tc1".into(),
804 name: "read_file".into(),
805 input: serde_json::json!({"path": "src/a.rs"}),
806 result: Some(ToolResult {
807 content: "fn a(){}".into(),
808 is_error: false,
809 }),
810 category: Some(ToolCategory::FileRead),
811 }];
812
813 let convo = GeminiProjector::default()
814 .project(&view_with(vec![user_turn("u1", "Read src/a.rs"), t]))
815 .unwrap();
816
817 let json = serde_json::to_string(&convo.main).unwrap();
818 let back: ChatFile = serde_json::from_str(&json).unwrap();
819 assert_eq!(back.messages.len(), 2);
820 assert_eq!(back.messages[1].tool_calls().len(), 1);
821 assert_eq!(back.messages[1].tool_calls()[0].result_text(), "fn a(){}");
822 }
823}