1use std::collections::HashMap;
13
14use crate::GeminiConvo;
15use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, Thought, ToolCall};
16use serde_json::{Map, Value};
17use toolpath_convo::{
18 ConversationMeta, ConversationProvider, ConversationView, ConvoError, DelegatedWork,
19 EnvironmentSnapshot, Role, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
20};
21
22fn gemini_role_to_role(role: &GeminiRole) -> Role {
25 match role {
26 GeminiRole::User => Role::User,
27 GeminiRole::Gemini => Role::Assistant,
28 GeminiRole::Info => Role::System,
29 GeminiRole::Other(s) => Role::Other(s.clone()),
30 }
31}
32
33pub fn tool_category(name: &str) -> Option<ToolCategory> {
38 match name {
39 "read_file" | "read_many_files" | "list_directory" | "get_internal_docs"
40 | "read_mcp_resource" => Some(ToolCategory::FileRead),
41 "glob" | "grep_search" | "search_file_content" => Some(ToolCategory::FileSearch),
42 "write_file" | "replace" | "edit" => Some(ToolCategory::FileWrite),
43 "run_shell_command" => Some(ToolCategory::Shell),
44 "web_fetch" | "google_web_search" => Some(ToolCategory::Network),
45 "task" | "activate_skill" => Some(ToolCategory::Delegation),
46 _ => None,
47 }
48}
49
50pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> {
60 match category {
61 ToolCategory::Shell => Some("run_shell_command"),
62 ToolCategory::FileRead => Some(if args.get("file_paths").is_some() {
63 "read_many_files"
64 } else if args.get("path").is_some() && args.get("file_path").is_none() {
65 "list_directory"
67 } else {
68 "read_file"
69 }),
70 ToolCategory::FileSearch => Some(if args.get("pattern").is_some() {
71 "grep_search"
72 } else {
73 "glob"
74 }),
75 ToolCategory::FileWrite => Some(
76 if args.get("old_string").is_some() || args.get("edits").is_some() {
82 "replace"
83 } else {
84 "write_file"
85 },
86 ),
87 ToolCategory::Network => Some(if args.get("url").is_some() {
88 "web_fetch"
89 } else {
90 "google_web_search"
91 }),
92 ToolCategory::Delegation => Some("task"),
93 }
94}
95
96fn message_to_turn(msg: &GeminiMessage, working_dir: Option<&str>) -> Turn {
99 let text = msg.content.text();
100 let thinking = flatten_thoughts(msg.thoughts());
101 let tool_uses: Vec<ToolInvocation> = msg
102 .tool_calls()
103 .iter()
104 .map(tool_call_to_invocation)
105 .collect();
106
107 let token_usage = msg.tokens.as_ref().map(|t| TokenUsage {
108 input_tokens: t.input,
109 output_tokens: t.output,
110 cache_read_tokens: t.cached,
111 cache_write_tokens: None,
112 });
113
114 let environment = working_dir.map(|wd| EnvironmentSnapshot {
115 working_dir: Some(wd.to_string()),
116 vcs_branch: None,
117 vcs_revision: None,
118 });
119
120 let mut extra = HashMap::new();
121 let gemini_extra = build_gemini_extra(msg);
122 if !gemini_extra.is_empty() {
123 extra.insert("gemini".to_string(), Value::Object(gemini_extra));
124 }
125
126 Turn {
127 id: msg.id.clone(),
128 parent_id: None,
129 role: gemini_role_to_role(&msg.role),
130 timestamp: msg.timestamp.clone(),
131 text,
132 thinking,
133 tool_uses,
134 model: msg.model.clone(),
135 stop_reason: None,
136 token_usage,
137 environment,
138 delegations: vec![],
139 extra,
140 }
141}
142
143fn flatten_thoughts(thoughts: &[Thought]) -> Option<String> {
144 if thoughts.is_empty() {
145 return None;
146 }
147 let joined: Vec<String> = thoughts
148 .iter()
149 .filter_map(|t| match (&t.subject, &t.description) {
150 (Some(s), Some(d)) => Some(format!("**{}**\n{}", s, d)),
151 (Some(s), None) => Some(s.clone()),
152 (None, Some(d)) => Some(d.clone()),
153 (None, None) => None,
154 })
155 .collect();
156 if joined.is_empty() {
157 None
158 } else {
159 Some(joined.join("\n\n"))
160 }
161}
162
163fn tool_call_to_invocation(call: &ToolCall) -> ToolInvocation {
164 let text = call.result_text();
165 let is_error = call.is_error();
166 let result = if call.result.is_empty() && !is_error {
167 None
168 } else {
169 Some(ToolResult {
170 content: text,
171 is_error,
172 })
173 };
174 ToolInvocation {
175 id: call.id.clone(),
176 name: call.name.clone(),
177 input: call.args.clone(),
178 result,
179 category: tool_category(&call.name),
180 }
181}
182
183fn build_gemini_extra(msg: &GeminiMessage) -> Map<String, Value> {
186 let mut map = Map::new();
187
188 if let Some(t) = &msg.tokens
190 && let Ok(v) = serde_json::to_value(t)
191 {
192 map.insert("tokens".to_string(), v);
193 }
194
195 if !msg.thoughts().is_empty() {
199 let meta: Vec<Value> = msg
200 .thoughts()
201 .iter()
202 .map(|t| {
203 serde_json::json!({
204 "subject": t.subject,
205 "description": t.description,
206 "timestamp": t.timestamp,
207 })
208 })
209 .collect();
210 map.insert("thoughts_meta".to_string(), Value::Array(meta));
211 }
212
213 if !msg.tool_calls().is_empty() {
216 let statuses: Vec<Value> = msg
217 .tool_calls()
218 .iter()
219 .map(|t| {
220 serde_json::json!({
221 "id": t.id,
222 "status": t.status,
223 "result_display": t.result_display,
224 "description": t.description,
225 "display_name": t.display_name,
226 })
227 })
228 .collect();
229 map.insert("tool_call_meta".to_string(), Value::Array(statuses));
230 }
231
232 for (k, v) in &msg.extra {
234 map.insert(k.clone(), v.clone());
235 }
236
237 map
238}
239
240fn sub_agent_to_delegation(
245 sub: &ChatFile,
246 working_dir: Option<&str>,
247 fallback_prompt: &str,
248 fallback_result: Option<&ToolResult>,
249) -> DelegatedWork {
250 let turns: Vec<Turn> = sub
251 .messages
252 .iter()
253 .map(|m| message_to_turn(m, working_dir))
254 .collect();
255
256 let prompt = first_user_text(sub).unwrap_or_else(|| fallback_prompt.to_string());
257
258 let result = sub
259 .summary
260 .clone()
261 .or_else(|| fallback_result.map(|r| r.content.clone()));
262
263 let agent_id = if sub.session_id.is_empty() {
264 format!("subagent-{}", turns.len())
265 } else {
266 sub.session_id.clone()
267 };
268
269 DelegatedWork {
270 agent_id,
271 prompt,
272 turns,
273 result,
274 }
275}
276
277fn first_user_text(chat: &ChatFile) -> Option<String> {
278 chat.messages
279 .iter()
280 .find(|m| m.role == GeminiRole::User)
281 .map(|m| m.content.text())
282 .filter(|t| !t.is_empty())
283}
284
285fn tool_invocation_to_delegation(tu: &ToolInvocation) -> DelegatedWork {
288 DelegatedWork {
289 agent_id: tu.id.clone(),
290 prompt: tu
291 .input
292 .get("prompt")
293 .and_then(|v| v.as_str())
294 .unwrap_or("")
295 .to_string(),
296 turns: vec![],
297 result: tu.result.as_ref().map(|r| r.content.clone()),
298 }
299}
300
301fn conversation_to_view(convo: &Conversation) -> ConversationView {
304 let working_dir: Option<String> = convo.project_path.clone().or_else(|| {
305 convo
306 .main
307 .directories()
308 .first()
309 .map(|p| p.to_string_lossy().to_string())
310 });
311 let wd_ref = working_dir.as_deref();
312
313 let mut sub_order: Vec<&ChatFile> = convo.sub_agents.iter().collect();
315 sub_order.sort_by_key(|s| s.start_time);
316 let mut sub_iter = sub_order.into_iter();
317
318 let mut turns: Vec<Turn> = Vec::with_capacity(convo.main.messages.len());
319
320 for msg in &convo.main.messages {
321 let mut turn = message_to_turn(msg, wd_ref);
322
323 for tu in &turn.tool_uses {
326 if tu.category != Some(ToolCategory::Delegation) {
327 continue;
328 }
329 let delegation = match sub_iter.next() {
330 Some(sub) => {
331 let prompt_fallback = tu
332 .input
333 .get("prompt")
334 .and_then(|v| v.as_str())
335 .unwrap_or("");
336 sub_agent_to_delegation(sub, wd_ref, prompt_fallback, tu.result.as_ref())
337 }
338 None => tool_invocation_to_delegation(tu),
339 };
340 turn.delegations.push(delegation);
341 }
342
343 turns.push(turn);
344 }
345
346 let leftover: Vec<&ChatFile> = sub_iter.collect();
349 if !leftover.is_empty()
350 && let Some(last_assistant) = turns
351 .iter_mut()
352 .rev()
353 .find(|t| matches!(t.role, Role::Assistant))
354 {
355 for sub in leftover {
356 last_assistant
357 .delegations
358 .push(sub_agent_to_delegation(sub, wd_ref, "", None));
359 }
360 }
361
362 let total_usage = sum_usage(&turns);
363 let files_changed = extract_files_changed(&turns);
364
365 ConversationView {
366 id: convo.session_uuid.clone(),
367 started_at: convo.started_at,
368 last_activity: convo.last_activity,
369 turns,
370 total_usage,
371 provider_id: Some("gemini-cli".into()),
372 files_changed,
373 session_ids: vec![],
374 events: vec![],
375 }
376}
377
378fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
379 let mut total = TokenUsage::default();
380 let mut any = false;
381 for turn in turns {
382 if let Some(u) = &turn.token_usage {
383 any = true;
384 total.input_tokens =
385 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
386 total.output_tokens =
387 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
388 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
389 (Some(a), Some(b)) => Some(a + b),
390 (Some(a), None) => Some(a),
391 (None, Some(b)) => Some(b),
392 (None, None) => None,
393 };
394 }
395 for d in &turn.delegations {
397 for t in &d.turns {
398 if let Some(u) = &t.token_usage {
399 any = true;
400 total.input_tokens =
401 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
402 total.output_tokens =
403 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
404 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
405 (Some(a), Some(b)) => Some(a + b),
406 (Some(a), None) => Some(a),
407 (None, Some(b)) => Some(b),
408 (None, None) => None,
409 };
410 }
411 }
412 }
413 }
414 if any { Some(total) } else { None }
415}
416
417fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
418 let mut seen = std::collections::HashSet::new();
419 let mut files = Vec::new();
420 let push = |tool_use: &ToolInvocation,
421 seen: &mut std::collections::HashSet<String>,
422 files: &mut Vec<String>| {
423 if tool_use.category == Some(ToolCategory::FileWrite)
424 && let Some(path) = file_path_from_args(&tool_use.input)
425 && seen.insert(path.clone())
426 {
427 files.push(path);
428 }
429 };
430 for turn in turns {
431 for tu in &turn.tool_uses {
432 push(tu, &mut seen, &mut files);
433 }
434 for d in &turn.delegations {
435 for t in &d.turns {
436 for tu in &t.tool_uses {
437 push(tu, &mut seen, &mut files);
438 }
439 }
440 }
441 }
442 files
443}
444
445pub(crate) fn file_path_from_args(args: &Value) -> Option<String> {
448 for key in ["file_path", "absolute_path", "path"] {
449 if let Some(v) = args.get(key).and_then(|v| v.as_str()) {
450 return Some(v.to_string());
451 }
452 }
453 None
454}
455
456pub fn to_view(convo: &Conversation) -> ConversationView {
460 conversation_to_view(convo)
461}
462
463pub fn to_turn(msg: &GeminiMessage) -> Turn {
466 message_to_turn(msg, None)
467}
468
469impl ConversationProvider for GeminiConvo {
472 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
473 GeminiConvo::list_conversations(self, project)
474 .map_err(|e| ConvoError::Provider(e.to_string()))
475 }
476
477 fn load_conversation(
478 &self,
479 project: &str,
480 conversation_id: &str,
481 ) -> toolpath_convo::Result<ConversationView> {
482 let convo = self
483 .read_conversation(project, conversation_id)
484 .map_err(|e| ConvoError::Provider(e.to_string()))?;
485 let view = conversation_to_view(&convo);
486 Ok(view)
487 }
488
489 fn load_metadata(
490 &self,
491 project: &str,
492 conversation_id: &str,
493 ) -> toolpath_convo::Result<ConversationMeta> {
494 let meta = self
495 .read_conversation_metadata(project, conversation_id)
496 .map_err(|e| ConvoError::Provider(e.to_string()))?;
497 Ok(ConversationMeta {
498 id: meta.session_uuid,
499 started_at: meta.started_at,
500 last_activity: meta.last_activity,
501 message_count: meta.message_count,
502 file_path: Some(meta.file_path),
503 predecessor: None,
504 successor: None,
505 })
506 }
507
508 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
509 let metas = self
510 .list_conversation_metadata(project)
511 .map_err(|e| ConvoError::Provider(e.to_string()))?;
512 Ok(metas
513 .into_iter()
514 .map(|m| ConversationMeta {
515 id: m.session_uuid,
516 started_at: m.started_at,
517 last_activity: m.last_activity,
518 message_count: m.message_count,
519 file_path: Some(m.file_path),
520 predecessor: None,
521 successor: None,
522 })
523 .collect())
524 }
525}
526
527#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::PathResolver;
533 use std::fs;
534 use tempfile::TempDir;
535
536 fn setup_provider() -> (TempDir, GeminiConvo) {
537 let temp = TempDir::new().unwrap();
538 let gemini = temp.path().join(".gemini");
539 let session_dir = gemini.join("tmp/myrepo/chats/session-uuid");
540 fs::create_dir_all(&session_dir).unwrap();
541 fs::write(
542 gemini.join("projects.json"),
543 r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
544 )
545 .unwrap();
546
547 let main = r#"{
548 "sessionId":"main-s",
549 "projectHash":"h",
550 "startTime":"2026-04-17T15:00:00Z",
551 "lastUpdated":"2026-04-17T15:10:00Z",
552 "directories":["/abs/myrepo"],
553 "messages":[
554 {"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Find the bug"}]},
555 {"id":"m2","timestamp":"2026-04-17T15:00:01Z","type":"gemini","content":"I'll delegate.","model":"gemini-3-flash-preview","tokens":{"input":100,"output":50,"cached":0,"thoughts":10,"tool":0,"total":160},"toolCalls":[
556 {"id":"task-1","name":"task","args":{"prompt":"Find auth bug"},"status":"success","timestamp":"2026-04-17T15:00:01Z","result":[{"functionResponse":{"id":"task-1","name":"task","response":{"output":"Found it"}}}]}
557 ]},
558 {"id":"m3","timestamp":"2026-04-17T15:05:00Z","type":"gemini","content":"Writing fix.","model":"gemini-3-flash-preview","tokens":{"input":200,"output":80,"cached":50,"thoughts":0,"tool":0,"total":330},"toolCalls":[
559 {"id":"write-1","name":"write_file","args":{"file_path":"src/auth.rs","content":"fn ok(){}"},"status":"success","timestamp":"2026-04-17T15:05:00Z","result":[{"functionResponse":{"id":"write-1","name":"write_file","response":{"output":"wrote"}}}]}
560 ]},
561 {"id":"m4","timestamp":"2026-04-17T15:05:05Z","type":"gemini","content":"Oops, fix again.","model":"gemini-3-flash-preview","toolCalls":[
562 {"id":"replace-1","name":"replace","args":{"file_path":"src/auth.rs","oldString":"a","newString":"b"},"status":"success","timestamp":"2026-04-17T15:05:05Z","result":[{"functionResponse":{"id":"replace-1","name":"replace","response":{"output":"ok"}}}]},
563 {"id":"write-2","name":"write_file","args":{"file_path":"src/lib.rs","content":"pub mod auth;"},"status":"success","timestamp":"2026-04-17T15:05:05Z","result":[{"functionResponse":{"id":"write-2","name":"write_file","response":{"output":"wrote"}}}]}
564 ]}
565 ]
566}"#;
567 fs::write(session_dir.join("main.json"), main).unwrap();
568
569 let sub = r#"{
570 "sessionId":"qclszz",
571 "projectHash":"h",
572 "startTime":"2026-04-17T15:01:00Z",
573 "lastUpdated":"2026-04-17T15:04:00Z",
574 "kind":"subagent",
575 "summary":"Found auth bug at line 42",
576 "messages":[
577 {"id":"s1","timestamp":"2026-04-17T15:01:00Z","type":"user","content":[{"text":"Search for auth bug"}]},
578 {"id":"s2","timestamp":"2026-04-17T15:02:00Z","type":"gemini","content":"","thoughts":[{"subject":"Searching","description":"looking in /auth","timestamp":"2026-04-17T15:02:00Z"}],"model":"gemini-3-flash-preview","tokens":{"input":20,"output":5,"cached":0},"toolCalls":[
579 {"id":"qclszz#0-0","name":"grep_search","args":{"pattern":"auth"},"status":"success","timestamp":"2026-04-17T15:02:00Z","result":[{"functionResponse":{"id":"qclszz#0-0","name":"grep_search","response":{"output":"auth.rs:42"}}}]}
580 ]}
581 ]
582}"#;
583 fs::write(session_dir.join("qclszz.json"), sub).unwrap();
584
585 let resolver = PathResolver::new().with_gemini_dir(&gemini);
586 (temp, GeminiConvo::with_resolver(resolver))
587 }
588
589 #[test]
590 fn test_tool_category_mapping() {
591 assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
592 assert_eq!(tool_category("glob"), Some(ToolCategory::FileSearch));
593 assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
594 assert_eq!(tool_category("write_file"), Some(ToolCategory::FileWrite));
595 assert_eq!(tool_category("replace"), Some(ToolCategory::FileWrite));
596 assert_eq!(
597 tool_category("run_shell_command"),
598 Some(ToolCategory::Shell)
599 );
600 assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
601 assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
602 assert_eq!(
603 tool_category("activate_skill"),
604 Some(ToolCategory::Delegation)
605 );
606 assert_eq!(tool_category("unknown"), None);
607 }
608
609 #[test]
610 fn test_load_conversation_basic() {
611 let (_t, p) = setup_provider();
612 let view =
613 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
614 assert_eq!(view.id, "session-uuid");
615 assert_eq!(view.provider_id.as_deref(), Some("gemini-cli"));
616 assert_eq!(view.turns.len(), 4);
617 assert_eq!(view.turns[0].role, Role::User);
618 assert_eq!(view.turns[0].text, "Find the bug");
619 assert_eq!(view.turns[1].role, Role::Assistant);
620 assert_eq!(view.turns[1].text, "I'll delegate.");
621 assert_eq!(
622 view.turns[1].model.as_deref(),
623 Some("gemini-3-flash-preview")
624 );
625 }
626
627 #[test]
628 fn test_delegation_populated_from_sub_agent() {
629 let (_t, p) = setup_provider();
630 let view =
631 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
632 let delegations = &view.turns[1].delegations;
633 assert_eq!(delegations.len(), 1);
634 let d = &delegations[0];
635 assert_eq!(d.agent_id, "qclszz");
636 assert_eq!(d.prompt, "Search for auth bug");
637 assert_eq!(d.result.as_deref(), Some("Found auth bug at line 42"));
638 assert_eq!(d.turns.len(), 2);
640 assert_eq!(d.turns[0].text, "Search for auth bug");
641 }
642
643 #[test]
644 fn test_tool_result_assembled_inline() {
645 let (_t, p) = setup_provider();
646 let view =
647 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
648 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
649 assert_eq!(result.content, "Found it");
650 assert!(!result.is_error);
651 }
652
653 #[test]
654 fn test_tool_category_on_invocations() {
655 let (_t, p) = setup_provider();
656 let view =
657 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
658 assert_eq!(
659 view.turns[1].tool_uses[0].category,
660 Some(ToolCategory::Delegation)
661 );
662 assert_eq!(
663 view.turns[2].tool_uses[0].category,
664 Some(ToolCategory::FileWrite)
665 );
666 }
667
668 #[test]
669 fn test_token_usage_aggregated() {
670 let (_t, p) = setup_provider();
671 let view =
672 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
673 let total = view.total_usage.as_ref().unwrap();
674 assert_eq!(total.input_tokens, Some(320));
676 assert_eq!(total.output_tokens, Some(135));
677 assert_eq!(total.cache_read_tokens, Some(50));
678 }
679
680 #[test]
681 fn test_files_changed() {
682 let (_t, p) = setup_provider();
683 let view =
684 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
685 assert_eq!(
686 view.files_changed,
687 vec!["src/auth.rs".to_string(), "src/lib.rs".to_string()]
688 );
689 }
690
691 #[test]
692 fn test_environment_working_dir() {
693 let (_t, p) = setup_provider();
694 let view =
695 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
696 for turn in &view.turns {
697 let wd = turn
698 .environment
699 .as_ref()
700 .and_then(|e| e.working_dir.as_deref());
701 assert_eq!(wd, Some("/abs/myrepo"));
702 }
703 }
704
705 #[test]
706 fn test_thinking_from_sub_agent_thoughts() {
707 let (_t, p) = setup_provider();
708 let view =
709 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
710 let sub_turn = &view.turns[1].delegations[0].turns[1];
711 let thinking = sub_turn.thinking.as_ref().unwrap();
712 assert!(thinking.contains("Searching"));
713 assert!(thinking.contains("looking in /auth"));
714 }
715
716 #[test]
717 fn test_extra_gemini_tokens_preserved() {
718 let (_t, p) = setup_provider();
719 let view =
720 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
721 let claude = view.turns[1].extra.get("gemini").expect("extra[gemini]");
722 let tokens = claude.get("tokens").unwrap();
723 assert_eq!(tokens["input"], 100);
724 assert_eq!(tokens["thoughts"], 10);
725 assert_eq!(tokens["total"], 160);
726 }
727
728 #[test]
729 fn test_list_metadata() {
730 let (_t, p) = setup_provider();
731 let metas = ConversationProvider::list_metadata(&p, "/abs/myrepo").unwrap();
732 assert_eq!(metas.len(), 1);
733 assert_eq!(metas[0].id, "session-uuid");
734 assert!(metas[0].predecessor.is_none());
736 assert!(metas[0].successor.is_none());
737 }
738
739 #[test]
740 fn test_load_metadata() {
741 let (_t, p) = setup_provider();
742 let meta = ConversationProvider::load_metadata(&p, "/abs/myrepo", "session-uuid").unwrap();
743 assert_eq!(meta.id, "session-uuid");
744 assert_eq!(meta.message_count, 6);
746 }
747
748 #[test]
749 fn test_list_conversations_via_trait() {
750 let (_t, p) = setup_provider();
751 let ids = ConversationProvider::list_conversations(&p, "/abs/myrepo").unwrap();
752 assert_eq!(ids, vec!["session-uuid".to_string()]);
753 }
754
755 #[test]
756 fn test_to_view_directly() {
757 let (_t, p) = setup_provider();
758 let convo = p.read_conversation("/abs/myrepo", "session-uuid").unwrap();
759 let view = to_view(&convo);
760 assert_eq!(view.turns.len(), 4);
761 }
762
763 #[test]
764 fn test_to_turn_single_message() {
765 let json = r#"{"id":"m","timestamp":"ts","type":"user","content":[{"text":"hi"}]}"#;
766 let msg: GeminiMessage = serde_json::from_str(json).unwrap();
767 let turn = to_turn(&msg);
768 assert_eq!(turn.id, "m");
769 assert_eq!(turn.text, "hi");
770 assert_eq!(turn.role, Role::User);
771 }
772
773 #[test]
774 fn test_file_path_from_args_all_keys() {
775 let v1 = serde_json::json!({"file_path": "/a"});
776 let v2 = serde_json::json!({"absolute_path": "/b"});
777 let v3 = serde_json::json!({"path": "/c"});
778 let v4 = serde_json::json!({"something_else": "/d"});
779 assert_eq!(file_path_from_args(&v1).as_deref(), Some("/a"));
780 assert_eq!(file_path_from_args(&v2).as_deref(), Some("/b"));
781 assert_eq!(file_path_from_args(&v3).as_deref(), Some("/c"));
782 assert_eq!(file_path_from_args(&v4), None);
783 }
784
785 #[test]
786 fn test_flatten_thoughts() {
787 let thoughts = vec![
788 Thought {
789 subject: Some("s1".into()),
790 description: Some("d1".into()),
791 timestamp: None,
792 },
793 Thought {
794 subject: None,
795 description: Some("d2".into()),
796 timestamp: None,
797 },
798 Thought {
799 subject: Some("s3".into()),
800 description: None,
801 timestamp: None,
802 },
803 Thought {
804 subject: None,
805 description: None,
806 timestamp: None,
807 },
808 ];
809 let out = flatten_thoughts(&thoughts).unwrap();
810 assert!(out.contains("s1"));
811 assert!(out.contains("d1"));
812 assert!(out.contains("d2"));
813 assert!(out.contains("s3"));
814 }
815
816 #[test]
817 fn test_flatten_thoughts_empty() {
818 assert!(flatten_thoughts(&[]).is_none());
819 }
820
821 #[test]
822 fn test_unused_delegation_fallback() {
823 let temp = TempDir::new().unwrap();
826 let gemini = temp.path().join(".gemini");
827 let session_dir = gemini.join("tmp/p/chats/s");
828 fs::create_dir_all(&session_dir).unwrap();
829 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
830
831 fs::write(
832 session_dir.join("main.json"),
833 r#"{
834 "sessionId":"main",
835 "projectHash":"",
836 "messages":[
837 {"id":"m1","timestamp":"ts","type":"user","content":[{"text":"x"}]},
838 {"id":"m2","timestamp":"ts","type":"gemini","content":"","toolCalls":[
839 {"id":"t1","name":"task","args":{"prompt":"go"},"status":"success","timestamp":"ts","result":[{"functionResponse":{"id":"t1","name":"task","response":{"output":"done"}}}]}
840 ]}
841 ]
842}"#,
843 )
844 .unwrap();
845
846 let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
847 let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap();
848
849 let d = &view.turns[1].delegations[0];
850 assert_eq!(d.agent_id, "t1");
851 assert_eq!(d.prompt, "go");
852 assert_eq!(d.result.as_deref(), Some("done"));
853 assert!(d.turns.is_empty());
854 }
855
856 #[test]
857 fn test_leftover_subagent_attached_to_last_assistant() {
858 let temp = TempDir::new().unwrap();
861 let gemini = temp.path().join(".gemini");
862 let session_dir = gemini.join("tmp/p/chats/s");
863 fs::create_dir_all(&session_dir).unwrap();
864 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
865 fs::write(
866 session_dir.join("main.json"),
867 r#"{"sessionId":"main","projectHash":"","messages":[
868 {"id":"m1","timestamp":"ts","type":"user","content":[{"text":"x"}]},
869 {"id":"m2","timestamp":"ts","type":"gemini","content":"","toolCalls":[
870 {"id":"t1","name":"task","args":{},"status":"success","timestamp":"ts"}
871 ]}
872]}"#,
873 )
874 .unwrap();
875 fs::write(
876 session_dir.join("a.json"),
877 r#"{"sessionId":"a","projectHash":"","startTime":"2026-04-17T10:00:00Z","kind":"subagent","summary":"A","messages":[]}"#,
878 )
879 .unwrap();
880 fs::write(
881 session_dir.join("b.json"),
882 r#"{"sessionId":"b","projectHash":"","startTime":"2026-04-17T11:00:00Z","kind":"subagent","summary":"B","messages":[]}"#,
883 )
884 .unwrap();
885
886 let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
887 let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap();
888 let delegations = &view.turns[1].delegations;
889 assert_eq!(delegations.len(), 2);
890 assert_eq!(delegations[0].agent_id, "a");
892 assert_eq!(delegations[1].agent_id, "b");
893 }
894}