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