1use std::collections::HashMap;
16
17use serde_json::{Map, Value};
18use toolpath_convo::{
19 ConversationProjector, ConversationView, ConvoError, DelegatedWork, Result, Role, TokenUsage,
20 ToolCategory, ToolInvocation, Turn,
21};
22
23use crate::types::{
24 ChatFile, Conversation, FunctionResponse, FunctionResponseBody, GeminiContent, GeminiMessage,
25 GeminiRole, TextPart, Thought, Tokens, ToolCall,
26};
27
28#[derive(Debug, Clone, Default)]
60pub struct GeminiProjector {
61 pub project_hash: Option<String>,
64 pub project_path: Option<String>,
66}
67
68impl GeminiProjector {
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn with_project_hash(mut self, hash: impl Into<String>) -> Self {
74 self.project_hash = Some(hash.into());
75 self
76 }
77
78 pub fn with_project_path(mut self, path: impl Into<String>) -> Self {
79 self.project_path = Some(path.into());
80 self
81 }
82}
83
84impl ConversationProjector for GeminiProjector {
85 type Output = Conversation;
86
87 fn project(&self, view: &ConversationView) -> Result<Conversation> {
88 project_view(self, view).map_err(ConvoError::Provider)
89 }
90}
91
92fn project_view(
95 cfg: &GeminiProjector,
96 view: &ConversationView,
97) -> std::result::Result<Conversation, String> {
98 let project_hash = cfg.project_hash.clone().unwrap_or_default();
99
100 let mut main_messages: Vec<GeminiMessage> = Vec::with_capacity(view.turns.len());
101 let mut sub_agents: Vec<ChatFile> = Vec::new();
102
103 for turn in &view.turns {
104 main_messages.push(turn_to_message(turn));
105
106 for delegation in &turn.delegations {
107 sub_agents.push(delegation_to_chat_file(delegation, &project_hash));
108 }
109 }
110
111 let directories = cfg
117 .project_path
118 .as_ref()
119 .map(|p| vec![std::path::PathBuf::from(p)]);
120
121 let main = ChatFile {
122 session_id: view.id.clone(),
123 project_hash: project_hash.clone(),
124 start_time: view.started_at,
125 last_updated: view.last_activity,
126 directories,
127 kind: Some("main".to_string()),
128 summary: None,
129 messages: main_messages,
130 extra: HashMap::new(),
131 };
132
133 Ok(Conversation {
134 session_uuid: view.id.clone(),
135 project_path: cfg.project_path.clone(),
136 main,
137 sub_agents,
138 started_at: view.started_at,
139 last_activity: view.last_activity,
140 })
141}
142
143fn turn_to_message(turn: &Turn) -> GeminiMessage {
146 let (gemini_extras, msg_extras) = split_gemini_extras(&turn.extra);
147
148 GeminiMessage {
149 id: turn.id.clone(),
150 timestamp: turn.timestamp.clone(),
151 role: role_to_gemini_role(&turn.role),
152 content: build_content(turn),
153 thoughts: build_thoughts(turn, &gemini_extras),
154 tokens: build_tokens(turn, &gemini_extras),
155 model: turn.model.clone(),
156 tool_calls: build_tool_calls(turn, &gemini_extras),
157 extra: msg_extras,
158 }
159}
160
161fn role_to_gemini_role(role: &Role) -> GeminiRole {
162 match role {
163 Role::User => GeminiRole::User,
164 Role::Assistant => GeminiRole::Gemini,
165 Role::System => GeminiRole::Info,
166 Role::Other(s) => GeminiRole::Other(s.clone()),
167 }
168}
169
170fn build_content(turn: &Turn) -> GeminiContent {
176 match turn.role {
177 Role::User => GeminiContent::Parts(vec![TextPart {
178 text: Some(turn.text.clone()),
179 extra: HashMap::new(),
180 }]),
181 _ => GeminiContent::Text(turn.text.clone()),
182 }
183}
184
185fn split_gemini_extras(
198 extra: &HashMap<String, Value>,
199) -> (Map<String, Value>, HashMap<String, Value>) {
200 let mut gemini_meta = Map::new();
201 let mut msg_extra: HashMap<String, Value> = HashMap::new();
202
203 if let Some(Value::Object(gem)) = extra.get("gemini") {
204 for (k, v) in gem {
205 match k.as_str() {
206 "tokens" | "thoughts_meta" | "tool_call_meta" => {
209 gemini_meta.insert(k.clone(), v.clone());
210 }
211 _ => {
215 msg_extra.insert(k.clone(), v.clone());
216 }
217 }
218 }
219 }
220
221 (gemini_meta, msg_extra)
222}
223
224fn build_thoughts(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<Thought>> {
232 if let Some(Value::Array(arr)) = gemini_extras.get("thoughts_meta") {
233 let thoughts: Vec<Thought> = arr
234 .iter()
235 .filter_map(|v| {
236 let obj = v.as_object()?;
237 Some(Thought {
238 subject: obj
239 .get("subject")
240 .and_then(Value::as_str)
241 .map(str::to_string),
242 description: obj
243 .get("description")
244 .and_then(Value::as_str)
245 .map(str::to_string),
246 timestamp: obj
247 .get("timestamp")
248 .and_then(Value::as_str)
249 .map(str::to_string),
250 })
251 })
252 .collect();
253 return if thoughts.is_empty() {
254 None
255 } else {
256 Some(thoughts)
257 };
258 }
259
260 let thinking = turn.thinking.as_deref()?;
262 let chunks: Vec<&str> = thinking.split("\n\n").collect();
263 if chunks.is_empty() {
264 return None;
265 }
266 let thoughts: Vec<Thought> = chunks
267 .iter()
268 .filter(|c| !c.is_empty())
269 .map(|chunk| split_flattened_thought(chunk))
270 .collect();
271 if thoughts.is_empty() {
272 None
273 } else {
274 Some(thoughts)
275 }
276}
277
278fn split_flattened_thought(chunk: &str) -> Thought {
279 if let Some(rest) = chunk.strip_prefix("**")
281 && let Some(end) = rest.find("**")
282 {
283 let subject = &rest[..end];
284 let after = &rest[end + 2..];
285 let description = after.strip_prefix('\n').unwrap_or(after);
286 return Thought {
287 subject: Some(subject.to_string()),
288 description: if description.is_empty() {
289 None
290 } else {
291 Some(description.to_string())
292 },
293 timestamp: None,
294 };
295 }
296 Thought {
297 subject: None,
298 description: Some(chunk.to_string()),
299 timestamp: None,
300 }
301}
302
303fn build_tokens(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Tokens> {
308 if let Some(v) = gemini_extras.get("tokens")
309 && let Ok(t) = serde_json::from_value::<Tokens>(v.clone())
310 {
311 return Some(t);
312 }
313 turn.token_usage.as_ref().map(tokens_from_common)
314}
315
316fn tokens_from_common(u: &TokenUsage) -> Tokens {
317 Tokens {
318 input: u.input_tokens,
319 output: u.output_tokens,
320 cached: u.cache_read_tokens,
321 thoughts: None,
322 tool: None,
323 total: None,
324 }
325}
326
327fn build_tool_calls(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<ToolCall>> {
331 if turn.tool_uses.is_empty() {
332 return None;
333 }
334
335 let meta_by_id: HashMap<String, &Value> = gemini_extras
336 .get("tool_call_meta")
337 .and_then(Value::as_array)
338 .map(|arr| {
339 arr.iter()
340 .filter_map(|v| {
341 let id = v.get("id")?.as_str()?.to_string();
342 Some((id, v))
343 })
344 .collect()
345 })
346 .unwrap_or_default();
347
348 let calls: Vec<ToolCall> = turn
349 .tool_uses
350 .iter()
351 .map(|tu| {
352 tool_invocation_to_tool_call(tu, meta_by_id.get(&tu.id).copied(), &turn.timestamp)
353 })
354 .collect();
355
356 Some(calls)
357}
358
359fn tool_invocation_to_tool_call(
360 tu: &ToolInvocation,
361 meta: Option<&Value>,
362 fallback_timestamp: &str,
363) -> ToolCall {
364 let meta_obj = meta.and_then(Value::as_object);
365
366 let name = if crate::provider::tool_category(&tu.name).is_some() {
374 tu.name.clone()
375 } else if let Some(cat) = tu.category
376 && let Some(remapped) = crate::provider::native_name(cat, &tu.input)
377 {
378 remapped.to_string()
379 } else {
380 tu.name.clone()
381 };
382
383 let status = meta_obj
384 .and_then(|m| m.get("status").and_then(Value::as_str))
385 .map(str::to_string)
386 .unwrap_or_else(|| match &tu.result {
387 Some(r) if r.is_error => "error".to_string(),
388 Some(_) => "success".to_string(),
389 None => "pending".to_string(),
390 });
391
392 let description = meta_obj
393 .and_then(|m| m.get("description").and_then(Value::as_str))
394 .map(str::to_string)
395 .or_else(|| synthesize_description(&name, &tu.input));
396
397 let display_name = meta_obj
398 .and_then(|m| m.get("display_name").and_then(Value::as_str))
399 .map(str::to_string)
400 .or_else(|| synthesize_display_name(&name, tu.category));
401
402 let result_display = meta_obj
403 .and_then(|m| m.get("result_display"))
404 .and_then(|v| if v.is_null() { None } else { Some(v.clone()) })
405 .or_else(|| synthesize_result_display(tu.result.as_ref()));
406
407 let result = tu
408 .result
409 .as_ref()
410 .map(|r| {
411 vec![FunctionResponse {
412 function_response: FunctionResponseBody {
413 id: tu.id.clone(),
416 name: name.clone(),
417 response: serde_json::json!({ "output": r.content }),
418 },
419 }]
420 })
421 .unwrap_or_default();
422
423 let mut extra = HashMap::new();
427 extra.insert("renderOutputAsMarkdown".to_string(), Value::Bool(true));
428
429 ToolCall {
430 id: tu.id.clone(),
431 name,
432 args: tu.input.clone(),
433 status,
434 timestamp: fallback_timestamp.to_string(),
435 result,
436 result_display,
437 description,
438 display_name,
439 extra,
440 }
441}
442
443fn synthesize_description(name: &str, args: &Value) -> Option<String> {
448 let pick = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
449 let by_name = match name {
450 "run_shell_command" => pick("description").or_else(|| pick("command")),
451 "read_file" | "list_directory" | "get_internal_docs" => {
452 pick("file_path").or_else(|| pick("path"))
453 }
454 "read_many_files" => args
455 .get("file_paths")
456 .and_then(Value::as_array)
457 .map(|a| {
458 a.iter()
459 .filter_map(Value::as_str)
460 .collect::<Vec<_>>()
461 .join(", ")
462 })
463 .filter(|s| !s.is_empty()),
464 "write_file" | "replace" | "edit" => pick("file_path"),
465 "glob" | "grep_search" | "search_file_content" => pick("pattern"),
466 "web_fetch" => pick("url"),
467 "google_web_search" => pick("query"),
468 "task" | "activate_skill" => pick("description")
469 .or_else(|| pick("prompt"))
470 .or_else(|| pick("subagent_type")),
471 _ => None,
472 };
473 by_name.or_else(|| generic_description_fallback(args))
474}
475
476fn generic_description_fallback(args: &Value) -> Option<String> {
481 static FALLBACK_KEYS: &[&str] = &[
482 "description",
483 "subject",
484 "summary",
485 "title",
486 "prompt",
487 "command",
488 "query",
489 "pattern",
490 "url",
491 "path",
492 "file_path",
493 "task_id",
494 "taskId",
495 "id",
496 "name",
497 ];
498 for key in FALLBACK_KEYS {
499 if let Some(s) = args.get(*key).and_then(Value::as_str)
500 && !s.is_empty()
501 {
502 return Some(s.to_string());
503 }
504 }
505 None
506}
507
508fn synthesize_display_name(name: &str, category: Option<ToolCategory>) -> Option<String> {
511 let by_name = match name {
512 "run_shell_command" => Some("Shell"),
513 "read_file" => Some("ReadFile"),
514 "read_many_files" => Some("ReadManyFiles"),
515 "list_directory" => Some("ListDirectory"),
516 "get_internal_docs" => Some("GetInternalDocs"),
517 "write_file" => Some("WriteFile"),
518 "replace" => Some("Replace"),
519 "edit" => Some("Edit"),
520 "glob" => Some("Glob"),
521 "grep_search" | "search_file_content" => Some("SearchText"),
522 "web_fetch" => Some("WebFetch"),
523 "google_web_search" => Some("GoogleSearch"),
524 "task" => Some("Task"),
525 "activate_skill" => Some("ActivateSkill"),
526 _ => None,
527 };
528 if let Some(s) = by_name {
529 return Some(s.to_string());
530 }
531 if let Some(c) = category {
534 return Some(
535 match c {
536 ToolCategory::Shell => "Shell",
537 ToolCategory::FileRead => "ReadFile",
538 ToolCategory::FileSearch => "Search",
539 ToolCategory::FileWrite => "WriteFile",
540 ToolCategory::Network => "Web",
541 ToolCategory::Delegation => "Task",
542 }
543 .to_string(),
544 );
545 }
546 if !name.is_empty() {
549 Some(name.to_string())
550 } else {
551 None
552 }
553}
554
555fn synthesize_result_display(result: Option<&toolpath_convo::ToolResult>) -> Option<Value> {
560 result.map(|r| Value::String(r.content.clone()))
561}
562
563fn delegation_to_chat_file(d: &DelegatedWork, project_hash: &str) -> ChatFile {
566 let messages: Vec<GeminiMessage> = d.turns.iter().map(turn_to_message).collect();
567
568 let start_time = d
569 .turns
570 .first()
571 .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
572 .map(|dt| dt.with_timezone(&chrono::Utc));
573 let last_updated = d
574 .turns
575 .last()
576 .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
577 .map(|dt| dt.with_timezone(&chrono::Utc));
578
579 ChatFile {
580 session_id: d.agent_id.clone(),
581 project_hash: project_hash.to_string(),
582 start_time,
583 last_updated,
584 directories: None,
585 kind: Some("subagent".to_string()),
586 summary: d.result.clone(),
587 messages,
588 extra: HashMap::new(),
589 }
590}
591
592#[cfg(test)]
595mod tests {
596 use super::*;
597 use toolpath_convo::{EnvironmentSnapshot, ToolCategory, ToolResult};
598
599 fn user_turn(id: &str, text: &str) -> Turn {
600 Turn {
601 id: id.into(),
602 parent_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 environment: None,
612 delegations: vec![],
613 extra: HashMap::new(),
614 }
615 }
616
617 fn assistant_turn(id: &str, text: &str) -> Turn {
618 Turn {
619 id: id.into(),
620 parent_id: None,
621 role: Role::Assistant,
622 timestamp: "2026-04-17T15:00:01Z".into(),
623 text: text.into(),
624 thinking: None,
625 tool_uses: vec![],
626 model: Some("gemini-3-flash-preview".into()),
627 stop_reason: None,
628 token_usage: None,
629 environment: None,
630 delegations: vec![],
631 extra: HashMap::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 }
647 }
648
649 #[test]
650 fn test_empty_view_projects_cleanly() {
651 let view = view_with(vec![]);
652 let convo = GeminiProjector::default().project(&view).unwrap();
653 assert_eq!(convo.session_uuid, "session-uuid");
654 assert!(convo.main.messages.is_empty());
655 assert!(convo.sub_agents.is_empty());
656 }
657
658 #[test]
659 fn test_user_content_becomes_parts() {
660 let view = view_with(vec![user_turn("u1", "Hello")]);
661 let convo = GeminiProjector::default().project(&view).unwrap();
662 let msg = &convo.main.messages[0];
663 assert_eq!(msg.role, GeminiRole::User);
664 match &msg.content {
665 GeminiContent::Parts(parts) => {
666 assert_eq!(parts.len(), 1);
667 assert_eq!(parts[0].text.as_deref(), Some("Hello"));
668 }
669 other => panic!("expected Parts, got {:?}", other),
670 }
671 }
672
673 #[test]
674 fn test_assistant_content_becomes_text() {
675 let view = view_with(vec![assistant_turn("a1", "Hi")]);
676 let convo = GeminiProjector::default().project(&view).unwrap();
677 let msg = &convo.main.messages[0];
678 assert_eq!(msg.role, GeminiRole::Gemini);
679 assert_eq!(msg.model.as_deref(), Some("gemini-3-flash-preview"));
680 match &msg.content {
681 GeminiContent::Text(s) => assert_eq!(s, "Hi"),
682 other => panic!("expected Text, got {:?}", other),
683 }
684 }
685
686 #[test]
687 fn test_system_role_maps_to_info() {
688 let mut t = user_turn("s1", "cancelled");
689 t.role = Role::System;
690 let convo = GeminiProjector::default()
691 .project(&view_with(vec![t]))
692 .unwrap();
693 assert_eq!(convo.main.messages[0].role, GeminiRole::Info);
694 }
695
696 #[test]
697 fn test_thoughts_rebuilt_from_meta() {
698 let mut t = assistant_turn("a1", "");
699 let meta = serde_json::json!([
700 {"subject": "Searching", "description": "looking in /auth", "timestamp": "2026-04-17T15:00:02Z"},
701 {"subject": "Plan", "description": "try token path", "timestamp": "2026-04-17T15:00:03Z"},
702 ]);
703 t.extra
704 .insert("gemini".into(), serde_json::json!({"thoughts_meta": meta}));
705 t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
706
707 let convo = GeminiProjector::default()
708 .project(&view_with(vec![t]))
709 .unwrap();
710 let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
711 assert_eq!(thoughts.len(), 2);
712 assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
713 assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
714 assert_eq!(
715 thoughts[0].timestamp.as_deref(),
716 Some("2026-04-17T15:00:02Z")
717 );
718 assert_eq!(thoughts[1].subject.as_deref(), Some("Plan"));
719 }
720
721 #[test]
722 fn test_thoughts_fallback_from_flattened_string() {
723 let mut t = assistant_turn("a1", "");
725 t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
726 let convo = GeminiProjector::default()
727 .project(&view_with(vec![t]))
728 .unwrap();
729 let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
730 assert_eq!(thoughts.len(), 2);
731 assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
732 assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
733 }
734
735 #[test]
736 fn test_tokens_from_gemini_extras_preserved() {
737 let mut t = assistant_turn("a1", "Done.");
738 t.extra.insert(
739 "gemini".into(),
740 serde_json::json!({
741 "tokens": {"input": 10, "output": 5, "cached": 0, "thoughts": 2, "tool": 0, "total": 17}
742 }),
743 );
744 let convo = GeminiProjector::default()
745 .project(&view_with(vec![t]))
746 .unwrap();
747 let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
748 assert_eq!(tokens.input, Some(10));
749 assert_eq!(tokens.output, Some(5));
750 assert_eq!(tokens.thoughts, Some(2));
751 assert_eq!(tokens.total, Some(17));
752 }
753
754 #[test]
755 fn test_tokens_fallback_from_common_token_usage() {
756 let mut t = assistant_turn("a1", "Done.");
757 t.token_usage = Some(TokenUsage {
758 input_tokens: Some(100),
759 output_tokens: Some(50),
760 cache_read_tokens: Some(20),
761 cache_write_tokens: None,
762 });
763 let convo = GeminiProjector::default()
764 .project(&view_with(vec![t]))
765 .unwrap();
766 let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
767 assert_eq!(tokens.input, Some(100));
768 assert_eq!(tokens.output, Some(50));
769 assert_eq!(tokens.cached, Some(20));
770 assert!(tokens.total.is_none());
772 }
773
774 #[test]
775 fn test_tool_call_with_success_result_wraps_into_function_response() {
776 let mut t = assistant_turn("a1", "Reading.");
777 t.tool_uses = vec![ToolInvocation {
778 id: "tc1".into(),
779 name: "read_file".into(),
780 input: serde_json::json!({"path": "src/main.rs"}),
781 result: Some(ToolResult {
782 content: "fn main(){}".into(),
783 is_error: false,
784 }),
785 category: Some(ToolCategory::FileRead),
786 }];
787 let convo = GeminiProjector::default()
788 .project(&view_with(vec![t]))
789 .unwrap();
790 let calls = convo.main.messages[0].tool_calls.as_ref().unwrap();
791 assert_eq!(calls.len(), 1);
792 let call = &calls[0];
793 assert_eq!(call.name, "read_file");
794 assert_eq!(call.status, "success");
795 assert_eq!(call.result.len(), 1);
796 assert_eq!(call.result[0].function_response.id, "tc1");
797 assert_eq!(call.result[0].function_response.name, "read_file");
798 assert_eq!(
799 call.result[0].function_response.response["output"],
800 serde_json::json!("fn main(){}")
801 );
802 }
803
804 #[test]
805 fn test_tool_call_with_error_result_sets_error_status() {
806 let mut t = assistant_turn("a1", "");
807 t.tool_uses = vec![ToolInvocation {
808 id: "tc1".into(),
809 name: "run_shell_command".into(),
810 input: serde_json::json!({"command": "nope"}),
811 result: Some(ToolResult {
812 content: "boom".into(),
813 is_error: true,
814 }),
815 category: Some(ToolCategory::Shell),
816 }];
817 let convo = GeminiProjector::default()
818 .project(&view_with(vec![t]))
819 .unwrap();
820 let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
821 assert_eq!(call.status, "error");
822 }
823
824 #[test]
825 fn test_tool_call_meta_preserves_result_display_and_description() {
826 let mut t = assistant_turn("a1", "");
827 t.tool_uses = vec![ToolInvocation {
828 id: "tc1".into(),
829 name: "write_file".into(),
830 input: serde_json::json!({"file_path": "a.rs"}),
831 result: Some(ToolResult {
832 content: "wrote".into(),
833 is_error: false,
834 }),
835 category: Some(ToolCategory::FileWrite),
836 }];
837 t.extra.insert(
838 "gemini".into(),
839 serde_json::json!({
840 "tool_call_meta": [{
841 "id": "tc1",
842 "status": "success",
843 "result_display": {"fileDiff": "@@\n+x"},
844 "description": "write a.rs",
845 "display_name": "Write a.rs",
846 }],
847 }),
848 );
849 let convo = GeminiProjector::default()
850 .project(&view_with(vec![t]))
851 .unwrap();
852 let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
853 assert_eq!(call.description.as_deref(), Some("write a.rs"));
854 assert_eq!(call.display_name.as_deref(), Some("Write a.rs"));
855 assert_eq!(call.file_diff().as_deref(), Some("@@\n+x"));
856 }
857
858 #[test]
859 fn test_delegation_becomes_subagent_chat_file() {
860 let mut t = assistant_turn("a1", "delegating");
861 t.delegations = vec![DelegatedWork {
862 agent_id: "helper-session".into(),
863 prompt: "search for the bug".into(),
864 turns: vec![user_turn("su1", "search for the bug"), {
865 let mut r = assistant_turn("sa1", "found it");
866 r.timestamp = "2026-04-17T15:10:00Z".into();
867 r
868 }],
869 result: Some("fixed line 42".into()),
870 }];
871 let convo = GeminiProjector::default()
872 .project(&view_with(vec![t]))
873 .unwrap();
874 assert_eq!(convo.sub_agents.len(), 1);
875 let sub = &convo.sub_agents[0];
876 assert_eq!(sub.session_id, "helper-session");
877 assert_eq!(sub.kind.as_deref(), Some("subagent"));
878 assert_eq!(sub.summary.as_deref(), Some("fixed line 42"));
879 assert_eq!(sub.messages.len(), 2);
880 }
881
882 #[test]
883 fn test_environment_does_not_appear_on_message() {
884 let mut t = user_turn("u1", "hi");
888 t.environment = Some(EnvironmentSnapshot {
889 working_dir: Some("/abs/myrepo".into()),
890 vcs_branch: Some("main".into()),
891 vcs_revision: None,
892 });
893 let convo = GeminiProjector::default()
894 .project(&view_with(vec![t]))
895 .unwrap();
896 assert!(convo.main.directories.is_none());
898 }
899
900 #[test]
901 fn test_foreign_namespace_extras_are_dropped() {
902 let mut t = user_turn("u1", "hi");
908 t.extra.insert(
909 "claude".into(),
910 serde_json::json!({"version": "2.1.116", "user_type": "external"}),
911 );
912 t.extra
913 .insert("codex".into(), serde_json::json!({"some": "data"}));
914 let convo = GeminiProjector::default()
915 .project(&view_with(vec![t]))
916 .unwrap();
917 let msg = &convo.main.messages[0];
918 assert!(
919 msg.extra.get("claude").is_none(),
920 "claude namespace should not leak onto Gemini messages"
921 );
922 assert!(msg.extra.get("codex").is_none());
923 }
924
925 #[test]
926 fn test_gemini_native_message_extras_are_preserved() {
927 let mut t = user_turn("u1", "hi");
931 t.extra.insert(
932 "gemini".into(),
933 serde_json::json!({
934 "tokens": {"input": 10},
935 "some_native_extra": "round-tripped value",
936 }),
937 );
938 let convo = GeminiProjector::default()
939 .project(&view_with(vec![t]))
940 .unwrap();
941 let msg = &convo.main.messages[0];
942 assert_eq!(
943 msg.extra.get("some_native_extra"),
944 Some(&serde_json::json!("round-tripped value"))
945 );
946 }
947
948 #[test]
949 fn test_project_hash_and_path_propagate() {
950 let view = view_with(vec![user_turn("u1", "hi")]);
951 let projector = GeminiProjector::new()
952 .with_project_hash("deadbeef")
953 .with_project_path("/abs/myrepo");
954 let convo = projector.project(&view).unwrap();
955 assert_eq!(convo.main.project_hash, "deadbeef");
956 assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
957 }
958
959 #[test]
960 fn test_output_chat_file_serde_roundtrip() {
961 let mut t = assistant_turn("a1", "Hi there.");
964 t.token_usage = Some(TokenUsage {
965 input_tokens: Some(10),
966 output_tokens: Some(5),
967 cache_read_tokens: None,
968 cache_write_tokens: None,
969 });
970 t.tool_uses = vec![ToolInvocation {
971 id: "tc1".into(),
972 name: "read_file".into(),
973 input: serde_json::json!({"path": "src/a.rs"}),
974 result: Some(ToolResult {
975 content: "fn a(){}".into(),
976 is_error: false,
977 }),
978 category: Some(ToolCategory::FileRead),
979 }];
980
981 let convo = GeminiProjector::default()
982 .project(&view_with(vec![user_turn("u1", "Read src/a.rs"), t]))
983 .unwrap();
984
985 let json = serde_json::to_string(&convo.main).unwrap();
986 let back: ChatFile = serde_json::from_str(&json).unwrap();
987 assert_eq!(back.messages.len(), 2);
988 assert_eq!(back.messages[1].tool_calls().len(), 1);
989 assert_eq!(back.messages[1].tool_calls()[0].result_text(), "fn a(){}");
990 }
991}