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