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    // Reasoning is folded into `output_tokens` on the forward path and the
275    // slice is recorded in `breakdowns["output"]["reasoning"]`. Un-fold it
276    // back out here so `output`/`thoughts` round-trip losslessly.
277    let thoughts = u
278        .breakdowns
279        .get("output")
280        .and_then(|m| m.get("reasoning"))
281        .copied();
282    Tokens {
283        input: u.input_tokens,
284        output: match (u.output_tokens, thoughts) {
285            (Some(o), Some(r)) => Some(o.saturating_sub(r)),
286            (o, _) => o,
287        },
288        cached: u.cache_read_tokens,
289        thoughts,
290        tool: None,
291        total: None,
292    }
293}
294
295/// Rebuild `toolCalls[]` by zipping `Turn.tool_uses` with
296/// `extra["gemini"]["tool_call_meta"]`. Missing meta entries fall back
297/// to a minimal `ToolCall` with `status` derived from `result.is_error`.
298fn build_tool_calls(turn: &Turn, gemini_extras: &Map<String, Value>) -> Option<Vec<ToolCall>> {
299    if turn.tool_uses.is_empty() {
300        return None;
301    }
302
303    let meta_by_id: HashMap<String, &Value> = gemini_extras
304        .get("tool_call_meta")
305        .and_then(Value::as_array)
306        .map(|arr| {
307            arr.iter()
308                .filter_map(|v| {
309                    let id = v.get("id")?.as_str()?.to_string();
310                    Some((id, v))
311                })
312                .collect()
313        })
314        .unwrap_or_default();
315
316    let calls: Vec<ToolCall> = turn
317        .tool_uses
318        .iter()
319        .map(|tu| {
320            tool_invocation_to_tool_call(tu, meta_by_id.get(&tu.id).copied(), &turn.timestamp)
321        })
322        .collect();
323
324    Some(calls)
325}
326
327fn tool_invocation_to_tool_call(
328    tu: &ToolInvocation,
329    meta: Option<&Value>,
330    fallback_timestamp: &str,
331) -> ToolCall {
332    let meta_obj = meta.and_then(Value::as_object);
333
334    // Pick the output tool name. If the source tool is already a known
335    // Gemini tool (e.g. for Gemini→Path→Gemini round-trips), keep the
336    // source name verbatim. Otherwise — when projecting from a foreign
337    // harness like Claude — route through the category to get Gemini's
338    // canonical name, with the call's args informing FileWrite/FileRead
339    // disambiguation. Falls back to the source name when the category
340    // is None or has no Gemini analog.
341    let name = if crate::provider::tool_category(&tu.name).is_some() {
342        tu.name.clone()
343    } else if let Some(cat) = tu.category
344        && let Some(remapped) = crate::provider::native_name(cat, &tu.input)
345    {
346        remapped.to_string()
347    } else {
348        tu.name.clone()
349    };
350
351    let status = meta_obj
352        .and_then(|m| m.get("status").and_then(Value::as_str))
353        .map(str::to_string)
354        .unwrap_or_else(|| match &tu.result {
355            Some(r) if r.is_error => "error".to_string(),
356            Some(_) => "success".to_string(),
357            None => "pending".to_string(),
358        });
359
360    let description = meta_obj
361        .and_then(|m| m.get("description").and_then(Value::as_str))
362        .map(str::to_string)
363        .or_else(|| synthesize_description(&name, &tu.input));
364
365    let display_name = meta_obj
366        .and_then(|m| m.get("display_name").and_then(Value::as_str))
367        .map(str::to_string)
368        .or_else(|| synthesize_display_name(&name, tu.category));
369
370    let result_display = meta_obj
371        .and_then(|m| m.get("result_display"))
372        .and_then(|v| if v.is_null() { None } else { Some(v.clone()) })
373        .or_else(|| synthesize_result_display(tu.result.as_ref()));
374
375    let result = tu
376        .result
377        .as_ref()
378        .map(|r| {
379            vec![FunctionResponse {
380                function_response: FunctionResponseBody {
381                    // Use the (possibly remapped) name for consistency
382                    // with the outer ToolCall.name.
383                    id: tu.id.clone(),
384                    name: name.clone(),
385                    response: serde_json::json!({ "output": r.content }),
386                },
387            }]
388        })
389        .unwrap_or_default();
390
391    // Real Gemini sets `renderOutputAsMarkdown: true` on every call;
392    // mirror that on synthesized calls. (Carries through the existing
393    // `extra` map so it serializes as a top-level field via serde flatten.)
394    let mut extra = HashMap::new();
395    extra.insert("renderOutputAsMarkdown".to_string(), Value::Bool(true));
396
397    ToolCall {
398        id: tu.id.clone(),
399        name,
400        args: tu.input.clone(),
401        status,
402        timestamp: fallback_timestamp.to_string(),
403        result,
404        result_display,
405        description,
406        display_name,
407        extra,
408    }
409}
410
411/// Synthesize a human-readable description of a tool call from the
412/// args alone. Real Gemini sessions populate this with the model's
413/// rationale; for projected calls we fall back to a path/command
414/// summary so the UI has something useful to show.
415fn synthesize_description(name: &str, args: &Value) -> Option<String> {
416    let pick = |k: &str| args.get(k).and_then(Value::as_str).map(str::to_string);
417    let by_name = match name {
418        "run_shell_command" => pick("description").or_else(|| pick("command")),
419        "read_file" | "list_directory" | "get_internal_docs" => {
420            pick("file_path").or_else(|| pick("path"))
421        }
422        "read_many_files" => args
423            .get("file_paths")
424            .and_then(Value::as_array)
425            .map(|a| {
426                a.iter()
427                    .filter_map(Value::as_str)
428                    .collect::<Vec<_>>()
429                    .join(", ")
430            })
431            .filter(|s| !s.is_empty()),
432        "write_file" | "replace" | "edit" => pick("file_path"),
433        "glob" | "grep_search" | "search_file_content" => pick("pattern"),
434        "web_fetch" => pick("url"),
435        "google_web_search" => pick("query"),
436        "task" | "activate_skill" => pick("description")
437            .or_else(|| pick("prompt"))
438            .or_else(|| pick("subagent_type")),
439        _ => None,
440    };
441    by_name.or_else(|| generic_description_fallback(args))
442}
443
444/// Last-resort description synthesizer for tools we don't have a
445/// per-name template for (foreign harness tools without a Gemini analog,
446/// MCP tools with arbitrary names, etc.). Picks the first plausible
447/// human-readable string from a small list of conventional arg keys.
448fn generic_description_fallback(args: &Value) -> Option<String> {
449    static FALLBACK_KEYS: &[&str] = &[
450        "description",
451        "subject",
452        "summary",
453        "title",
454        "prompt",
455        "command",
456        "query",
457        "pattern",
458        "url",
459        "path",
460        "file_path",
461        "task_id",
462        "taskId",
463        "id",
464        "name",
465    ];
466    for key in FALLBACK_KEYS {
467        if let Some(s) = args.get(*key).and_then(Value::as_str)
468            && !s.is_empty()
469        {
470            return Some(s.to_string());
471        }
472    }
473    None
474}
475
476/// Friendly UI label for Gemini's tool palette. Real Gemini sessions
477/// carry these on every call.
478fn synthesize_display_name(name: &str, category: Option<ToolCategory>) -> Option<String> {
479    let by_name = match name {
480        "run_shell_command" => Some("Shell"),
481        "read_file" => Some("ReadFile"),
482        "read_many_files" => Some("ReadManyFiles"),
483        "list_directory" => Some("ListDirectory"),
484        "get_internal_docs" => Some("GetInternalDocs"),
485        "write_file" => Some("WriteFile"),
486        "replace" => Some("Replace"),
487        "edit" => Some("Edit"),
488        "glob" => Some("Glob"),
489        "grep_search" | "search_file_content" => Some("SearchText"),
490        "web_fetch" => Some("WebFetch"),
491        "google_web_search" => Some("GoogleSearch"),
492        "task" => Some("Task"),
493        "activate_skill" => Some("ActivateSkill"),
494        _ => None,
495    };
496    if let Some(s) = by_name {
497        return Some(s.to_string());
498    }
499    // Category fallback for foreign tools the projector recognized
500    // categorically but didn't get a Gemini-vocabulary name remap.
501    if let Some(c) = category {
502        return Some(
503            match c {
504                ToolCategory::Shell => "Shell",
505                ToolCategory::FileRead => "ReadFile",
506                ToolCategory::FileSearch => "Search",
507                ToolCategory::FileWrite => "WriteFile",
508                ToolCategory::Network => "Web",
509                ToolCategory::Delegation => "Task",
510            }
511            .to_string(),
512        );
513    }
514    // Last resort: use the source tool name verbatim. Better than `None`
515    // so the UI has *something* to label the call with.
516    if !name.is_empty() {
517        Some(name.to_string())
518    } else {
519        None
520    }
521}
522
523/// Bare-string `resultDisplay`. Gemini-native tools sometimes carry
524/// structured shapes (fileDiff objects, styled-text arrays); we only
525/// have the result text from `ToolResult`, so we render it as a plain
526/// string. Gemini accepts any JSON value here.
527fn synthesize_result_display(result: Option<&toolpath_convo::ToolResult>) -> Option<Value> {
528    result.map(|r| Value::String(r.content.clone()))
529}
530
531// ── Delegation → sub-agent ChatFile ───────────────────────────────────
532
533fn delegation_to_chat_file(d: &DelegatedWork, project_hash: &str) -> ChatFile {
534    let messages: Vec<GeminiMessage> = d.turns.iter().map(turn_to_message).collect();
535
536    let start_time = d
537        .turns
538        .first()
539        .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
540        .map(|dt| dt.with_timezone(&chrono::Utc));
541    let last_updated = d
542        .turns
543        .last()
544        .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t.timestamp).ok())
545        .map(|dt| dt.with_timezone(&chrono::Utc));
546
547    ChatFile {
548        session_id: d.agent_id.clone(),
549        project_hash: project_hash.to_string(),
550        start_time,
551        last_updated,
552        directories: None,
553        kind: Some("subagent".to_string()),
554        summary: d.result.clone(),
555        messages,
556        extra: HashMap::new(),
557    }
558}
559
560// ── Tests ─────────────────────────────────────────────────────────────
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use std::collections::BTreeMap;
566    use toolpath_convo::{EnvironmentSnapshot, ToolCategory, ToolResult};
567
568    #[test]
569    fn tokens_from_common_unfolds_reasoning_out_of_output() {
570        let mut breakdowns: BTreeMap<String, BTreeMap<String, u32>> = BTreeMap::new();
571        breakdowns.insert("output".into(), BTreeMap::from([("reasoning".into(), 243u32)]));
572        let usage = TokenUsage {
573            output_tokens: Some(337),
574            breakdowns,
575            ..Default::default()
576        };
577
578        let tokens = tokens_from_common(&usage);
579        assert_eq!(tokens.output, Some(94));
580        assert_eq!(tokens.thoughts, Some(243));
581    }
582
583    #[test]
584    fn tokens_from_common_without_breakdown_leaves_output_unchanged() {
585        let usage = TokenUsage {
586            output_tokens: Some(337),
587            ..Default::default()
588        };
589
590        let tokens = tokens_from_common(&usage);
591        assert_eq!(tokens.output, Some(337));
592        assert_eq!(tokens.thoughts, None);
593    }
594
595    fn user_turn(id: &str, text: &str) -> Turn {
596        Turn {
597            id: id.into(),
598            parent_id: None,
599            group_id: None,
600            role: Role::User,
601            timestamp: "2026-04-17T15:00:00Z".into(),
602            text: text.into(),
603            thinking: None,
604            tool_uses: vec![],
605            model: None,
606            stop_reason: None,
607            token_usage: None,
608            attributed_token_usage: None,
609            environment: None,
610            delegations: vec![],
611            file_mutations: Vec::new(),
612        }
613    }
614
615    fn assistant_turn(id: &str, text: &str) -> Turn {
616        Turn {
617            id: id.into(),
618            parent_id: None,
619            group_id: None,
620            role: Role::Assistant,
621            timestamp: "2026-04-17T15:00:01Z".into(),
622            text: text.into(),
623            thinking: None,
624            tool_uses: vec![],
625            model: Some("gemini-3-flash-preview".into()),
626            stop_reason: None,
627            token_usage: None,
628            attributed_token_usage: None,
629            environment: None,
630            delegations: vec![],
631            file_mutations: Vec::new(),
632        }
633    }
634
635    fn view_with(turns: Vec<Turn>) -> ConversationView {
636        ConversationView {
637            id: "session-uuid".into(),
638            started_at: None,
639            last_activity: None,
640            turns,
641            total_usage: None,
642            provider_id: Some("gemini-cli".into()),
643            files_changed: vec![],
644            session_ids: vec![],
645            events: vec![],
646            ..Default::default()
647        }
648    }
649
650    #[test]
651    fn test_empty_view_projects_cleanly() {
652        let view = view_with(vec![]);
653        let convo = GeminiProjector::default().project(&view).unwrap();
654        assert_eq!(convo.session_uuid, "session-uuid");
655        assert!(convo.main.messages.is_empty());
656        assert!(convo.sub_agents.is_empty());
657    }
658
659    #[test]
660    fn test_user_content_becomes_parts() {
661        let view = view_with(vec![user_turn("u1", "Hello")]);
662        let convo = GeminiProjector::default().project(&view).unwrap();
663        let msg = &convo.main.messages[0];
664        assert_eq!(msg.role, GeminiRole::User);
665        match &msg.content {
666            GeminiContent::Parts(parts) => {
667                assert_eq!(parts.len(), 1);
668                assert_eq!(parts[0].text.as_deref(), Some("Hello"));
669            }
670            other => panic!("expected Parts, got {:?}", other),
671        }
672    }
673
674    #[test]
675    fn test_assistant_content_becomes_text() {
676        let view = view_with(vec![assistant_turn("a1", "Hi")]);
677        let convo = GeminiProjector::default().project(&view).unwrap();
678        let msg = &convo.main.messages[0];
679        assert_eq!(msg.role, GeminiRole::Gemini);
680        assert_eq!(msg.model.as_deref(), Some("gemini-3-flash-preview"));
681        match &msg.content {
682            GeminiContent::Text(s) => assert_eq!(s, "Hi"),
683            other => panic!("expected Text, got {:?}", other),
684        }
685    }
686
687    #[test]
688    fn test_system_role_maps_to_info() {
689        let mut t = user_turn("s1", "cancelled");
690        t.role = Role::System;
691        let convo = GeminiProjector::default()
692            .project(&view_with(vec![t]))
693            .unwrap();
694        assert_eq!(convo.main.messages[0].role, GeminiRole::Info);
695    }
696
697    #[test]
698    fn test_thoughts_fallback_from_flattened_string() {
699        // No gemini.thoughts_meta — projector should still un-flatten the string.
700        let mut t = assistant_turn("a1", "");
701        t.thinking = Some("**Searching**\nlooking in /auth\n\n**Plan**\ntry token path".into());
702        let convo = GeminiProjector::default()
703            .project(&view_with(vec![t]))
704            .unwrap();
705        let thoughts = convo.main.messages[0].thoughts.as_ref().unwrap();
706        assert_eq!(thoughts.len(), 2);
707        assert_eq!(thoughts[0].subject.as_deref(), Some("Searching"));
708        assert_eq!(thoughts[0].description.as_deref(), Some("looking in /auth"));
709    }
710
711    #[test]
712    fn test_tokens_fallback_from_common_token_usage() {
713        let mut t = assistant_turn("a1", "Done.");
714        t.token_usage = Some(TokenUsage {
715            input_tokens: Some(100),
716            output_tokens: Some(50),
717            cache_read_tokens: Some(20),
718            cache_write_tokens: None,
719            ..Default::default()
720        });
721        let convo = GeminiProjector::default()
722            .project(&view_with(vec![t]))
723            .unwrap();
724        let tokens = convo.main.messages[0].tokens.as_ref().unwrap();
725        assert_eq!(tokens.input, Some(100));
726        assert_eq!(tokens.output, Some(50));
727        assert_eq!(tokens.cached, Some(20));
728        // thoughts/tool/total unknown on the fallback path
729        assert!(tokens.total.is_none());
730    }
731
732    #[test]
733    fn test_tool_call_with_success_result_wraps_into_function_response() {
734        let mut t = assistant_turn("a1", "Reading.");
735        t.tool_uses = vec![ToolInvocation {
736            id: "tc1".into(),
737            name: "read_file".into(),
738            input: serde_json::json!({"path": "src/main.rs"}),
739            result: Some(ToolResult {
740                content: "fn main(){}".into(),
741                is_error: false,
742            }),
743            category: Some(ToolCategory::FileRead),
744        }];
745        let convo = GeminiProjector::default()
746            .project(&view_with(vec![t]))
747            .unwrap();
748        let calls = convo.main.messages[0].tool_calls.as_ref().unwrap();
749        assert_eq!(calls.len(), 1);
750        let call = &calls[0];
751        assert_eq!(call.name, "read_file");
752        assert_eq!(call.status, "success");
753        assert_eq!(call.result.len(), 1);
754        assert_eq!(call.result[0].function_response.id, "tc1");
755        assert_eq!(call.result[0].function_response.name, "read_file");
756        assert_eq!(
757            call.result[0].function_response.response["output"],
758            serde_json::json!("fn main(){}")
759        );
760    }
761
762    #[test]
763    fn test_tool_call_with_error_result_sets_error_status() {
764        let mut t = assistant_turn("a1", "");
765        t.tool_uses = vec![ToolInvocation {
766            id: "tc1".into(),
767            name: "run_shell_command".into(),
768            input: serde_json::json!({"command": "nope"}),
769            result: Some(ToolResult {
770                content: "boom".into(),
771                is_error: true,
772            }),
773            category: Some(ToolCategory::Shell),
774        }];
775        let convo = GeminiProjector::default()
776            .project(&view_with(vec![t]))
777            .unwrap();
778        let call = &convo.main.messages[0].tool_calls.as_ref().unwrap()[0];
779        assert_eq!(call.status, "error");
780    }
781
782    #[test]
783    fn test_delegation_becomes_subagent_chat_file() {
784        let mut t = assistant_turn("a1", "delegating");
785        t.delegations = vec![DelegatedWork {
786            agent_id: "helper-session".into(),
787            prompt: "search for the bug".into(),
788            turns: vec![user_turn("su1", "search for the bug"), {
789                let mut r = assistant_turn("sa1", "found it");
790                r.timestamp = "2026-04-17T15:10:00Z".into();
791                r
792            }],
793            result: Some("fixed line 42".into()),
794        }];
795        let convo = GeminiProjector::default()
796            .project(&view_with(vec![t]))
797            .unwrap();
798        assert_eq!(convo.sub_agents.len(), 1);
799        let sub = &convo.sub_agents[0];
800        assert_eq!(sub.session_id, "helper-session");
801        assert_eq!(sub.kind.as_deref(), Some("subagent"));
802        assert_eq!(sub.summary.as_deref(), Some("fixed line 42"));
803        assert_eq!(sub.messages.len(), 2);
804    }
805
806    #[test]
807    fn test_environment_does_not_appear_on_message() {
808        // `environment` is a ConversationView-level concern, not a
809        // per-message concern on the Gemini wire format. Projector
810        // should simply ignore it (it'll be None on the output).
811        let mut t = user_turn("u1", "hi");
812        t.environment = Some(EnvironmentSnapshot {
813            working_dir: Some("/abs/myrepo".into()),
814            vcs_branch: Some("main".into()),
815            vcs_revision: None,
816        });
817        let convo = GeminiProjector::default()
818            .project(&view_with(vec![t]))
819            .unwrap();
820        // The projected main file has no directories field by default.
821        assert!(convo.main.directories.is_none());
822    }
823
824    #[test]
825    fn test_project_hash_and_path_propagate() {
826        let view = view_with(vec![user_turn("u1", "hi")]);
827        let projector = GeminiProjector::new()
828            .with_project_hash("deadbeef")
829            .with_project_path("/abs/myrepo");
830        let convo = projector.project(&view).unwrap();
831        assert_eq!(convo.main.project_hash, "deadbeef");
832        assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
833    }
834
835    #[test]
836    fn test_output_chat_file_serde_roundtrip() {
837        // The projected ChatFile must survive a JSON round-trip
838        // (i.e. load fine back into Gemini's type).
839        let mut t = assistant_turn("a1", "Hi there.");
840        t.token_usage = Some(TokenUsage {
841            input_tokens: Some(10),
842            output_tokens: Some(5),
843            cache_read_tokens: None,
844            cache_write_tokens: None,
845            ..Default::default()
846        });
847        t.tool_uses = vec![ToolInvocation {
848            id: "tc1".into(),
849            name: "read_file".into(),
850            input: serde_json::json!({"path": "src/a.rs"}),
851            result: Some(ToolResult {
852                content: "fn a(){}".into(),
853                is_error: false,
854            }),
855            category: Some(ToolCategory::FileRead),
856        }];
857
858        let convo = GeminiProjector::default()
859            .project(&view_with(vec![user_turn("u1", "Read src/a.rs"), t]))
860            .unwrap();
861
862        let json = serde_json::to_string(&convo.main).unwrap();
863        let back: ChatFile = serde_json::from_str(&json).unwrap();
864        assert_eq!(back.messages.len(), 2);
865        assert_eq!(back.messages[1].tool_calls().len(), 1);
866        assert_eq!(back.messages[1].tool_calls()[0].result_text(), "fn a(){}");
867    }
868}