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