Skip to main content

toolpath_gemini/
provider.rs

1//! Implementation of `toolpath-convo` traits for Gemini CLI conversations.
2//!
3//! Key differences from the Claude implementation:
4//!
5//! - Gemini stores tool results inline on the same message as the call, so
6//!   there's no cross-message "tool-result-only" assembly pass.
7//! - Sub-agent work lives in sibling chat files inside the same session
8//!   UUID directory. When a `task` tool invocation fires in the main
9//!   chat, the matching sub-agent file's turns are populated onto a
10//!   [`DelegatedWork`].
11
12use 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
20// ── Role/tool mapping ────────────────────────────────────────────────
21
22fn 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
31/// Classify a Gemini CLI tool name into toolpath's category ontology.
32///
33/// Returns `None` for unrecognized tools. Keep this table in sync with
34/// <https://geminicli.com/docs/reference/tools>.
35pub 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
48/// Reverse of [`tool_category`]: pick Gemini's preferred native tool name
49/// for a generic [`ToolCategory`], using the call's `args` to disambiguate
50/// when multiple Gemini tools share the same category.
51///
52/// Used by [`crate::project::GeminiProjector`] when projecting tool calls
53/// from foreign harnesses (Claude, Codex, etc.) — we know the category
54/// from the source harness's classifier and need to pick a Gemini name
55/// whose arg shape best matches the call's actual args. Returns `None`
56/// for categories with no obvious Gemini analog.
57pub 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            // `path` (no `file_path`) matches list_directory's arg shape
64            "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            // Edit-family calls carry old_string + new_string; whole-file
75            // writes carry content. Multi-edit shapes (Claude's MultiEdit
76            // edits[]) collapse to `replace` — Gemini has no direct
77            // multi-edit equivalent, but `replace` accepts the args
78            // schema closely enough that the call is still intelligible.
79            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
94// ── Message → Turn ────────────────────────────────────────────────────
95
96fn 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
136/// For each file-write tool call in this message, build a
137/// `FileMutation` with a pre-resolved unified diff. Preference order:
138///   1. Gemini's own `resultDisplay.fileDiff` when present (real diff
139///      computed by the harness).
140///   2. Hand-rolled fallback from `args` (`old_string`/`new_string` for
141///      `replace`, `content` for `write_file`).
142///
143/// `tool_id` links back to the [`ToolCall`].
144fn 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
180/// Synthesize a unified-diff hunk when Gemini's `resultDisplay.fileDiff`
181/// is absent. Not pixel-perfect but enough to give readers a change
182/// perspective.
183fn 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
258// ── Delegation wiring ────────────────────────────────────────────────
259
260/// Build a `DelegatedWork` from a sub-agent chat file, optionally using
261/// a parent `ToolInvocation`'s fields as a fallback.
262fn 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
303/// Build a fallback `DelegatedWork` from a `ToolInvocation` when no
304/// sub-agent file was found — mirrors the Claude provider's behaviour.
305fn 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
319// ── Conversation → View ──────────────────────────────────────────────
320
321fn 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    // Sort sub-agents by start_time (deterministic attachment).
332    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 each delegation-category tool invocation, try to pull the
342        // next sub-agent off the queue.
343        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    // Leftover sub-agents (no matching task invocation) attach to the
365    // last assistant turn, or get dropped if there is none.
366    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    // Gemini's wire format doesn't carry parent_id on messages, so link
381    // turns sequentially. (Matches the old `derive_path_from_view`,
382    // which used `last_step_id` as the parent for each new step.)
383    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        // Also walk sub-agent turns inside delegations.
437        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
486/// Pull the file path out of a tool's `args`. Gemini uses different key
487/// names depending on the tool (`file_path`, `path`, or `absolute_path`).
488pub(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
497// ── Public conversion helpers ────────────────────────────────────────
498
499/// Convert a Gemini [`Conversation`] into a [`ConversationView`].
500pub fn to_view(convo: &Conversation) -> ConversationView {
501    conversation_to_view(convo)
502}
503
504/// Convert a single [`GeminiMessage`] into a [`Turn`]. Does not perform
505/// any cross-message sub-agent assembly; call [`to_view`] for that.
506pub fn to_turn(msg: &GeminiMessage) -> Turn {
507    message_to_turn(msg, None)
508}
509
510// ── Trait impls ──────────────────────────────────────────────────────
511
512impl 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// ── Tests ────────────────────────────────────────────────────────────
569
570#[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        // Sub-agent turns are populated, unlike Claude
680        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        // Main turns: (100,50), (200,80). Sub-agent turn: (20,5).
716        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        // Chains are not a thing in Gemini — link fields stay None.
764        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        // 4 main messages + 2 sub-agent messages
774        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        // If sub-agent file is missing, delegation still emits from the
853        // tool_use fields.
854        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        // Two sub-agents but only one `task` call — the second sub-agent
888        // attaches to the last assistant turn.
889        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        // a.json attaches to the task (first delegation), b.json is leftover
920        assert_eq!(delegations[0].agent_id, "a");
921        assert_eq!(delegations[1].agent_id, "b");
922    }
923}