1use crate::GeminiConvo;
13use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, Thought, Tokens, 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(tokens_to_usage);
107
108 let environment = working_dir.map(|wd| EnvironmentSnapshot {
109 working_dir: Some(wd.to_string()),
110 vcs_branch: None,
111 vcs_revision: None,
112 });
113
114 Turn {
115 id: msg.id.clone(),
116 parent_id: None,
117 group_id: None,
118 role: gemini_role_to_role(&msg.role),
119 timestamp: msg.timestamp.clone(),
120 text,
121 thinking,
122 tool_uses,
123 model: msg.model.clone(),
124 stop_reason: None,
125 token_usage,
126 attributed_token_usage: None,
127 environment,
128 delegations: vec![],
129 file_mutations,
130 }
131}
132
133fn tokens_to_usage(t: &Tokens) -> TokenUsage {
160 let output = t.output.unwrap_or(0);
161 let thoughts = t.thoughts.unwrap_or(0);
162 let generated = output.saturating_add(thoughts);
163
164 let mut usage = TokenUsage {
165 input_tokens: t.input,
166 output_tokens: if generated == 0 {
170 None
171 } else {
172 Some(generated)
173 },
174 cache_read_tokens: t.cached,
175 cache_write_tokens: None,
176 ..Default::default()
177 };
178
179 if let Some(thoughts) = t.thoughts {
185 usage
186 .breakdowns
187 .entry("output".to_string())
188 .or_default()
189 .insert("reasoning".to_string(), thoughts);
190 }
191
192 usage
193}
194
195fn compute_file_mutations(calls: &[ToolCall]) -> Vec<toolpath_convo::FileMutation> {
204 let mut out = Vec::new();
205 for call in calls {
206 if tool_category(&call.name) != Some(ToolCategory::FileWrite) {
207 continue;
208 }
209 let Some(path) = file_path_from_args(&call.args) else {
210 continue;
211 };
212 let raw_diff = call.file_diff().or_else(|| fallback_raw_diff(call));
213 let operation = match call.name.as_str() {
214 "write_file" => Some("add".to_string()),
215 "replace" | "edit" => Some("update".to_string()),
216 _ => Some(call.name.clone()),
217 };
218 let after = match call.name.as_str() {
219 "write_file" => call
220 .args
221 .get("content")
222 .and_then(|v| v.as_str())
223 .map(|s| s.to_string()),
224 _ => None,
225 };
226 out.push(toolpath_convo::FileMutation {
227 path,
228 tool_id: Some(call.id.clone()),
229 operation,
230 raw_diff,
231 before: None,
232 after,
233 rename_to: None,
234 });
235 }
236 out
237}
238
239fn fallback_raw_diff(call: &ToolCall) -> Option<String> {
243 match call.name.as_str() {
244 "replace" => {
245 let old_s = call.args.get("old_string").and_then(|v| v.as_str())?;
246 let new_s = call.args.get("new_string").and_then(|v| v.as_str())?;
247 let old_lines: Vec<&str> = old_s.split('\n').collect();
248 let new_lines: Vec<&str> = new_s.split('\n').collect();
249 let mut buf = format!("@@ -1,{} +1,{} @@\n", old_lines.len(), new_lines.len());
250 for l in old_lines {
251 buf.push('-');
252 buf.push_str(l);
253 buf.push('\n');
254 }
255 for l in new_lines {
256 buf.push('+');
257 buf.push_str(l);
258 buf.push('\n');
259 }
260 Some(buf)
261 }
262 "write_file" => {
263 let content = call.args.get("content").and_then(|v| v.as_str())?;
264 let lines: Vec<&str> = content.split('\n').collect();
265 let mut buf = format!("@@ -0,0 +1,{} @@\n", lines.len());
266 for l in lines {
267 buf.push('+');
268 buf.push_str(l);
269 buf.push('\n');
270 }
271 Some(buf)
272 }
273 _ => None,
274 }
275}
276
277fn flatten_thoughts(thoughts: &[Thought]) -> Option<String> {
278 if thoughts.is_empty() {
279 return None;
280 }
281 let joined: Vec<String> = thoughts
282 .iter()
283 .filter_map(|t| match (&t.subject, &t.description) {
284 (Some(s), Some(d)) => Some(format!("**{}**\n{}", s, d)),
285 (Some(s), None) => Some(s.clone()),
286 (None, Some(d)) => Some(d.clone()),
287 (None, None) => None,
288 })
289 .collect();
290 if joined.is_empty() {
291 None
292 } else {
293 Some(joined.join("\n\n"))
294 }
295}
296
297fn tool_call_to_invocation(call: &ToolCall) -> ToolInvocation {
298 let text = call.result_text();
299 let is_error = call.is_error();
300 let result = if call.result.is_empty() && !is_error {
301 None
302 } else {
303 Some(ToolResult {
304 content: text,
305 is_error,
306 })
307 };
308 ToolInvocation {
309 id: call.id.clone(),
310 name: call.name.clone(),
311 input: call.args.clone(),
312 result,
313 category: tool_category(&call.name),
314 }
315}
316
317fn sub_agent_to_delegation(
322 sub: &ChatFile,
323 working_dir: Option<&str>,
324 fallback_prompt: &str,
325 fallback_result: Option<&ToolResult>,
326) -> DelegatedWork {
327 let turns: Vec<Turn> = sub
328 .messages
329 .iter()
330 .map(|m| message_to_turn(m, working_dir))
331 .collect();
332
333 let prompt = first_user_text(sub).unwrap_or_else(|| fallback_prompt.to_string());
334
335 let result = sub
336 .summary
337 .clone()
338 .or_else(|| fallback_result.map(|r| r.content.clone()));
339
340 let agent_id = if sub.session_id.is_empty() {
341 format!("subagent-{}", turns.len())
342 } else {
343 sub.session_id.clone()
344 };
345
346 DelegatedWork {
347 agent_id,
348 prompt,
349 turns,
350 result,
351 }
352}
353
354fn first_user_text(chat: &ChatFile) -> Option<String> {
355 chat.messages
356 .iter()
357 .find(|m| m.role == GeminiRole::User)
358 .map(|m| m.content.text())
359 .filter(|t| !t.is_empty())
360}
361
362fn tool_invocation_to_delegation(tu: &ToolInvocation) -> DelegatedWork {
365 DelegatedWork {
366 agent_id: tu.id.clone(),
367 prompt: tu
368 .input
369 .get("prompt")
370 .and_then(|v| v.as_str())
371 .unwrap_or("")
372 .to_string(),
373 turns: vec![],
374 result: tu.result.as_ref().map(|r| r.content.clone()),
375 }
376}
377
378fn conversation_to_view(convo: &Conversation) -> ConversationView {
381 let working_dir: Option<String> = convo.project_path.clone().or_else(|| {
382 convo
383 .main
384 .directories()
385 .first()
386 .map(|p| p.to_string_lossy().to_string())
387 });
388 let wd_ref = working_dir.as_deref();
389
390 let mut sub_order: Vec<&ChatFile> = convo.sub_agents.iter().collect();
392 sub_order.sort_by_key(|s| s.start_time);
393 let mut sub_iter = sub_order.into_iter();
394
395 let mut turns: Vec<Turn> = Vec::with_capacity(convo.main.messages.len());
396
397 for msg in &convo.main.messages {
398 let mut turn = message_to_turn(msg, wd_ref);
399
400 for tu in &turn.tool_uses {
403 if tu.category != Some(ToolCategory::Delegation) {
404 continue;
405 }
406 let delegation = match sub_iter.next() {
407 Some(sub) => {
408 let prompt_fallback = tu
409 .input
410 .get("prompt")
411 .and_then(|v| v.as_str())
412 .unwrap_or("");
413 sub_agent_to_delegation(sub, wd_ref, prompt_fallback, tu.result.as_ref())
414 }
415 None => tool_invocation_to_delegation(tu),
416 };
417 turn.delegations.push(delegation);
418 }
419
420 turns.push(turn);
421 }
422
423 let leftover: Vec<&ChatFile> = sub_iter.collect();
426 if !leftover.is_empty()
427 && let Some(last_assistant) = turns
428 .iter_mut()
429 .rev()
430 .find(|t| matches!(t.role, Role::Assistant))
431 {
432 for sub in leftover {
433 last_assistant
434 .delegations
435 .push(sub_agent_to_delegation(sub, wd_ref, "", None));
436 }
437 }
438
439 let mut prev: Option<String> = None;
443 for t in turns.iter_mut() {
444 if t.parent_id.is_none() {
445 t.parent_id = prev.clone();
446 }
447 prev = Some(t.id.clone());
448 }
449
450 let total_usage = sum_usage(&turns);
451 let files_changed = extract_files_changed(&turns);
452
453 let view_base = working_dir.as_ref().map(|wd| toolpath_convo::SessionBase {
454 working_dir: Some(wd.clone()),
455 vcs_revision: None,
456 vcs_branch: None,
457 vcs_remote: None,
458 });
459
460 ConversationView {
461 id: convo.session_uuid.clone(),
462 started_at: convo.started_at,
463 last_activity: convo.last_activity,
464 turns,
465 total_usage,
466 provider_id: Some("gemini-cli".into()),
467 files_changed,
468 session_ids: vec![],
469 events: vec![],
470 base: view_base,
471 producer: Some(toolpath_convo::ProducerInfo {
472 name: "gemini-cli".into(),
473 version: None,
474 }),
475 }
476}
477
478fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
479 let mut total = TokenUsage::default();
480 let mut any = false;
481 for turn in turns {
482 if let Some(u) = &turn.token_usage {
483 any = true;
484 total.input_tokens =
485 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
486 total.output_tokens =
487 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
488 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
489 (Some(a), Some(b)) => Some(a + b),
490 (Some(a), None) => Some(a),
491 (None, Some(b)) => Some(b),
492 (None, None) => None,
493 };
494 }
495 for d in &turn.delegations {
497 for t in &d.turns {
498 if let Some(u) = &t.token_usage {
499 any = true;
500 total.input_tokens =
501 Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
502 total.output_tokens =
503 Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
504 total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
505 (Some(a), Some(b)) => Some(a + b),
506 (Some(a), None) => Some(a),
507 (None, Some(b)) => Some(b),
508 (None, None) => None,
509 };
510 }
511 }
512 }
513 }
514 if any { Some(total) } else { None }
515}
516
517fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
518 let mut seen = std::collections::HashSet::new();
519 let mut files = Vec::new();
520 let push = |tool_use: &ToolInvocation,
521 seen: &mut std::collections::HashSet<String>,
522 files: &mut Vec<String>| {
523 if tool_use.category == Some(ToolCategory::FileWrite)
524 && let Some(path) = file_path_from_args(&tool_use.input)
525 && seen.insert(path.clone())
526 {
527 files.push(path);
528 }
529 };
530 for turn in turns {
531 for tu in &turn.tool_uses {
532 push(tu, &mut seen, &mut files);
533 }
534 for d in &turn.delegations {
535 for t in &d.turns {
536 for tu in &t.tool_uses {
537 push(tu, &mut seen, &mut files);
538 }
539 }
540 }
541 }
542 files
543}
544
545pub(crate) fn file_path_from_args(args: &Value) -> Option<String> {
548 for key in ["file_path", "absolute_path", "path"] {
549 if let Some(v) = args.get(key).and_then(|v| v.as_str()) {
550 return Some(v.to_string());
551 }
552 }
553 None
554}
555
556pub fn to_view(convo: &Conversation) -> ConversationView {
560 conversation_to_view(convo)
561}
562
563pub fn to_turn(msg: &GeminiMessage) -> Turn {
566 message_to_turn(msg, None)
567}
568
569impl ConversationProvider for GeminiConvo {
572 fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
573 GeminiConvo::list_conversations(self, project)
574 .map_err(|e| ConvoError::Provider(e.to_string()))
575 }
576
577 fn load_conversation(
578 &self,
579 project: &str,
580 conversation_id: &str,
581 ) -> toolpath_convo::Result<ConversationView> {
582 let convo = self
583 .read_conversation(project, conversation_id)
584 .map_err(|e| ConvoError::Provider(e.to_string()))?;
585 let view = conversation_to_view(&convo);
586 Ok(view)
587 }
588
589 fn load_metadata(
590 &self,
591 project: &str,
592 conversation_id: &str,
593 ) -> toolpath_convo::Result<ConversationMeta> {
594 let meta = self
595 .read_conversation_metadata(project, conversation_id)
596 .map_err(|e| ConvoError::Provider(e.to_string()))?;
597 Ok(ConversationMeta {
598 id: meta.session_uuid,
599 started_at: meta.started_at,
600 last_activity: meta.last_activity,
601 message_count: meta.message_count,
602 file_path: Some(meta.file_path),
603 predecessor: None,
604 successor: None,
605 })
606 }
607
608 fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
609 let metas = self
610 .list_conversation_metadata(project)
611 .map_err(|e| ConvoError::Provider(e.to_string()))?;
612 Ok(metas
613 .into_iter()
614 .map(|m| ConversationMeta {
615 id: m.session_uuid,
616 started_at: m.started_at,
617 last_activity: m.last_activity,
618 message_count: m.message_count,
619 file_path: Some(m.file_path),
620 predecessor: None,
621 successor: None,
622 })
623 .collect())
624 }
625}
626
627#[cfg(test)]
630mod tests {
631 use super::*;
632 use crate::PathResolver;
633 use std::fs;
634 use tempfile::TempDir;
635
636 fn setup_provider() -> (TempDir, GeminiConvo) {
637 let temp = TempDir::new().unwrap();
638 let gemini = temp.path().join(".gemini");
639 let session_dir = gemini.join("tmp/myrepo/chats/session-uuid");
640 fs::create_dir_all(&session_dir).unwrap();
641 fs::write(
642 gemini.join("projects.json"),
643 r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
644 )
645 .unwrap();
646
647 let main = r#"{
648 "sessionId":"main-s",
649 "projectHash":"h",
650 "startTime":"2026-04-17T15:00:00Z",
651 "lastUpdated":"2026-04-17T15:10:00Z",
652 "directories":["/abs/myrepo"],
653 "messages":[
654 {"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Find the bug"}]},
655 {"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":[
656 {"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"}}}]}
657 ]},
658 {"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":[
659 {"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"}}}]}
660 ]},
661 {"id":"m4","timestamp":"2026-04-17T15:05:05Z","type":"gemini","content":"Oops, fix again.","model":"gemini-3-flash-preview","toolCalls":[
662 {"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"}}}]},
663 {"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"}}}]}
664 ]}
665 ]
666}"#;
667 fs::write(session_dir.join("main.json"), main).unwrap();
668
669 let sub = r#"{
670 "sessionId":"qclszz",
671 "projectHash":"h",
672 "startTime":"2026-04-17T15:01:00Z",
673 "lastUpdated":"2026-04-17T15:04:00Z",
674 "kind":"subagent",
675 "summary":"Found auth bug at line 42",
676 "messages":[
677 {"id":"s1","timestamp":"2026-04-17T15:01:00Z","type":"user","content":[{"text":"Search for auth bug"}]},
678 {"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":[
679 {"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"}}}]}
680 ]}
681 ]
682}"#;
683 fs::write(session_dir.join("qclszz.json"), sub).unwrap();
684
685 let resolver = PathResolver::new().with_gemini_dir(&gemini);
686 (temp, GeminiConvo::with_resolver(resolver))
687 }
688
689 #[test]
690 fn test_tool_category_mapping() {
691 assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
692 assert_eq!(tool_category("glob"), Some(ToolCategory::FileSearch));
693 assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
694 assert_eq!(tool_category("write_file"), Some(ToolCategory::FileWrite));
695 assert_eq!(tool_category("replace"), Some(ToolCategory::FileWrite));
696 assert_eq!(
697 tool_category("run_shell_command"),
698 Some(ToolCategory::Shell)
699 );
700 assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
701 assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
702 assert_eq!(
703 tool_category("activate_skill"),
704 Some(ToolCategory::Delegation)
705 );
706 assert_eq!(tool_category("unknown"), None);
707 }
708
709 #[test]
710 fn test_load_conversation_basic() {
711 let (_t, p) = setup_provider();
712 let view =
713 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
714 assert_eq!(view.id, "session-uuid");
715 assert_eq!(view.provider_id.as_deref(), Some("gemini-cli"));
716 assert_eq!(view.turns.len(), 4);
717 assert_eq!(view.turns[0].role, Role::User);
718 assert_eq!(view.turns[0].text, "Find the bug");
719 assert_eq!(view.turns[1].role, Role::Assistant);
720 assert_eq!(view.turns[1].text, "I'll delegate.");
721 assert_eq!(
722 view.turns[1].model.as_deref(),
723 Some("gemini-3-flash-preview")
724 );
725 }
726
727 #[test]
728 fn test_delegation_populated_from_sub_agent() {
729 let (_t, p) = setup_provider();
730 let view =
731 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
732 let delegations = &view.turns[1].delegations;
733 assert_eq!(delegations.len(), 1);
734 let d = &delegations[0];
735 assert_eq!(d.agent_id, "qclszz");
736 assert_eq!(d.prompt, "Search for auth bug");
737 assert_eq!(d.result.as_deref(), Some("Found auth bug at line 42"));
738 assert_eq!(d.turns.len(), 2);
740 assert_eq!(d.turns[0].text, "Search for auth bug");
741 }
742
743 #[test]
744 fn test_tool_result_assembled_inline() {
745 let (_t, p) = setup_provider();
746 let view =
747 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
748 let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
749 assert_eq!(result.content, "Found it");
750 assert!(!result.is_error);
751 }
752
753 #[test]
754 fn test_tool_category_on_invocations() {
755 let (_t, p) = setup_provider();
756 let view =
757 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
758 assert_eq!(
759 view.turns[1].tool_uses[0].category,
760 Some(ToolCategory::Delegation)
761 );
762 assert_eq!(
763 view.turns[2].tool_uses[0].category,
764 Some(ToolCategory::FileWrite)
765 );
766 }
767
768 #[test]
769 fn test_token_usage_aggregated() {
770 let (_t, p) = setup_provider();
771 let view =
772 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
773 let total = view.total_usage.as_ref().unwrap();
774 assert_eq!(total.input_tokens, Some(320));
778 assert_eq!(total.output_tokens, Some(145));
779 assert_eq!(total.cache_read_tokens, Some(50));
780 }
781
782 #[test]
783 fn test_thoughts_folded_into_output_with_breakdown() {
784 let t = Tokens {
789 input: Some(8665),
790 output: Some(94),
791 cached: Some(0),
792 thoughts: Some(243),
793 tool: Some(0),
794 total: Some(9002),
795 };
796 let u = tokens_to_usage(&t);
797 assert_eq!(u.input_tokens, Some(8665));
798 assert_eq!(u.output_tokens, Some(337));
799 let reasoning = u
800 .breakdowns
801 .get("output")
802 .and_then(|m| m.get("reasoning"))
803 .copied();
804 assert_eq!(reasoning, Some(243));
805 assert!(reasoning.unwrap() <= u.output_tokens.unwrap());
807 }
808
809 #[test]
810 fn test_present_zero_thoughts_records_zero_breakdown() {
811 let t = Tokens {
815 input: Some(200),
816 output: Some(80),
817 cached: Some(50),
818 thoughts: Some(0),
819 tool: Some(0),
820 total: Some(330),
821 };
822 let u = tokens_to_usage(&t);
823 assert_eq!(u.output_tokens, Some(80));
824 assert_eq!(
825 u.breakdowns.get("output").and_then(|m| m.get("reasoning")),
826 Some(&0)
827 );
828 }
829
830 #[test]
831 fn test_absent_thoughts_yields_no_breakdown() {
832 let t = Tokens {
835 input: Some(20),
836 output: Some(5),
837 cached: Some(0),
838 thoughts: None,
839 tool: None,
840 total: None,
841 };
842 let u = tokens_to_usage(&t);
843 assert_eq!(u.output_tokens, Some(5));
844 assert!(u.breakdowns.is_empty());
845 }
846
847 #[test]
848 fn test_zero_output_and_thoughts_yields_none_output() {
849 let t = Tokens {
854 input: Some(100),
855 output: Some(0),
856 cached: Some(0),
857 thoughts: Some(0),
858 tool: Some(0),
859 total: Some(100),
860 };
861 let u = tokens_to_usage(&t);
862 assert_eq!(u.output_tokens, None);
863 assert_eq!(
864 u.breakdowns.get("output").and_then(|m| m.get("reasoning")),
865 Some(&0)
866 );
867
868 let empty = Tokens::default();
870 let u2 = tokens_to_usage(&empty);
871 assert_eq!(u2.output_tokens, None);
872 assert!(u2.breakdowns.is_empty());
873 }
874
875 #[test]
876 fn test_files_changed() {
877 let (_t, p) = setup_provider();
878 let view =
879 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
880 assert_eq!(
881 view.files_changed,
882 vec!["src/auth.rs".to_string(), "src/lib.rs".to_string()]
883 );
884 }
885
886 #[test]
887 fn test_environment_working_dir() {
888 let (_t, p) = setup_provider();
889 let view =
890 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
891 for turn in &view.turns {
892 let wd = turn
893 .environment
894 .as_ref()
895 .and_then(|e| e.working_dir.as_deref());
896 assert_eq!(wd, Some("/abs/myrepo"));
897 }
898 }
899
900 #[test]
901 fn test_thinking_from_sub_agent_thoughts() {
902 let (_t, p) = setup_provider();
903 let view =
904 ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
905 let sub_turn = &view.turns[1].delegations[0].turns[1];
906 let thinking = sub_turn.thinking.as_ref().unwrap();
907 assert!(thinking.contains("Searching"));
908 assert!(thinking.contains("looking in /auth"));
909 }
910
911 #[test]
912 fn test_list_metadata() {
913 let (_t, p) = setup_provider();
914 let metas = ConversationProvider::list_metadata(&p, "/abs/myrepo").unwrap();
915 assert_eq!(metas.len(), 1);
916 assert_eq!(metas[0].id, "session-uuid");
917 assert!(metas[0].predecessor.is_none());
919 assert!(metas[0].successor.is_none());
920 }
921
922 #[test]
923 fn test_load_metadata() {
924 let (_t, p) = setup_provider();
925 let meta = ConversationProvider::load_metadata(&p, "/abs/myrepo", "session-uuid").unwrap();
926 assert_eq!(meta.id, "session-uuid");
927 assert_eq!(meta.message_count, 6);
929 }
930
931 #[test]
932 fn test_list_conversations_via_trait() {
933 let (_t, p) = setup_provider();
934 let ids = ConversationProvider::list_conversations(&p, "/abs/myrepo").unwrap();
935 assert_eq!(ids, vec!["session-uuid".to_string()]);
936 }
937
938 #[test]
939 fn test_to_view_directly() {
940 let (_t, p) = setup_provider();
941 let convo = p.read_conversation("/abs/myrepo", "session-uuid").unwrap();
942 let view = to_view(&convo);
943 assert_eq!(view.turns.len(), 4);
944 }
945
946 #[test]
947 fn test_to_turn_single_message() {
948 let json = r#"{"id":"m","timestamp":"ts","type":"user","content":[{"text":"hi"}]}"#;
949 let msg: GeminiMessage = serde_json::from_str(json).unwrap();
950 let turn = to_turn(&msg);
951 assert_eq!(turn.id, "m");
952 assert_eq!(turn.text, "hi");
953 assert_eq!(turn.role, Role::User);
954 }
955
956 #[test]
957 fn test_file_path_from_args_all_keys() {
958 let v1 = serde_json::json!({"file_path": "/a"});
959 let v2 = serde_json::json!({"absolute_path": "/b"});
960 let v3 = serde_json::json!({"path": "/c"});
961 let v4 = serde_json::json!({"something_else": "/d"});
962 assert_eq!(file_path_from_args(&v1).as_deref(), Some("/a"));
963 assert_eq!(file_path_from_args(&v2).as_deref(), Some("/b"));
964 assert_eq!(file_path_from_args(&v3).as_deref(), Some("/c"));
965 assert_eq!(file_path_from_args(&v4), None);
966 }
967
968 #[test]
969 fn test_flatten_thoughts() {
970 let thoughts = vec![
971 Thought {
972 subject: Some("s1".into()),
973 description: Some("d1".into()),
974 timestamp: None,
975 },
976 Thought {
977 subject: None,
978 description: Some("d2".into()),
979 timestamp: None,
980 },
981 Thought {
982 subject: Some("s3".into()),
983 description: None,
984 timestamp: None,
985 },
986 Thought {
987 subject: None,
988 description: None,
989 timestamp: None,
990 },
991 ];
992 let out = flatten_thoughts(&thoughts).unwrap();
993 assert!(out.contains("s1"));
994 assert!(out.contains("d1"));
995 assert!(out.contains("d2"));
996 assert!(out.contains("s3"));
997 }
998
999 #[test]
1000 fn test_flatten_thoughts_empty() {
1001 assert!(flatten_thoughts(&[]).is_none());
1002 }
1003
1004 #[test]
1005 fn test_unused_delegation_fallback() {
1006 let temp = TempDir::new().unwrap();
1009 let gemini = temp.path().join(".gemini");
1010 let session_dir = gemini.join("tmp/p/chats/s");
1011 fs::create_dir_all(&session_dir).unwrap();
1012 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
1013
1014 fs::write(
1015 session_dir.join("main.json"),
1016 r#"{
1017 "sessionId":"main",
1018 "projectHash":"",
1019 "messages":[
1020 {"id":"m1","timestamp":"ts","type":"user","content":[{"text":"x"}]},
1021 {"id":"m2","timestamp":"ts","type":"gemini","content":"","toolCalls":[
1022 {"id":"t1","name":"task","args":{"prompt":"go"},"status":"success","timestamp":"ts","result":[{"functionResponse":{"id":"t1","name":"task","response":{"output":"done"}}}]}
1023 ]}
1024 ]
1025}"#,
1026 )
1027 .unwrap();
1028
1029 let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
1030 let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap();
1031
1032 let d = &view.turns[1].delegations[0];
1033 assert_eq!(d.agent_id, "t1");
1034 assert_eq!(d.prompt, "go");
1035 assert_eq!(d.result.as_deref(), Some("done"));
1036 assert!(d.turns.is_empty());
1037 }
1038
1039 #[test]
1040 fn test_leftover_subagent_attached_to_last_assistant() {
1041 let temp = TempDir::new().unwrap();
1044 let gemini = temp.path().join(".gemini");
1045 let session_dir = gemini.join("tmp/p/chats/s");
1046 fs::create_dir_all(&session_dir).unwrap();
1047 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
1048 fs::write(
1049 session_dir.join("main.json"),
1050 r#"{"sessionId":"main","projectHash":"","messages":[
1051 {"id":"m1","timestamp":"ts","type":"user","content":[{"text":"x"}]},
1052 {"id":"m2","timestamp":"ts","type":"gemini","content":"","toolCalls":[
1053 {"id":"t1","name":"task","args":{},"status":"success","timestamp":"ts"}
1054 ]}
1055]}"#,
1056 )
1057 .unwrap();
1058 fs::write(
1059 session_dir.join("a.json"),
1060 r#"{"sessionId":"a","projectHash":"","startTime":"2026-04-17T10:00:00Z","kind":"subagent","summary":"A","messages":[]}"#,
1061 )
1062 .unwrap();
1063 fs::write(
1064 session_dir.join("b.json"),
1065 r#"{"sessionId":"b","projectHash":"","startTime":"2026-04-17T11:00:00Z","kind":"subagent","summary":"B","messages":[]}"#,
1066 )
1067 .unwrap();
1068
1069 let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
1070 let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap();
1071 let delegations = &view.turns[1].delegations;
1072 assert_eq!(delegations.len(), 2);
1073 assert_eq!(delegations[0].agent_id, "a");
1075 assert_eq!(delegations[1].agent_id, "b");
1076 }
1077}