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, 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
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(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
133/// Map Gemini's on-disk [`Tokens`] struct onto the provider-agnostic
134/// [`TokenUsage`].
135///
136/// Gemini records `thoughts` (reasoning) as a **separate additive**
137/// counter, sibling to `output` — across real sessions
138/// `total == input + output + thoughts` exactly, and the format doc
139/// describes `output` as "generated tokens *excluding reasoning*."
140/// Google bills reasoning as output, so we fold `thoughts` into
141/// `output_tokens` (same convention as opencode) — that way the IR's
142/// `output` means "all generated tokens" and the session total isn't
143/// under-counted.
144///
145/// The folded reasoning slice is *also* recorded under
146/// `breakdowns["output"]["reasoning"]`. It's INFORMATIONAL: breakdowns
147/// are never summed into the total (output already counts it), and the
148/// invariant `Σ(inner) = reasoning ≤ output` holds because we fold the
149/// same number in. It's also what lets the projector un-fold reasoning
150/// back out of `output_tokens` (see `tokens_from_common`), so the
151/// `output`/`thoughts` split round-trips losslessly. The entry is
152/// recorded whenever `thoughts` is present (including a genuine `Some(0)`),
153/// preserving the `Some(0)` vs `None` distinction; when `thoughts` is
154/// absent the map stays empty and is omitted from serialization.
155///
156/// `tool` is prompt-side (tool-result tokens billed separately) and
157/// `total` is a Gemini-side sum; neither is folded here — both remain
158/// available raw via `Turn.extra["gemini"]["tokens"]`.
159fn 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        // Fold reasoning into output (additive in Gemini — billed as
167        // output). None only when both output and thoughts are
168        // absent/zero, mirroring the per-field Option semantics.
169        output_tokens: if generated == 0 {
170            None
171        } else {
172            Some(generated)
173        },
174        cache_read_tokens: t.cached,
175        cache_write_tokens: None,
176        ..Default::default()
177    };
178
179    // Memoize the reasoning slice folded into output so the projector can
180    // un-fold it back out losslessly. Recorded whenever `thoughts` is
181    // present — including a genuine `Some(0)` — so the projector
182    // reconstructs `Some(0)` rather than `None`; absent only when the
183    // source had no reasoning counter at all.
184    if let Some(thoughts) = t.thoughts {
185        usage
186            .breakdowns
187            .entry("output".to_string())
188            .or_default()
189            .insert("reasoning".to_string(), thoughts);
190    }
191
192    usage
193}
194
195/// For each file-write tool call in this message, build a
196/// `FileMutation` with a pre-resolved unified diff. Preference order:
197///   1. Gemini's own `resultDisplay.fileDiff` when present (real diff
198///      computed by the harness).
199///   2. Hand-rolled fallback from `args` (`old_string`/`new_string` for
200///      `replace`, `content` for `write_file`).
201///
202/// `tool_id` links back to the [`ToolCall`].
203fn compute_file_mutations(calls: &[ToolCall]) -> Vec<toolpath_convo::FileMutation> {
204    let mut out = Vec::new();
205    for call in calls {
206        if tool_category(&call.name) != Some(ToolCategory::FileWrite) {
207            continue;
208        }
209        let Some(path) = file_path_from_args(&call.args) else {
210            continue;
211        };
212        let raw_diff = call.file_diff().or_else(|| fallback_raw_diff(call));
213        let operation = match call.name.as_str() {
214            "write_file" => Some("add".to_string()),
215            "replace" | "edit" => Some("update".to_string()),
216            _ => Some(call.name.clone()),
217        };
218        let after = match call.name.as_str() {
219            "write_file" => call
220                .args
221                .get("content")
222                .and_then(|v| v.as_str())
223                .map(|s| s.to_string()),
224            _ => None,
225        };
226        out.push(toolpath_convo::FileMutation {
227            path,
228            tool_id: Some(call.id.clone()),
229            operation,
230            raw_diff,
231            before: None,
232            after,
233            rename_to: None,
234        });
235    }
236    out
237}
238
239/// Synthesize a unified-diff hunk when Gemini's `resultDisplay.fileDiff`
240/// is absent. Not pixel-perfect but enough to give readers a change
241/// perspective.
242fn fallback_raw_diff(call: &ToolCall) -> Option<String> {
243    match call.name.as_str() {
244        "replace" => {
245            let old_s = call.args.get("old_string").and_then(|v| v.as_str())?;
246            let new_s = call.args.get("new_string").and_then(|v| v.as_str())?;
247            let old_lines: Vec<&str> = old_s.split('\n').collect();
248            let new_lines: Vec<&str> = new_s.split('\n').collect();
249            let mut buf = format!("@@ -1,{} +1,{} @@\n", old_lines.len(), new_lines.len());
250            for l in old_lines {
251                buf.push('-');
252                buf.push_str(l);
253                buf.push('\n');
254            }
255            for l in new_lines {
256                buf.push('+');
257                buf.push_str(l);
258                buf.push('\n');
259            }
260            Some(buf)
261        }
262        "write_file" => {
263            let content = call.args.get("content").and_then(|v| v.as_str())?;
264            let lines: Vec<&str> = content.split('\n').collect();
265            let mut buf = format!("@@ -0,0 +1,{} @@\n", lines.len());
266            for l in lines {
267                buf.push('+');
268                buf.push_str(l);
269                buf.push('\n');
270            }
271            Some(buf)
272        }
273        _ => None,
274    }
275}
276
277fn flatten_thoughts(thoughts: &[Thought]) -> Option<String> {
278    if thoughts.is_empty() {
279        return None;
280    }
281    let joined: Vec<String> = thoughts
282        .iter()
283        .filter_map(|t| match (&t.subject, &t.description) {
284            (Some(s), Some(d)) => Some(format!("**{}**\n{}", s, d)),
285            (Some(s), None) => Some(s.clone()),
286            (None, Some(d)) => Some(d.clone()),
287            (None, None) => None,
288        })
289        .collect();
290    if joined.is_empty() {
291        None
292    } else {
293        Some(joined.join("\n\n"))
294    }
295}
296
297fn tool_call_to_invocation(call: &ToolCall) -> ToolInvocation {
298    let text = call.result_text();
299    let is_error = call.is_error();
300    let result = if call.result.is_empty() && !is_error {
301        None
302    } else {
303        Some(ToolResult {
304            content: text,
305            is_error,
306        })
307    };
308    ToolInvocation {
309        id: call.id.clone(),
310        name: call.name.clone(),
311        input: call.args.clone(),
312        result,
313        category: tool_category(&call.name),
314    }
315}
316
317// ── Delegation wiring ────────────────────────────────────────────────
318
319/// Build a `DelegatedWork` from a sub-agent chat file, optionally using
320/// a parent `ToolInvocation`'s fields as a fallback.
321fn sub_agent_to_delegation(
322    sub: &ChatFile,
323    working_dir: Option<&str>,
324    fallback_prompt: &str,
325    fallback_result: Option<&ToolResult>,
326) -> DelegatedWork {
327    let turns: Vec<Turn> = sub
328        .messages
329        .iter()
330        .map(|m| message_to_turn(m, working_dir))
331        .collect();
332
333    let prompt = first_user_text(sub).unwrap_or_else(|| fallback_prompt.to_string());
334
335    let result = sub
336        .summary
337        .clone()
338        .or_else(|| fallback_result.map(|r| r.content.clone()));
339
340    let agent_id = if sub.session_id.is_empty() {
341        format!("subagent-{}", turns.len())
342    } else {
343        sub.session_id.clone()
344    };
345
346    DelegatedWork {
347        agent_id,
348        prompt,
349        turns,
350        result,
351    }
352}
353
354fn first_user_text(chat: &ChatFile) -> Option<String> {
355    chat.messages
356        .iter()
357        .find(|m| m.role == GeminiRole::User)
358        .map(|m| m.content.text())
359        .filter(|t| !t.is_empty())
360}
361
362/// Build a fallback `DelegatedWork` from a `ToolInvocation` when no
363/// sub-agent file was found — mirrors the Claude provider's behaviour.
364fn tool_invocation_to_delegation(tu: &ToolInvocation) -> DelegatedWork {
365    DelegatedWork {
366        agent_id: tu.id.clone(),
367        prompt: tu
368            .input
369            .get("prompt")
370            .and_then(|v| v.as_str())
371            .unwrap_or("")
372            .to_string(),
373        turns: vec![],
374        result: tu.result.as_ref().map(|r| r.content.clone()),
375    }
376}
377
378// ── Conversation → View ──────────────────────────────────────────────
379
380fn conversation_to_view(convo: &Conversation) -> ConversationView {
381    let working_dir: Option<String> = convo.project_path.clone().or_else(|| {
382        convo
383            .main
384            .directories()
385            .first()
386            .map(|p| p.to_string_lossy().to_string())
387    });
388    let wd_ref = working_dir.as_deref();
389
390    // Sort sub-agents by start_time (deterministic attachment).
391    let mut sub_order: Vec<&ChatFile> = convo.sub_agents.iter().collect();
392    sub_order.sort_by_key(|s| s.start_time);
393    let mut sub_iter = sub_order.into_iter();
394
395    let mut turns: Vec<Turn> = Vec::with_capacity(convo.main.messages.len());
396
397    for msg in &convo.main.messages {
398        let mut turn = message_to_turn(msg, wd_ref);
399
400        // For each delegation-category tool invocation, try to pull the
401        // next sub-agent off the queue.
402        for tu in &turn.tool_uses {
403            if tu.category != Some(ToolCategory::Delegation) {
404                continue;
405            }
406            let delegation = match sub_iter.next() {
407                Some(sub) => {
408                    let prompt_fallback = tu
409                        .input
410                        .get("prompt")
411                        .and_then(|v| v.as_str())
412                        .unwrap_or("");
413                    sub_agent_to_delegation(sub, wd_ref, prompt_fallback, tu.result.as_ref())
414                }
415                None => tool_invocation_to_delegation(tu),
416            };
417            turn.delegations.push(delegation);
418        }
419
420        turns.push(turn);
421    }
422
423    // Leftover sub-agents (no matching task invocation) attach to the
424    // last assistant turn, or get dropped if there is none.
425    let leftover: Vec<&ChatFile> = sub_iter.collect();
426    if !leftover.is_empty()
427        && let Some(last_assistant) = turns
428            .iter_mut()
429            .rev()
430            .find(|t| matches!(t.role, Role::Assistant))
431    {
432        for sub in leftover {
433            last_assistant
434                .delegations
435                .push(sub_agent_to_delegation(sub, wd_ref, "", None));
436        }
437    }
438
439    // Gemini's wire format doesn't carry parent_id on messages, so link
440    // turns sequentially. (Matches the old `derive_path_from_view`,
441    // which used `last_step_id` as the parent for each new step.)
442    let mut prev: Option<String> = None;
443    for t in turns.iter_mut() {
444        if t.parent_id.is_none() {
445            t.parent_id = prev.clone();
446        }
447        prev = Some(t.id.clone());
448    }
449
450    let total_usage = sum_usage(&turns);
451    let files_changed = extract_files_changed(&turns);
452
453    let view_base = working_dir.as_ref().map(|wd| toolpath_convo::SessionBase {
454        working_dir: Some(wd.clone()),
455        vcs_revision: None,
456        vcs_branch: None,
457        vcs_remote: None,
458    });
459
460    ConversationView {
461        id: convo.session_uuid.clone(),
462        started_at: convo.started_at,
463        last_activity: convo.last_activity,
464        turns,
465        total_usage,
466        provider_id: Some("gemini-cli".into()),
467        files_changed,
468        session_ids: vec![],
469        events: vec![],
470        base: view_base,
471        producer: Some(toolpath_convo::ProducerInfo {
472            name: "gemini-cli".into(),
473            version: None,
474        }),
475    }
476}
477
478fn sum_usage(turns: &[Turn]) -> Option<TokenUsage> {
479    let mut total = TokenUsage::default();
480    let mut any = false;
481    for turn in turns {
482        if let Some(u) = &turn.token_usage {
483            any = true;
484            total.input_tokens =
485                Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
486            total.output_tokens =
487                Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
488            total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
489                (Some(a), Some(b)) => Some(a + b),
490                (Some(a), None) => Some(a),
491                (None, Some(b)) => Some(b),
492                (None, None) => None,
493            };
494        }
495        // Also walk sub-agent turns inside delegations.
496        for d in &turn.delegations {
497            for t in &d.turns {
498                if let Some(u) = &t.token_usage {
499                    any = true;
500                    total.input_tokens =
501                        Some(total.input_tokens.unwrap_or(0) + u.input_tokens.unwrap_or(0));
502                    total.output_tokens =
503                        Some(total.output_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
504                    total.cache_read_tokens = match (total.cache_read_tokens, u.cache_read_tokens) {
505                        (Some(a), Some(b)) => Some(a + b),
506                        (Some(a), None) => Some(a),
507                        (None, Some(b)) => Some(b),
508                        (None, None) => None,
509                    };
510                }
511            }
512        }
513    }
514    if any { Some(total) } else { None }
515}
516
517fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
518    let mut seen = std::collections::HashSet::new();
519    let mut files = Vec::new();
520    let push = |tool_use: &ToolInvocation,
521                seen: &mut std::collections::HashSet<String>,
522                files: &mut Vec<String>| {
523        if tool_use.category == Some(ToolCategory::FileWrite)
524            && let Some(path) = file_path_from_args(&tool_use.input)
525            && seen.insert(path.clone())
526        {
527            files.push(path);
528        }
529    };
530    for turn in turns {
531        for tu in &turn.tool_uses {
532            push(tu, &mut seen, &mut files);
533        }
534        for d in &turn.delegations {
535            for t in &d.turns {
536                for tu in &t.tool_uses {
537                    push(tu, &mut seen, &mut files);
538                }
539            }
540        }
541    }
542    files
543}
544
545/// Pull the file path out of a tool's `args`. Gemini uses different key
546/// names depending on the tool (`file_path`, `path`, or `absolute_path`).
547pub(crate) fn file_path_from_args(args: &Value) -> Option<String> {
548    for key in ["file_path", "absolute_path", "path"] {
549        if let Some(v) = args.get(key).and_then(|v| v.as_str()) {
550            return Some(v.to_string());
551        }
552    }
553    None
554}
555
556// ── Public conversion helpers ────────────────────────────────────────
557
558/// Convert a Gemini [`Conversation`] into a [`ConversationView`].
559pub fn to_view(convo: &Conversation) -> ConversationView {
560    conversation_to_view(convo)
561}
562
563/// Convert a single [`GeminiMessage`] into a [`Turn`]. Does not perform
564/// any cross-message sub-agent assembly; call [`to_view`] for that.
565pub fn to_turn(msg: &GeminiMessage) -> Turn {
566    message_to_turn(msg, None)
567}
568
569// ── Trait impls ──────────────────────────────────────────────────────
570
571impl ConversationProvider for GeminiConvo {
572    fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
573        GeminiConvo::list_conversations(self, project)
574            .map_err(|e| ConvoError::Provider(e.to_string()))
575    }
576
577    fn load_conversation(
578        &self,
579        project: &str,
580        conversation_id: &str,
581    ) -> toolpath_convo::Result<ConversationView> {
582        let convo = self
583            .read_conversation(project, conversation_id)
584            .map_err(|e| ConvoError::Provider(e.to_string()))?;
585        let view = conversation_to_view(&convo);
586        Ok(view)
587    }
588
589    fn load_metadata(
590        &self,
591        project: &str,
592        conversation_id: &str,
593    ) -> toolpath_convo::Result<ConversationMeta> {
594        let meta = self
595            .read_conversation_metadata(project, conversation_id)
596            .map_err(|e| ConvoError::Provider(e.to_string()))?;
597        Ok(ConversationMeta {
598            id: meta.session_uuid,
599            started_at: meta.started_at,
600            last_activity: meta.last_activity,
601            message_count: meta.message_count,
602            file_path: Some(meta.file_path),
603            predecessor: None,
604            successor: None,
605        })
606    }
607
608    fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
609        let metas = self
610            .list_conversation_metadata(project)
611            .map_err(|e| ConvoError::Provider(e.to_string()))?;
612        Ok(metas
613            .into_iter()
614            .map(|m| ConversationMeta {
615                id: m.session_uuid,
616                started_at: m.started_at,
617                last_activity: m.last_activity,
618                message_count: m.message_count,
619                file_path: Some(m.file_path),
620                predecessor: None,
621                successor: None,
622            })
623            .collect())
624    }
625}
626
627// ── Tests ────────────────────────────────────────────────────────────
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::PathResolver;
633    use std::fs;
634    use tempfile::TempDir;
635
636    fn setup_provider() -> (TempDir, GeminiConvo) {
637        let temp = TempDir::new().unwrap();
638        let gemini = temp.path().join(".gemini");
639        let session_dir = gemini.join("tmp/myrepo/chats/session-uuid");
640        fs::create_dir_all(&session_dir).unwrap();
641        fs::write(
642            gemini.join("projects.json"),
643            r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
644        )
645        .unwrap();
646
647        let main = r#"{
648  "sessionId":"main-s",
649  "projectHash":"h",
650  "startTime":"2026-04-17T15:00:00Z",
651  "lastUpdated":"2026-04-17T15:10:00Z",
652  "directories":["/abs/myrepo"],
653  "messages":[
654    {"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Find the bug"}]},
655    {"id":"m2","timestamp":"2026-04-17T15:00:01Z","type":"gemini","content":"I'll delegate.","model":"gemini-3-flash-preview","tokens":{"input":100,"output":50,"cached":0,"thoughts":10,"tool":0,"total":160},"toolCalls":[
656      {"id":"task-1","name":"task","args":{"prompt":"Find auth bug"},"status":"success","timestamp":"2026-04-17T15:00:01Z","result":[{"functionResponse":{"id":"task-1","name":"task","response":{"output":"Found it"}}}]}
657    ]},
658    {"id":"m3","timestamp":"2026-04-17T15:05:00Z","type":"gemini","content":"Writing fix.","model":"gemini-3-flash-preview","tokens":{"input":200,"output":80,"cached":50,"thoughts":0,"tool":0,"total":330},"toolCalls":[
659      {"id":"write-1","name":"write_file","args":{"file_path":"src/auth.rs","content":"fn ok(){}"},"status":"success","timestamp":"2026-04-17T15:05:00Z","result":[{"functionResponse":{"id":"write-1","name":"write_file","response":{"output":"wrote"}}}]}
660    ]},
661    {"id":"m4","timestamp":"2026-04-17T15:05:05Z","type":"gemini","content":"Oops, fix again.","model":"gemini-3-flash-preview","toolCalls":[
662      {"id":"replace-1","name":"replace","args":{"file_path":"src/auth.rs","oldString":"a","newString":"b"},"status":"success","timestamp":"2026-04-17T15:05:05Z","result":[{"functionResponse":{"id":"replace-1","name":"replace","response":{"output":"ok"}}}]},
663      {"id":"write-2","name":"write_file","args":{"file_path":"src/lib.rs","content":"pub mod auth;"},"status":"success","timestamp":"2026-04-17T15:05:05Z","result":[{"functionResponse":{"id":"write-2","name":"write_file","response":{"output":"wrote"}}}]}
664    ]}
665  ]
666}"#;
667        fs::write(session_dir.join("main.json"), main).unwrap();
668
669        let sub = r#"{
670  "sessionId":"qclszz",
671  "projectHash":"h",
672  "startTime":"2026-04-17T15:01:00Z",
673  "lastUpdated":"2026-04-17T15:04:00Z",
674  "kind":"subagent",
675  "summary":"Found auth bug at line 42",
676  "messages":[
677    {"id":"s1","timestamp":"2026-04-17T15:01:00Z","type":"user","content":[{"text":"Search for auth bug"}]},
678    {"id":"s2","timestamp":"2026-04-17T15:02:00Z","type":"gemini","content":"","thoughts":[{"subject":"Searching","description":"looking in /auth","timestamp":"2026-04-17T15:02:00Z"}],"model":"gemini-3-flash-preview","tokens":{"input":20,"output":5,"cached":0},"toolCalls":[
679      {"id":"qclszz#0-0","name":"grep_search","args":{"pattern":"auth"},"status":"success","timestamp":"2026-04-17T15:02:00Z","result":[{"functionResponse":{"id":"qclszz#0-0","name":"grep_search","response":{"output":"auth.rs:42"}}}]}
680    ]}
681  ]
682}"#;
683        fs::write(session_dir.join("qclszz.json"), sub).unwrap();
684
685        let resolver = PathResolver::new().with_gemini_dir(&gemini);
686        (temp, GeminiConvo::with_resolver(resolver))
687    }
688
689    #[test]
690    fn test_tool_category_mapping() {
691        assert_eq!(tool_category("read_file"), Some(ToolCategory::FileRead));
692        assert_eq!(tool_category("glob"), Some(ToolCategory::FileSearch));
693        assert_eq!(tool_category("grep_search"), Some(ToolCategory::FileSearch));
694        assert_eq!(tool_category("write_file"), Some(ToolCategory::FileWrite));
695        assert_eq!(tool_category("replace"), Some(ToolCategory::FileWrite));
696        assert_eq!(
697            tool_category("run_shell_command"),
698            Some(ToolCategory::Shell)
699        );
700        assert_eq!(tool_category("web_fetch"), Some(ToolCategory::Network));
701        assert_eq!(tool_category("task"), Some(ToolCategory::Delegation));
702        assert_eq!(
703            tool_category("activate_skill"),
704            Some(ToolCategory::Delegation)
705        );
706        assert_eq!(tool_category("unknown"), None);
707    }
708
709    #[test]
710    fn test_load_conversation_basic() {
711        let (_t, p) = setup_provider();
712        let view =
713            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
714        assert_eq!(view.id, "session-uuid");
715        assert_eq!(view.provider_id.as_deref(), Some("gemini-cli"));
716        assert_eq!(view.turns.len(), 4);
717        assert_eq!(view.turns[0].role, Role::User);
718        assert_eq!(view.turns[0].text, "Find the bug");
719        assert_eq!(view.turns[1].role, Role::Assistant);
720        assert_eq!(view.turns[1].text, "I'll delegate.");
721        assert_eq!(
722            view.turns[1].model.as_deref(),
723            Some("gemini-3-flash-preview")
724        );
725    }
726
727    #[test]
728    fn test_delegation_populated_from_sub_agent() {
729        let (_t, p) = setup_provider();
730        let view =
731            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
732        let delegations = &view.turns[1].delegations;
733        assert_eq!(delegations.len(), 1);
734        let d = &delegations[0];
735        assert_eq!(d.agent_id, "qclszz");
736        assert_eq!(d.prompt, "Search for auth bug");
737        assert_eq!(d.result.as_deref(), Some("Found auth bug at line 42"));
738        // Sub-agent turns are populated, unlike Claude
739        assert_eq!(d.turns.len(), 2);
740        assert_eq!(d.turns[0].text, "Search for auth bug");
741    }
742
743    #[test]
744    fn test_tool_result_assembled_inline() {
745        let (_t, p) = setup_provider();
746        let view =
747            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
748        let result = view.turns[1].tool_uses[0].result.as_ref().unwrap();
749        assert_eq!(result.content, "Found it");
750        assert!(!result.is_error);
751    }
752
753    #[test]
754    fn test_tool_category_on_invocations() {
755        let (_t, p) = setup_provider();
756        let view =
757            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
758        assert_eq!(
759            view.turns[1].tool_uses[0].category,
760            Some(ToolCategory::Delegation)
761        );
762        assert_eq!(
763            view.turns[2].tool_uses[0].category,
764            Some(ToolCategory::FileWrite)
765        );
766    }
767
768    #[test]
769    fn test_token_usage_aggregated() {
770        let (_t, p) = setup_provider();
771        let view =
772            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
773        let total = view.total_usage.as_ref().unwrap();
774        // Main turns: input/(output+thoughts) = (100, 50+10), (200, 80+0).
775        // Sub-agent turn: (20, 5+0). thoughts is additive reasoning, folded
776        // into output (billed as output by Google).
777        assert_eq!(total.input_tokens, Some(320));
778        assert_eq!(total.output_tokens, Some(145));
779        assert_eq!(total.cache_read_tokens, Some(50));
780    }
781
782    #[test]
783    fn test_thoughts_folded_into_output_with_breakdown() {
784        // Real-fixture-shaped numbers: total == input + output + thoughts
785        // (8665 + 94 + 243 == 9002). output EXCLUDES reasoning, so folding
786        // gives output_tokens = 94 + 243 = 337, and the reasoning slice is
787        // recorded under breakdowns["output"]["reasoning"] = 243 (≤ output).
788        let t = Tokens {
789            input: Some(8665),
790            output: Some(94),
791            cached: Some(0),
792            thoughts: Some(243),
793            tool: Some(0),
794            total: Some(9002),
795        };
796        let u = tokens_to_usage(&t);
797        assert_eq!(u.input_tokens, Some(8665));
798        assert_eq!(u.output_tokens, Some(337));
799        let reasoning = u
800            .breakdowns
801            .get("output")
802            .and_then(|m| m.get("reasoning"))
803            .copied();
804        assert_eq!(reasoning, Some(243));
805        // reasoning ≤ output invariant holds.
806        assert!(reasoning.unwrap() <= u.output_tokens.unwrap());
807    }
808
809    #[test]
810    fn test_present_zero_thoughts_records_zero_breakdown() {
811        // thoughts == Some(0) → breakdown records reasoning: 0 so the
812        // projector reconstructs Some(0) (not None) on the reverse path.
813        // output_tokens is unchanged (folding 0 is a no-op).
814        let t = Tokens {
815            input: Some(200),
816            output: Some(80),
817            cached: Some(50),
818            thoughts: Some(0),
819            tool: Some(0),
820            total: Some(330),
821        };
822        let u = tokens_to_usage(&t);
823        assert_eq!(u.output_tokens, Some(80));
824        assert_eq!(
825            u.breakdowns.get("output").and_then(|m| m.get("reasoning")),
826            Some(&0)
827        );
828    }
829
830    #[test]
831    fn test_absent_thoughts_yields_no_breakdown() {
832        // thoughts absent (Gemini 2.5) → treated as 0: no breakdown,
833        // output_tokens == output.
834        let t = Tokens {
835            input: Some(20),
836            output: Some(5),
837            cached: Some(0),
838            thoughts: None,
839            tool: None,
840            total: None,
841        };
842        let u = tokens_to_usage(&t);
843        assert_eq!(u.output_tokens, Some(5));
844        assert!(u.breakdowns.is_empty());
845    }
846
847    #[test]
848    fn test_zero_output_and_thoughts_yields_none_output() {
849        // Both output and thoughts zero → output_tokens None (mirrors the
850        // per-field Option semantics; no fabricated zero). thoughts is
851        // present (Some(0)), so the breakdown still records reasoning: 0
852        // for lossless round-trip of the Some(0) distinction.
853        let t = Tokens {
854            input: Some(100),
855            output: Some(0),
856            cached: Some(0),
857            thoughts: Some(0),
858            tool: Some(0),
859            total: Some(100),
860        };
861        let u = tokens_to_usage(&t);
862        assert_eq!(u.output_tokens, None);
863        assert_eq!(
864            u.breakdowns.get("output").and_then(|m| m.get("reasoning")),
865            Some(&0)
866        );
867
868        // And the fully-absent case: thoughts None → no breakdown.
869        let empty = Tokens::default();
870        let u2 = tokens_to_usage(&empty);
871        assert_eq!(u2.output_tokens, None);
872        assert!(u2.breakdowns.is_empty());
873    }
874
875    #[test]
876    fn test_files_changed() {
877        let (_t, p) = setup_provider();
878        let view =
879            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
880        assert_eq!(
881            view.files_changed,
882            vec!["src/auth.rs".to_string(), "src/lib.rs".to_string()]
883        );
884    }
885
886    #[test]
887    fn test_environment_working_dir() {
888        let (_t, p) = setup_provider();
889        let view =
890            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
891        for turn in &view.turns {
892            let wd = turn
893                .environment
894                .as_ref()
895                .and_then(|e| e.working_dir.as_deref());
896            assert_eq!(wd, Some("/abs/myrepo"));
897        }
898    }
899
900    #[test]
901    fn test_thinking_from_sub_agent_thoughts() {
902        let (_t, p) = setup_provider();
903        let view =
904            ConversationProvider::load_conversation(&p, "/abs/myrepo", "session-uuid").unwrap();
905        let sub_turn = &view.turns[1].delegations[0].turns[1];
906        let thinking = sub_turn.thinking.as_ref().unwrap();
907        assert!(thinking.contains("Searching"));
908        assert!(thinking.contains("looking in /auth"));
909    }
910
911    #[test]
912    fn test_list_metadata() {
913        let (_t, p) = setup_provider();
914        let metas = ConversationProvider::list_metadata(&p, "/abs/myrepo").unwrap();
915        assert_eq!(metas.len(), 1);
916        assert_eq!(metas[0].id, "session-uuid");
917        // Chains are not a thing in Gemini — link fields stay None.
918        assert!(metas[0].predecessor.is_none());
919        assert!(metas[0].successor.is_none());
920    }
921
922    #[test]
923    fn test_load_metadata() {
924        let (_t, p) = setup_provider();
925        let meta = ConversationProvider::load_metadata(&p, "/abs/myrepo", "session-uuid").unwrap();
926        assert_eq!(meta.id, "session-uuid");
927        // 4 main messages + 2 sub-agent messages
928        assert_eq!(meta.message_count, 6);
929    }
930
931    #[test]
932    fn test_list_conversations_via_trait() {
933        let (_t, p) = setup_provider();
934        let ids = ConversationProvider::list_conversations(&p, "/abs/myrepo").unwrap();
935        assert_eq!(ids, vec!["session-uuid".to_string()]);
936    }
937
938    #[test]
939    fn test_to_view_directly() {
940        let (_t, p) = setup_provider();
941        let convo = p.read_conversation("/abs/myrepo", "session-uuid").unwrap();
942        let view = to_view(&convo);
943        assert_eq!(view.turns.len(), 4);
944    }
945
946    #[test]
947    fn test_to_turn_single_message() {
948        let json = r#"{"id":"m","timestamp":"ts","type":"user","content":[{"text":"hi"}]}"#;
949        let msg: GeminiMessage = serde_json::from_str(json).unwrap();
950        let turn = to_turn(&msg);
951        assert_eq!(turn.id, "m");
952        assert_eq!(turn.text, "hi");
953        assert_eq!(turn.role, Role::User);
954    }
955
956    #[test]
957    fn test_file_path_from_args_all_keys() {
958        let v1 = serde_json::json!({"file_path": "/a"});
959        let v2 = serde_json::json!({"absolute_path": "/b"});
960        let v3 = serde_json::json!({"path": "/c"});
961        let v4 = serde_json::json!({"something_else": "/d"});
962        assert_eq!(file_path_from_args(&v1).as_deref(), Some("/a"));
963        assert_eq!(file_path_from_args(&v2).as_deref(), Some("/b"));
964        assert_eq!(file_path_from_args(&v3).as_deref(), Some("/c"));
965        assert_eq!(file_path_from_args(&v4), None);
966    }
967
968    #[test]
969    fn test_flatten_thoughts() {
970        let thoughts = vec![
971            Thought {
972                subject: Some("s1".into()),
973                description: Some("d1".into()),
974                timestamp: None,
975            },
976            Thought {
977                subject: None,
978                description: Some("d2".into()),
979                timestamp: None,
980            },
981            Thought {
982                subject: Some("s3".into()),
983                description: None,
984                timestamp: None,
985            },
986            Thought {
987                subject: None,
988                description: None,
989                timestamp: None,
990            },
991        ];
992        let out = flatten_thoughts(&thoughts).unwrap();
993        assert!(out.contains("s1"));
994        assert!(out.contains("d1"));
995        assert!(out.contains("d2"));
996        assert!(out.contains("s3"));
997    }
998
999    #[test]
1000    fn test_flatten_thoughts_empty() {
1001        assert!(flatten_thoughts(&[]).is_none());
1002    }
1003
1004    #[test]
1005    fn test_unused_delegation_fallback() {
1006        // If sub-agent file is missing, delegation still emits from the
1007        // tool_use fields.
1008        let temp = TempDir::new().unwrap();
1009        let gemini = temp.path().join(".gemini");
1010        let session_dir = gemini.join("tmp/p/chats/s");
1011        fs::create_dir_all(&session_dir).unwrap();
1012        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
1013
1014        fs::write(
1015            session_dir.join("main.json"),
1016            r#"{
1017  "sessionId":"main",
1018  "projectHash":"",
1019  "messages":[
1020    {"id":"m1","timestamp":"ts","type":"user","content":[{"text":"x"}]},
1021    {"id":"m2","timestamp":"ts","type":"gemini","content":"","toolCalls":[
1022      {"id":"t1","name":"task","args":{"prompt":"go"},"status":"success","timestamp":"ts","result":[{"functionResponse":{"id":"t1","name":"task","response":{"output":"done"}}}]}
1023    ]}
1024  ]
1025}"#,
1026        )
1027        .unwrap();
1028
1029        let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
1030        let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap();
1031
1032        let d = &view.turns[1].delegations[0];
1033        assert_eq!(d.agent_id, "t1");
1034        assert_eq!(d.prompt, "go");
1035        assert_eq!(d.result.as_deref(), Some("done"));
1036        assert!(d.turns.is_empty());
1037    }
1038
1039    #[test]
1040    fn test_leftover_subagent_attached_to_last_assistant() {
1041        // Two sub-agents but only one `task` call — the second sub-agent
1042        // attaches to the last assistant turn.
1043        let temp = TempDir::new().unwrap();
1044        let gemini = temp.path().join(".gemini");
1045        let session_dir = gemini.join("tmp/p/chats/s");
1046        fs::create_dir_all(&session_dir).unwrap();
1047        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
1048        fs::write(
1049            session_dir.join("main.json"),
1050            r#"{"sessionId":"main","projectHash":"","messages":[
1051  {"id":"m1","timestamp":"ts","type":"user","content":[{"text":"x"}]},
1052  {"id":"m2","timestamp":"ts","type":"gemini","content":"","toolCalls":[
1053    {"id":"t1","name":"task","args":{},"status":"success","timestamp":"ts"}
1054  ]}
1055]}"#,
1056        )
1057        .unwrap();
1058        fs::write(
1059            session_dir.join("a.json"),
1060            r#"{"sessionId":"a","projectHash":"","startTime":"2026-04-17T10:00:00Z","kind":"subagent","summary":"A","messages":[]}"#,
1061        )
1062        .unwrap();
1063        fs::write(
1064            session_dir.join("b.json"),
1065            r#"{"sessionId":"b","projectHash":"","startTime":"2026-04-17T11:00:00Z","kind":"subagent","summary":"B","messages":[]}"#,
1066        )
1067        .unwrap();
1068
1069        let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
1070        let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap();
1071        let delegations = &view.turns[1].delegations;
1072        assert_eq!(delegations.len(), 2);
1073        // a.json attaches to the task (first delegation), b.json is leftover
1074        assert_eq!(delegations[0].agent_id, "a");
1075        assert_eq!(delegations[1].agent_id, "b");
1076    }
1077}