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