Skip to main content

toolpath_gemini/
project.rs

1//! [`GeminiProjector`] — maps a [`ConversationView`] back to a Gemini
2//! [`Conversation`].
3//!
4//! This is the inverse of [`crate::provider::to_view`]: where `to_view`
5//! reads a Gemini session directory into a provider-agnostic view,
6//! `GeminiProjector` serializes that view back into the on-disk chat
7//! format. Round-tripping is best-effort and lossy on Gemini-only
8//! fields (full token breakdown, per-thought timestamps, per-tool-call
9//! status/displayName) — the IR no longer carries them.
10
11use std::collections::HashMap;
12
13use serde_json::{Map, Value};
14use toolpath_convo::{
15    ConversationProjector, ConversationView, ConvoError, DelegatedWork, Result, Role, TokenUsage,
16    ToolCategory, ToolInvocation, Turn,
17};
18
19use crate::types::{
20    ChatFile, Conversation, FunctionResponse, FunctionResponseBody, GeminiContent, GeminiMessage,
21    GeminiRole, TextPart, Thought, Tokens, ToolCall,
22};
23
24// ── GeminiProjector ───────────────────────────────────────────────────
25
26/// Project a [`ConversationView`] into a Gemini [`Conversation`].
27///
28/// Config fields are optional — pass them when you want to populate
29/// file-level metadata that doesn't live on `ConversationView` (the
30/// project hash and absolute project path). None-valued fields fall
31/// through to empty strings / defaults, which Gemini CLI accepts.
32///
33/// # Example
34///
35/// ```rust
36/// use toolpath_gemini::project::GeminiProjector;
37/// use toolpath_convo::{ConversationProjector, ConversationView};
38///
39/// let view = ConversationView {
40///     id: "session-uuid".into(),
41///     provider_id: Some("gemini-cli".into()),
42///     ..Default::default()
43/// };
44///
45/// let projector = GeminiProjector::default();
46/// let convo = projector.project(&view).unwrap();
47/// assert_eq!(convo.session_uuid, "session-uuid");
48/// ```
49#[derive(Debug, Clone, Default)]
50pub struct GeminiProjector {
51    /// SHA-256 hex of the absolute project path. Round-trip callers
52    /// should preserve the original value; new sessions can leave it `None`.
53    pub project_hash: Option<String>,
54    /// Absolute project path for [`Conversation::project_path`].
55    pub project_path: Option<String>,
56}
57
58impl GeminiProjector {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    pub fn with_project_hash(mut self, hash: impl Into<String>) -> Self {
64        self.project_hash = Some(hash.into());
65        self
66    }
67
68    pub fn with_project_path(mut self, path: impl Into<String>) -> Self {
69        self.project_path = Some(path.into());
70        self
71    }
72}
73
74impl ConversationProjector for GeminiProjector {
75    type Output = Conversation;
76
77    fn project(&self, view: &ConversationView) -> Result<Conversation> {
78        project_view(self, view).map_err(ConvoError::Provider)
79    }
80}
81
82// ── Projection logic ─────────────────────────────────────────────────
83
84fn project_view(
85    cfg: &GeminiProjector,
86    view: &ConversationView,
87) -> std::result::Result<Conversation, String> {
88    let project_hash = cfg.project_hash.clone().unwrap_or_default();
89
90    let mut main_messages: Vec<GeminiMessage> = Vec::with_capacity(view.turns.len());
91    let mut sub_agents: Vec<ChatFile> = Vec::new();
92
93    for turn in &view.turns {
94        main_messages.push(turn_to_message(turn));
95
96        for delegation in &turn.delegations {
97            sub_agents.push(delegation_to_chat_file(delegation, &project_hash));
98        }
99    }
100
101    // Gemini's main chat file carries the session UUID in `sessionId`,
102    // `kind: "main"`, and any project directories the session ran
103    // against. Real `--resume <uuid>` matches against this `sessionId`
104    // field — so the value here must be the full UUID, not a derived
105    // short slug.
106    let directories = cfg
107        .project_path
108        .as_ref()
109        .map(|p| vec![std::path::PathBuf::from(p)]);
110
111    let main = ChatFile {
112        session_id: view.id.clone(),
113        project_hash: project_hash.clone(),
114        start_time: view.started_at,
115        last_updated: view.last_activity,
116        directories,
117        kind: Some("main".to_string()),
118        summary: None,
119        messages: main_messages,
120        extra: HashMap::new(),
121    };
122
123    Ok(Conversation {
124        session_uuid: view.id.clone(),
125        project_path: cfg.project_path.clone(),
126        main,
127        sub_agents,
128        started_at: view.started_at,
129        last_activity: view.last_activity,
130    })
131}
132
133// ── Turn → GeminiMessage ─────────────────────────────────────────────
134
135fn turn_to_message(turn: &Turn) -> GeminiMessage {
136    // `Turn.extra` is gone; previously the Gemini projector pulled
137    // `extra["gemini"]` for structured thought meta, full tokens, and
138    // per-tool-call status. With that source removed, `build_thoughts` /
139    // `build_tokens` / `build_tool_calls` fall back to the typed IR
140    // fields (`Turn.thinking` as a string, `Turn.token_usage`, etc.).
141    let gemini_extras: Map<String, Value> = Map::new();
142    let msg_extras: HashMap<String, Value> = HashMap::new();
143
144    GeminiMessage {
145        id: turn.id.clone(),
146        timestamp: turn.timestamp.clone(),
147        role: role_to_gemini_role(&turn.role),
148        content: build_content(turn),
149        thoughts: build_thoughts(turn, &gemini_extras),
150        tokens: build_tokens(turn, &gemini_extras),
151        model: turn.model.clone(),
152        tool_calls: build_tool_calls(turn, &gemini_extras),
153        extra: msg_extras,
154    }
155}
156
157fn role_to_gemini_role(role: &Role) -> GeminiRole {
158    match role {
159        Role::User => GeminiRole::User,
160        Role::Assistant => GeminiRole::Gemini,
161        Role::System => GeminiRole::Info,
162        Role::Other(s) => GeminiRole::Other(s.clone()),
163    }
164}
165
166/// Pick the wire-format content shape based on role.
167///
168/// Gemini's real sessions carry user turns as `Parts([{text}])` and
169/// assistant turns as `Text(String)` (sometimes empty, when the payload
170/// lives in `toolCalls`). Info/Other turns use `Text`.
171fn build_content(turn: &Turn) -> GeminiContent {
172    match turn.role {
173        Role::User => GeminiContent::Parts(vec![TextPart {
174            text: Some(turn.text.clone()),
175            extra: HashMap::new(),
176        }]),
177        _ => GeminiContent::Text(turn.text.clone()),
178    }
179}
180
181/// Rebuild `Thought[]`.
182///
183/// Preferred source: `extra["gemini"]["thoughts_meta"]`, which carries
184/// `{subject, description, timestamp}` triples written by the forward
185/// path. Falls back to splitting `Turn.thinking` on `"\n\n"` and
186/// extracting subject/description from the `**subject**\n{description}`
187/// shape used by `flatten_thoughts`.
188fn build_thoughts(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<Thought>> {
189    if let Some(Value::Array(arr)) = gemini_extras.get("thoughts_meta") {
190        let thoughts: Vec<Thought> = arr
191            .iter()
192            .filter_map(|v| {
193                let obj = v.as_object()?;
194                Some(Thought {
195                    subject: obj
196                        .get("subject")
197                        .and_then(Value::as_str)
198                        .map(str::to_string),
199                    description: obj
200                        .get("description")
201                        .and_then(Value::as_str)
202                        .map(str::to_string),
203                    timestamp: obj
204                        .get("timestamp")
205                        .and_then(Value::as_str)
206                        .map(str::to_string),
207                })
208            })
209            .collect();
210        return if thoughts.is_empty() {
211            None
212        } else {
213            Some(thoughts)
214        };
215    }
216
217    // Fallback: parse the flattened string.
218    let thinking = turn.thinking.as_deref()?;
219    let chunks: Vec<&str> = thinking.split("\n\n").collect();
220    if chunks.is_empty() {
221        return None;
222    }
223    let thoughts: Vec<Thought> = chunks
224        .iter()
225        .filter(|c| !c.is_empty())
226        .map(|chunk| split_flattened_thought(chunk))
227        .collect();
228    if thoughts.is_empty() {
229        None
230    } else {
231        Some(thoughts)
232    }
233}
234
235fn split_flattened_thought(chunk: &str) -> Thought {
236    // `**subject**\n{description}` or just `{description}`/`{subject}`.
237    if let Some(rest) = chunk.strip_prefix("**")
238        && let Some(end) = rest.find("**")
239    {
240        let subject = &rest[..end];
241        let after = &rest[end + 2..];
242        let description = after.strip_prefix('\n').unwrap_or(after);
243        return Thought {
244            subject: Some(subject.to_string()),
245            description: if description.is_empty() {
246                None
247            } else {
248                Some(description.to_string())
249            },
250            timestamp: None,
251        };
252    }
253    Thought {
254        subject: None,
255        description: Some(chunk.to_string()),
256        timestamp: None,
257    }
258}
259
260/// Rebuild `Tokens`.
261///
262/// Preferred source: `extra["gemini"]["tokens"]` (the full struct).
263/// Fallback: derive a partial struct from `Turn.token_usage`.
264fn build_tokens(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Tokens> {
265    if let Some(v) = gemini_extras.get("tokens")
266        && let Ok(t) = serde_json::from_value::<Tokens>(v.clone())
267    {
268        return Some(t);
269    }
270    turn.token_usage.as_ref().map(tokens_from_common)
271}
272
273fn tokens_from_common(u: &TokenUsage) -> Tokens {
274    Tokens {
275        input: u.input_tokens,
276        output: u.output_tokens,
277        cached: u.cache_read_tokens,
278        thoughts: None,
279        tool: None,
280        total: None,
281    }
282}
283
284/// Rebuild `toolCalls[]` by zipping `Turn.tool_uses` with
285/// `extra["gemini"]["tool_call_meta"]`. Missing meta entries fall back
286/// to a minimal `ToolCall` with `status` derived from `result.is_error`.
287fn build_tool_calls(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<ToolCall>> {
288    if turn.tool_uses.is_empty() {
289        return None;
290    }
291
292    let meta_by_id: HashMap<String, &Value> = gemini_extras
293        .get("tool_call_meta")
294        .and_then(Value::as_array)
295        .map(|arr| {
296            arr.iter()
297                .filter_map(|v| {
298                    let id = v.get("id")?.as_str()?.to_string();
299                    Some((id, v))
300                })
301                .collect()
302        })
303        .unwrap_or_default();
304
305    let calls: Vec<ToolCall> = turn
306        .tool_uses
307        .iter()
308        .map(|tu| {
309            tool_invocation_to_tool_call(tu, meta_by_id.get(&tu.id).copied(), &turn.timestamp)
310        })
311        .collect();
312
313    Some(calls)
314}
315
316fn tool_invocation_to_tool_call(
317    tu: &ToolInvocation,
318    meta: Option<&Value>,
319    fallback_timestamp: &str,
320) -> ToolCall {
321    let meta_obj = meta.and_then(Value::as_object);
322
323    // Pick the output tool name. If the source tool is already a known
324    // Gemini tool (e.g. for Gemini→Path→Gemini round-trips), keep the
325    // source name verbatim. Otherwise — when projecting from a foreign
326    // harness like Claude — route through the category to get Gemini's
327    // canonical name, with the call's args informing FileWrite/FileRead
328    // disambiguation. Falls back to the source name when the category
329    // is None or has no Gemini analog.
330    let name = if crate::provider::tool_category(&tu.name).is_some() {
331        tu.name.clone()
332    } else if let Some(cat) = tu.category
333        && let Some(remapped) = crate::provider::native_name(cat, &tu.input)
334    {
335        remapped.to_string()
336    } else {
337        tu.name.clone()
338    };
339
340    let status = meta_obj
341        .and_then(|m| m.get("status").and_then(Value::as_str))
342        .map(str::to_string)
343        .unwrap_or_else(|| match &tu.result {
344            Some(r) if r.is_error => "error".to_string(),
345            Some(_) => "success".to_string(),
346            None => "pending".to_string(),
347        });
348
349    let description = meta_obj
350        .and_then(|m| m.get("description").and_then(Value::as_str))
351        .map(str::to_string)
352        .or_else(|| synthesize_description(&name, &tu.input));
353
354    let display_name = meta_obj
355        .and_then(|m| m.get("display_name").and_then(Value::as_str))
356        .map(str::to_string)
357        .or_else(|| synthesize_display_name(&name, tu.category));
358
359    let result_display = meta_obj
360        .and_then(|m| m.get("result_display"))
361        .and_then(|v| if v.is_null() { None } else { Some(v.clone()) })
362        .or_else(|| synthesize_result_display(tu.result.as_ref()));
363
364    let result = tu
365        .result
366        .as_ref()
367        .map(|r| {
368            vec![FunctionResponse {
369                function_response: FunctionResponseBody {
370                    // Use the (possibly remapped) name for consistency
371                    // with the outer ToolCall.name.
372                    id: tu.id.clone(),
373                    name: name.clone(),
374                    response: serde_json::json!({ "output": r.content }),
375                },
376            }]
377        })
378        .unwrap_or_default();
379
380    // Real Gemini sets `renderOutputAsMarkdown: true` on every call;
381    // mirror that on synthesized calls. (Carries through the existing
382    // `extra` map so it serializes as a top-level field via serde flatten.)
383    let mut extra = HashMap::new();
384    extra.insert("renderOutputAsMarkdown".to_string(), Value::Bool(true));
385
386    ToolCall {
387        id: tu.id.clone(),
388        name,
389        args: tu.input.clone(),
390        status,
391        timestamp: fallback_timestamp.to_string(),
392        result,
393        result_display,
394        description,
395        display_name,
396        extra,
397    }
398}
399
400/// Synthesize a human-readable description of a tool call from the
401/// args alone. Real Gemini sessions populate this with the model's
402/// rationale; for projected calls we fall back to a path/command
403/// summary so the UI has something useful to show.
404fn synthesize_description(name: &str, args: &Value) -> Option<String> {
405    let pick = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
406    let by_name = match name {
407        "run_shell_command" => pick("description").or_else(|| pick("command")),
408        "read_file" | "list_directory" | "get_internal_docs" => {
409            pick("file_path").or_else(|| pick("path"))
410        }
411        "read_many_files" => args
412            .get("file_paths")
413            .and_then(Value::as_array)
414            .map(|a| {
415                a.iter()
416                    .filter_map(Value::as_str)
417                    .collect::<Vec<_>>()
418                    .join(", ")
419            })
420            .filter(|s| !s.is_empty()),
421        "write_file" | "replace" | "edit" => pick("file_path"),
422        "glob" | "grep_search" | "search_file_content" => pick("pattern"),
423        "web_fetch" => pick("url"),
424        "google_web_search" => pick("query"),
425        "task" | "activate_skill" => pick("description")
426            .or_else(|| pick("prompt"))
427            .or_else(|| pick("subagent_type")),
428        _ => None,
429    };
430    by_name.or_else(|| generic_description_fallback(args))
431}
432
433/// Last-resort description synthesizer for tools we don't have a
434/// per-name template for (foreign harness tools without a Gemini analog,
435/// MCP tools with arbitrary names, etc.). Picks the first plausible
436/// human-readable string from a small list of conventional arg keys.
437fn generic_description_fallback(args: &Value) -> Option<String> {
438    static FALLBACK_KEYS: &[&str] = &[
439        "description",
440        "subject",
441        "summary",
442        "title",
443        "prompt",
444        "command",
445        "query",
446        "pattern",
447        "url",
448        "path",
449        "file_path",
450        "task_id",
451        "taskId",
452        "id",
453        "name",
454    ];
455    for key in FALLBACK_KEYS {
456        if let Some(s) = args.get(*key).and_then(Value::as_str)
457            && !s.is_empty()
458        {
459            return Some(s.to_string());
460        }
461    }
462    None
463}
464
465/// Friendly UI label for Gemini's tool palette. Real Gemini sessions
466/// carry these on every call.
467fn synthesize_display_name(name: &str, category: Option<ToolCategory>) -> Option<String> {
468    let by_name = match name {
469        "run_shell_command" => Some("Shell"),
470        "read_file" => Some("ReadFile"),
471        "read_many_files" => Some("ReadManyFiles"),
472        "list_directory" => Some("ListDirectory"),
473        "get_internal_docs" => Some("GetInternalDocs"),
474        "write_file" => Some("WriteFile"),
475        "replace" => Some("Replace"),
476        "edit" => Some("Edit"),
477        "glob" => Some("Glob"),
478        "grep_search" | "search_file_content" => Some("SearchText"),
479        "web_fetch" => Some("WebFetch"),
480        "google_web_search" => Some("GoogleSearch"),
481        "task" => Some("Task"),
482        "activate_skill" => Some("ActivateSkill"),
483        _ => None,
484    };
485    if let Some(s) = by_name {
486        return Some(s.to_string());
487    }
488    // Category fallback for foreign tools the projector recognized
489    // categorically but didn't get a Gemini-vocabulary name remap.
490    if let Some(c) = category {
491        return Some(
492            match c {
493                ToolCategory::Shell => "Shell",
494                ToolCategory::FileRead => "ReadFile",
495                ToolCategory::FileSearch => "Search",
496                ToolCategory::FileWrite => "WriteFile",
497                ToolCategory::Network => "Web",
498                ToolCategory::Delegation => "Task",
499            }
500            .to_string(),
501        );
502    }
503    // Last resort: use the source tool name verbatim. Better than `None`
504    // so the UI has *something* to label the call with.
505    if !name.is_empty() {
506        Some(name.to_string())
507    } else {
508        None
509    }
510}
511
512/// Bare-string `resultDisplay`. Gemini-native tools sometimes carry
513/// structured shapes (fileDiff objects, styled-text arrays); we only
514/// have the result text from `ToolResult`, so we render it as a plain
515/// string. Gemini accepts any JSON value here.
516fn synthesize_result_display(result: Option<&toolpath_convo::ToolResult>) -> Option<Value> {
517    result.map(|r| Value::String(r.content.clone()))
518}
519
520// ── Delegation → sub-agent ChatFile ───────────────────────────────────
521
522fn delegation_to_chat_file(d: &DelegatedWork, project_hash: &str) -> ChatFile {
523    let messages: Vec<GeminiMessage> = d.turns.iter().map(turn_to_message).collect();
524
525    let start_time = d
526        .turns
527        .first()
528        .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
529        .map(|dt| dt.with_timezone(&chrono::Utc));
530    let last_updated = d
531        .turns
532        .last()
533        .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
534        .map(|dt| dt.with_timezone(&chrono::Utc));
535
536    ChatFile {
537        session_id: d.agent_id.clone(),
538        project_hash: project_hash.to_string(),
539        start_time,
540        last_updated,
541        directories: None,
542        kind: Some("subagent".to_string()),
543        summary: d.result.clone(),
544        messages,
545        extra: HashMap::new(),
546    }
547}
548
549// ── Tests ─────────────────────────────────────────────────────────────
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use toolpath_convo::{EnvironmentSnapshot, ToolCategory, ToolResult};
555
556    fn user_turn(id: &str, text: &str) -> Turn {
557        Turn {
558            id: id.into(),
559            parent_id: None,
560            role: Role::User,
561            timestamp: "2026-04-17T15:00:00Z".into(),
562            text: text.into(),
563            thinking: None,
564            tool_uses: vec![],
565            model: None,
566            stop_reason: None,
567            token_usage: None,
568            environment: None,
569            delegations: vec![],
570            file_mutations: Vec::new(),
571        }
572    }
573
574    fn assistant_turn(id: &str, text: &str) -> Turn {
575        Turn {
576            id: id.into(),
577            parent_id: None,
578            role: Role::Assistant,
579            timestamp: "2026-04-17T15:00:01Z".into(),
580            text: text.into(),
581            thinking: None,
582            tool_uses: vec![],
583            model: Some("gemini-3-flash-preview".into()),
584            stop_reason: None,
585            token_usage: None,
586            environment: None,
587            delegations: vec![],
588            file_mutations: Vec::new(),
589        }
590    }
591
592    fn view_with(turns: Vec<Turn>) -> ConversationView {
593        ConversationView {
594            id: "session-uuid".into(),
595            started_at: None,
596            last_activity: None,
597            turns,
598            total_usage: None,
599            provider_id: Some("gemini-cli".into()),
600            files_changed: vec![],
601            session_ids: vec![],
602            events: vec![],
603            ..Default::default()
604        }
605    }
606
607    #[test]
608    fn test_empty_view_projects_cleanly() {
609        let view = view_with(vec![]);
610        let convo = GeminiProjector::default().project(&view).unwrap();
611        assert_eq!(convo.session_uuid, "session-uuid");
612        assert!(convo.main.messages.is_empty());
613        assert!(convo.sub_agents.is_empty());
614    }
615
616    #[test]
617    fn test_user_content_becomes_parts() {
618        let view = view_with(vec![user_turn("u1", "Hello")]);
619        let convo = GeminiProjector::default().project(&view).unwrap();
620        let msg = &convo.main.messages[0];
621        assert_eq!(msg.role, GeminiRole::User);
622        match &msg.content {
623            GeminiContent::Parts(parts) => {
624                assert_eq!(parts.len(), 1);
625                assert_eq!(parts[0].text.as_deref(), Some("Hello"));
626            }
627            other => panic!("expected Parts, got {:?}", other),
628        }
629    }
630
631    #[test]
632    fn test_assistant_content_becomes_text() {
633        let view = view_with(vec![assistant_turn("a1", "Hi")]);
634        let convo = GeminiProjector::default().project(&view).unwrap();
635        let msg = &convo.main.messages[0];
636        assert_eq!(msg.role, GeminiRole::Gemini);
637        assert_eq!(msg.model.as_deref(), Some("gemini-3-flash-preview"));
638        match &msg.content {
639            GeminiContent::Text(s) => assert_eq!(s, "Hi"),
640            other => panic!("expected Text, got {:?}", other),
641        }
642    }
643
644    #[test]
645    fn test_system_role_maps_to_info() {
646        let mut t = user_turn("s1", "cancelled");
647        t.role = Role::System;
648        let convo = GeminiProjector::default()
649            .project(&view_with(vec![t]))
650            .unwrap();
651        assert_eq!(convo.main.messages[0].role, GeminiRole::Info);
652    }
653
654    #[test]
655    fn test_thoughts_fallback_from_flattened_string() {
656        // No gemini.thoughts_meta — projector should still un-flatten the string.
657        let mut t = assistant_turn("a1", "");
658        t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
659        let convo = GeminiProjector::default()
660            .project(&view_with(vec![t]))
661            .unwrap();
662        let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
663        assert_eq!(thoughts.len(), 2);
664        assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
665        assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
666    }
667
668    #[test]
669    fn test_tokens_fallback_from_common_token_usage() {
670        let mut t = assistant_turn("a1", "Done.");
671        t.token_usage = Some(TokenUsage {
672            input_tokens: Some(100),
673            output_tokens: Some(50),
674            cache_read_tokens: Some(20),
675            cache_write_tokens: None,
676        });
677        let convo = GeminiProjector::default()
678            .project(&view_with(vec![t]))
679            .unwrap();
680        let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
681        assert_eq!(tokens.input, Some(100));
682        assert_eq!(tokens.output, Some(50));
683        assert_eq!(tokens.cached, Some(20));
684        // thoughts/tool/total unknown on the fallback path
685        assert!(tokens.total.is_none());
686    }
687
688    #[test]
689    fn test_tool_call_with_success_result_wraps_into_function_response() {
690        let mut t = assistant_turn("a1", "Reading.");
691        t.tool_uses = vec![ToolInvocation {
692            id: "tc1".into(),
693            name: "read_file".into(),
694            input: serde_json::json!({"path": "src/main.rs"}),
695            result: Some(ToolResult {
696                content: "fn main(){}".into(),
697                is_error: false,
698            }),
699            category: Some(ToolCategory::FileRead),
700        }];
701        let convo = GeminiProjector::default()
702            .project(&view_with(vec![t]))
703            .unwrap();
704        let calls = convo.main.messages[0].tool_calls.as_ref().unwrap();
705        assert_eq!(calls.len(), 1);
706        let call = &calls[0];
707        assert_eq!(call.name, "read_file");
708        assert_eq!(call.status, "success");
709        assert_eq!(call.result.len(), 1);
710        assert_eq!(call.result[0].function_response.id, "tc1");
711        assert_eq!(call.result[0].function_response.name, "read_file");
712        assert_eq!(
713            call.result[0].function_response.response["output"],
714            serde_json::json!("fn main(){}")
715        );
716    }
717
718    #[test]
719    fn test_tool_call_with_error_result_sets_error_status() {
720        let mut t = assistant_turn("a1", "");
721        t.tool_uses = vec![ToolInvocation {
722            id: "tc1".into(),
723            name: "run_shell_command".into(),
724            input: serde_json::json!({"command": "nope"}),
725            result: Some(ToolResult {
726                content: "boom".into(),
727                is_error: true,
728            }),
729            category: Some(ToolCategory::Shell),
730        }];
731        let convo = GeminiProjector::default()
732            .project(&view_with(vec![t]))
733            .unwrap();
734        let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
735        assert_eq!(call.status, "error");
736    }
737
738    #[test]
739    fn test_delegation_becomes_subagent_chat_file() {
740        let mut t = assistant_turn("a1", "delegating");
741        t.delegations = vec![DelegatedWork {
742            agent_id: "helper-session".into(),
743            prompt: "search for the bug".into(),
744            turns: vec![user_turn("su1", "search for the bug"), {
745                let mut r = assistant_turn("sa1", "found it");
746                r.timestamp = "2026-04-17T15:10:00Z".into();
747                r
748            }],
749            result: Some("fixed line 42".into()),
750        }];
751        let convo = GeminiProjector::default()
752            .project(&view_with(vec![t]))
753            .unwrap();
754        assert_eq!(convo.sub_agents.len(), 1);
755        let sub = &convo.sub_agents[0];
756        assert_eq!(sub.session_id, "helper-session");
757        assert_eq!(sub.kind.as_deref(), Some("subagent"));
758        assert_eq!(sub.summary.as_deref(), Some("fixed line 42"));
759        assert_eq!(sub.messages.len(), 2);
760    }
761
762    #[test]
763    fn test_environment_does_not_appear_on_message() {
764        // `environment` is a ConversationView-level concern, not a
765        // per-message concern on the Gemini wire format. Projector
766        // should simply ignore it (it'll be None on the output).
767        let mut t = user_turn("u1", "hi");
768        t.environment = Some(EnvironmentSnapshot {
769            working_dir: Some("/abs/myrepo".into()),
770            vcs_branch: Some("main".into()),
771            vcs_revision: None,
772        });
773        let convo = GeminiProjector::default()
774            .project(&view_with(vec![t]))
775            .unwrap();
776        // The projected main file has no directories field by default.
777        assert!(convo.main.directories.is_none());
778    }
779
780    #[test]
781    fn test_project_hash_and_path_propagate() {
782        let view = view_with(vec![user_turn("u1", "hi")]);
783        let projector = GeminiProjector::new()
784            .with_project_hash("deadbeef")
785            .with_project_path("/abs/myrepo");
786        let convo = projector.project(&view).unwrap();
787        assert_eq!(convo.main.project_hash, "deadbeef");
788        assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
789    }
790
791    #[test]
792    fn test_output_chat_file_serde_roundtrip() {
793        // The projected ChatFile must survive a JSON round-trip
794        // (i.e. load fine back into Gemini's type).
795        let mut t = assistant_turn("a1", "Hi there.");
796        t.token_usage = Some(TokenUsage {
797            input_tokens: Some(10),
798            output_tokens: Some(5),
799            cache_read_tokens: None,
800            cache_write_tokens: None,
801        });
802        t.tool_uses = vec![ToolInvocation {
803            id: "tc1".into(),
804            name: "read_file".into(),
805            input: serde_json::json!({"path": "src/a.rs"}),
806            result: Some(ToolResult {
807                content: "fn a(){}".into(),
808                is_error: false,
809            }),
810            category: Some(ToolCategory::FileRead),
811        }];
812
813        let convo = GeminiProjector::default()
814            .project(&view_with(vec![user_turn("u1", "Read src/a.rs"), t]))
815            .unwrap();
816
817        let json = serde_json::to_string(&convo.main).unwrap();
818        let back: ChatFile = serde_json::from_str(&json).unwrap();
819        assert_eq!(back.messages.len(), 2);
820        assert_eq!(back.messages[1].tool_calls().len(), 1);
821        assert_eq!(back.messages[1].tool_calls()[0].result_text(), "fn a(){}");
822    }
823}