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