Skip to main content

toolpath_gemini/
derive.rs

1//! Derive Toolpath documents from Gemini CLI conversation logs.
2//!
3//! The conversation is modeled as an artifact at
4//! `gemini://<session-id>`. Each turn appends to that artifact via a
5//! `conversation.append` structural change. File mutations from
6//! `write_file` and `replace` tool calls appear as sibling artifacts in
7//! the same step's `change` map.
8//!
9//! Sub-agent chats are linearized into the path as additional steps
10//! parented to the main assistant step whose `task` tool invocation
11//! spawned them (document order, matching [`crate::provider`]).
12
13use crate::provider::{file_path_from_args, tool_category};
14use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, ToolCall};
15use serde_json::json;
16use std::collections::HashMap;
17use toolpath::v1::{
18    ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
19    StepIdentity, StructuralChange,
20};
21use toolpath_convo::ToolCategory;
22
23/// Configuration for deriving Toolpath documents from Gemini conversations.
24#[derive(Debug, Clone, Default)]
25pub struct DeriveConfig {
26    /// Override the project path used for `path.base.uri`.
27    pub project_path: Option<String>,
28    /// Include thinking blocks in the `conversation.append` text payload.
29    pub include_thinking: bool,
30}
31
32/// Derive a single Toolpath [`Path`] from a Gemini conversation.
33pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
34    let session_short = safe_prefix(&conversation.main.session_id, 8);
35    let path_id = if session_short.is_empty() {
36        format!("path-gemini-{}", safe_prefix(&conversation.session_uuid, 8))
37    } else {
38        format!("path-gemini-{}", session_short)
39    };
40    let convo_artifact = convo_artifact_uri(&conversation.main);
41
42    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
43    let mut steps: Vec<Step> = Vec::new();
44
45    // Index sub-agents deterministically by start_time so we attach them
46    // in the same order as the provider.
47    let mut sub_order: Vec<&ChatFile> = conversation.sub_agents.iter().collect();
48    sub_order.sort_by_key(|s| s.start_time);
49    let mut sub_iter = sub_order.into_iter();
50
51    let mut last_step_id: Option<String> = None;
52
53    for msg in &conversation.main.messages {
54        let Some(step) = build_step(
55            msg,
56            &convo_artifact,
57            last_step_id.as_deref(),
58            &mut actors,
59            config,
60        ) else {
61            continue;
62        };
63        let step_id = step.step.id.clone();
64        steps.push(step);
65
66        // For each delegation-category tool call, pull the next sub-agent
67        // off the queue and append its messages as steps parented under
68        // this main step.
69        let delegation_calls: Vec<&ToolCall> = msg
70            .tool_calls()
71            .iter()
72            .filter(|t| tool_category(&t.name) == Some(ToolCategory::Delegation))
73            .collect();
74        for _ in &delegation_calls {
75            if let Some(sub) = sub_iter.next() {
76                append_sub_agent_steps(sub, &step_id, &mut steps, &mut actors, config);
77            }
78        }
79
80        last_step_id = Some(step_id);
81    }
82
83    // Leftover sub-agents attach to the last step we emitted.
84    let leftover: Vec<&ChatFile> = sub_iter.collect();
85    if !leftover.is_empty()
86        && let Some(parent) = last_step_id.clone()
87    {
88        for sub in leftover {
89            append_sub_agent_steps(sub, &parent, &mut steps, &mut actors, config);
90        }
91    }
92
93    let head = last_step_id.unwrap_or_else(|| "empty".to_string());
94
95    let base_uri = config
96        .project_path
97        .clone()
98        .or_else(|| conversation.project_path.clone())
99        .or_else(|| {
100            conversation
101                .main
102                .directories()
103                .first()
104                .map(|p| p.to_string_lossy().to_string())
105        })
106        .map(|p| format!("file://{}", p));
107
108    Path {
109        path: PathIdentity {
110            id: path_id,
111            base: base_uri.map(|uri| Base {
112                uri,
113                ref_str: None,
114                branch: None,
115            }),
116            head,
117            graph_ref: None,
118        },
119        steps,
120        meta: Some(PathMeta {
121            title: Some(format!(
122                "Gemini session: {}",
123                if session_short.is_empty() {
124                    safe_prefix(&conversation.session_uuid, 8)
125                } else {
126                    session_short
127                }
128            )),
129            source: Some("gemini-cli".to_string()),
130            actors: if actors.is_empty() {
131                None
132            } else {
133                Some(actors)
134            },
135            ..Default::default()
136        }),
137    }
138}
139
140/// Derive Toolpath Paths from multiple conversations.
141pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
142    conversations
143        .iter()
144        .map(|c| derive_path(c, config))
145        .collect()
146}
147
148// ── Step construction ────────────────────────────────────────────────
149
150fn build_step(
151    msg: &GeminiMessage,
152    convo_artifact: &str,
153    parent_id: Option<&str>,
154    actors: &mut HashMap<String, ActorDefinition>,
155    config: &DeriveConfig,
156) -> Option<Step> {
157    if msg.id.is_empty() {
158        return None;
159    }
160
161    let (actor, role_str) = resolve_actor(msg, actors);
162
163    let mut file_changes: HashMap<String, ArtifactChange> = HashMap::new();
164    let mut text_parts: Vec<String> = Vec::new();
165    let mut tool_calls_meta: Vec<serde_json::Value> = Vec::new();
166
167    let content_text = msg.content.text();
168    if !content_text.trim().is_empty() {
169        text_parts.push(content_text);
170    }
171    if config.include_thinking && !msg.thoughts().is_empty() {
172        for t in msg.thoughts() {
173            let subject = t.subject.as_deref().unwrap_or("");
174            let description = t.description.as_deref().unwrap_or("");
175            let combined = match (subject.is_empty(), description.is_empty()) {
176                (false, false) => format!("[thinking: {}] {}", subject, description),
177                (false, true) => format!("[thinking] {}", subject),
178                (true, false) => format!("[thinking] {}", description),
179                (true, true) => continue,
180            };
181            text_parts.push(combined);
182        }
183    }
184
185    for call in msg.tool_calls() {
186        tool_calls_meta.push(serde_json::json!({
187            "name": call.name,
188            "status": call.status,
189            "summary": tool_call_summary(call),
190        }));
191        if matches!(tool_category(&call.name), Some(ToolCategory::FileWrite))
192            && let Some(fp) = file_path_from_args(&call.args)
193        {
194            let new_change = build_file_write_change(call);
195            // If the same file is touched twice by one message (rare but
196            // possible), prefer the first; downstream steps show the
197            // later edit distinctly.
198            file_changes.entry(fp).or_insert(new_change);
199        }
200    }
201
202    if text_parts.is_empty() && tool_calls_meta.is_empty() && file_changes.is_empty() {
203        return None;
204    }
205
206    let mut convo_extra = HashMap::new();
207    convo_extra.insert("role".to_string(), json!(role_str));
208    if !text_parts.is_empty() {
209        let combined = text_parts.join("\n\n");
210        convo_extra.insert("text".to_string(), json!(combined));
211    }
212    if !tool_calls_meta.is_empty() {
213        convo_extra.insert("tool_calls".to_string(), json!(tool_calls_meta));
214    }
215
216    let convo_change = ArtifactChange {
217        raw: None,
218        structural: Some(StructuralChange {
219            change_type: "conversation.append".to_string(),
220            extra: convo_extra,
221        }),
222    };
223
224    let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
225    changes.insert(convo_artifact.to_string(), convo_change);
226    changes.extend(file_changes);
227
228    let step_id = format!("step-{}", safe_prefix(&msg.id, 8));
229    let parents = parent_id.map(|p| vec![p.to_string()]).unwrap_or_default();
230
231    Some(Step {
232        step: StepIdentity {
233            id: step_id,
234            parents,
235            actor,
236            timestamp: msg.timestamp.clone(),
237        },
238        change: changes,
239        meta: None,
240    })
241}
242
243/// Build an `ArtifactChange` for a single file-write tool invocation.
244///
245/// Always populates at least one perspective (per RFC §"Change
246/// Perspectives"): `raw` is preferred when Gemini's `resultDisplay`
247/// carries a `fileDiff`; otherwise we fall back to a hand-rolled
248/// unified-diff hunk for `replace`, or a "new file" hunk for
249/// `write_file`. `structural` mirrors the tool name and captures the
250/// raw args (trimmed) so downstream consumers have machine-readable
251/// detail.
252fn build_file_write_change(call: &ToolCall) -> ArtifactChange {
253    let raw = call.file_diff().or_else(|| fallback_raw_diff(call));
254    let structural = Some(StructuralChange {
255        change_type: format!("gemini.{}", call.name),
256        extra: structural_extra_for(call),
257    });
258    ArtifactChange { raw, structural }
259}
260
261/// Compact human-readable summary of a tool call's salient args. Used
262/// in `conversation.append` structural payloads so shell commands,
263/// grep patterns, read targets, etc. aren't dropped during derivation.
264fn tool_call_summary(call: &ToolCall) -> String {
265    let pick = |k: &str| -> Option<&str> { call.args.get(k).and_then(|v| v.as_str()) };
266    let summary = match call.name.as_str() {
267        "run_shell_command" => pick("command").map(str::to_string),
268        "read_file" | "read_many_files" | "list_directory" => pick("file_path")
269            .or_else(|| pick("path"))
270            .map(str::to_string),
271        "write_file" | "replace" | "edit" => pick("file_path").map(str::to_string),
272        "glob" => pick("pattern").map(str::to_string),
273        "grep_search" | "search_file_content" => pick("pattern").map(str::to_string),
274        "web_fetch" => pick("url").map(str::to_string),
275        "google_web_search" => pick("query").map(str::to_string),
276        "task" | "activate_skill" => pick("prompt").map(str::to_string),
277        "get_internal_docs" => pick("path").map(str::to_string),
278        _ => None,
279    };
280    summary.unwrap_or_default()
281}
282
283fn structural_extra_for(call: &ToolCall) -> HashMap<String, serde_json::Value> {
284    let mut extra = HashMap::new();
285    match call.name.as_str() {
286        "write_file" => {
287            let content = call
288                .args
289                .get("content")
290                .and_then(|v| v.as_str())
291                .unwrap_or("");
292            extra.insert("operation".into(), json!("write"));
293            extra.insert("byte_count".into(), json!(content.len()));
294            extra.insert("line_count".into(), json!(content.lines().count()));
295        }
296        "replace" => {
297            let old_s = call
298                .args
299                .get("old_string")
300                .and_then(|v| v.as_str())
301                .unwrap_or("");
302            let new_s = call
303                .args
304                .get("new_string")
305                .and_then(|v| v.as_str())
306                .unwrap_or("");
307            let instruction = call
308                .args
309                .get("instruction")
310                .and_then(|v| v.as_str())
311                .unwrap_or("");
312            extra.insert("operation".into(), json!("replace"));
313            extra.insert("old_string".into(), json!(old_s));
314            extra.insert("new_string".into(), json!(new_s));
315            if !instruction.is_empty() {
316                extra.insert("instruction".into(), json!(instruction));
317            }
318        }
319        "edit" => {
320            extra.insert("operation".into(), json!("edit"));
321        }
322        _ => {
323            extra.insert("operation".into(), json!(call.name.clone()));
324        }
325    }
326    extra.insert("status".into(), json!(call.status));
327    extra
328}
329
330/// Construct a unified-diff hunk when Gemini's `resultDisplay.fileDiff`
331/// is absent. Not pixel-perfect but good enough to give readers a
332/// change perspective.
333fn fallback_raw_diff(call: &ToolCall) -> Option<String> {
334    match call.name.as_str() {
335        "replace" => {
336            let old_s = call.args.get("old_string").and_then(|v| v.as_str())?;
337            let new_s = call.args.get("new_string").and_then(|v| v.as_str())?;
338            let old_lines: Vec<&str> = old_s.split('\n').collect();
339            let new_lines: Vec<&str> = new_s.split('\n').collect();
340            let mut buf = format!("@@ -1,{} +1,{} @@\n", old_lines.len(), new_lines.len());
341            for l in old_lines {
342                buf.push('-');
343                buf.push_str(l);
344                buf.push('\n');
345            }
346            for l in new_lines {
347                buf.push('+');
348                buf.push_str(l);
349                buf.push('\n');
350            }
351            Some(buf)
352        }
353        "write_file" => {
354            let content = call.args.get("content").and_then(|v| v.as_str())?;
355            let lines: Vec<&str> = content.split('\n').collect();
356            let mut buf = format!("@@ -0,0 +1,{} @@\n", lines.len());
357            for l in lines {
358                buf.push('+');
359                buf.push_str(l);
360                buf.push('\n');
361            }
362            Some(buf)
363        }
364        _ => None,
365    }
366}
367
368/// Append every message in a sub-agent chat as a step parented under
369/// `parent_step_id`, linearizing internally.
370fn append_sub_agent_steps(
371    sub: &ChatFile,
372    parent_step_id: &str,
373    steps: &mut Vec<Step>,
374    actors: &mut HashMap<String, ActorDefinition>,
375    config: &DeriveConfig,
376) {
377    let convo_artifact = convo_artifact_uri(sub);
378    let mut local_parent = parent_step_id.to_string();
379
380    for msg in &sub.messages {
381        if let Some(mut step) =
382            build_step(msg, &convo_artifact, Some(&local_parent), actors, config)
383        {
384            // Prefix sub-agent step IDs to avoid collisions with main-chat
385            // step IDs (which are derived from the message UUID prefix).
386            let session_tag = if sub.session_id.is_empty() {
387                "sub".to_string()
388            } else {
389                safe_prefix(&sub.session_id, 6)
390            };
391            step.step.id = format!("sub-{}-{}", session_tag, safe_prefix(&msg.id, 8));
392            step.step.parents = vec![local_parent.clone()];
393            local_parent = step.step.id.clone();
394            steps.push(step);
395        }
396    }
397}
398
399fn resolve_actor(
400    msg: &GeminiMessage,
401    actors: &mut HashMap<String, ActorDefinition>,
402) -> (String, &'static str) {
403    match &msg.role {
404        GeminiRole::User => {
405            actors
406                .entry("human:user".to_string())
407                .or_insert_with(|| ActorDefinition {
408                    name: Some("User".to_string()),
409                    ..Default::default()
410                });
411            ("human:user".to_string(), "user")
412        }
413        GeminiRole::Gemini => {
414            let (actor_key, model_str) = match &msg.model {
415                Some(m) if !m.is_empty() => (format!("agent:{}", m), m.clone()),
416                _ => ("agent:gemini-cli".to_string(), "gemini-cli".to_string()),
417            };
418            actors
419                .entry(actor_key.clone())
420                .or_insert_with(|| ActorDefinition {
421                    name: Some("Gemini CLI".to_string()),
422                    provider: Some("google".to_string()),
423                    model: Some(model_str.clone()),
424                    identities: vec![Identity {
425                        system: "google".to_string(),
426                        id: model_str,
427                    }],
428                    ..Default::default()
429                });
430            (actor_key, "gemini")
431        }
432        GeminiRole::Info => {
433            actors
434                .entry("system:gemini-cli".to_string())
435                .or_insert_with(|| ActorDefinition {
436                    name: Some("Gemini CLI system".to_string()),
437                    provider: Some("google".to_string()),
438                    ..Default::default()
439                });
440            ("system:gemini-cli".to_string(), "info")
441        }
442        GeminiRole::Other(s) => {
443            let key = format!("other:{}", s);
444            actors
445                .entry(key.clone())
446                .or_insert_with(|| ActorDefinition {
447                    name: Some(s.clone()),
448                    ..Default::default()
449                });
450            // Static string only — unknown roles render as "other" in the
451            // conversation.append payload for readability.
452            (key, "other")
453        }
454    }
455}
456
457fn convo_artifact_uri(chat: &ChatFile) -> String {
458    let sid = if chat.session_id.is_empty() {
459        "unknown".to_string()
460    } else {
461        chat.session_id.clone()
462    };
463    format!("gemini://{}", sid)
464}
465
466fn safe_prefix(s: &str, n: usize) -> String {
467    s.chars().take(n).collect()
468}
469
470// ── Tests ────────────────────────────────────────────────────────────
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::types::ChatFile;
476    use serde_json::Value;
477
478    fn parse_chat(s: &str) -> ChatFile {
479        serde_json::from_str(s).unwrap()
480    }
481
482    fn main_only_convo() -> Conversation {
483        let chat = parse_chat(
484            r#"{
485  "sessionId":"sess1",
486  "projectHash":"h",
487  "startTime":"2026-04-17T10:00:00Z",
488  "lastUpdated":"2026-04-17T10:10:00Z",
489  "directories":["/abs/project"],
490  "messages":[
491    {"id":"user-1111aaaa","timestamp":"2026-04-17T10:00:00Z","type":"user","content":[{"text":"Fix the bug"}]},
492    {"id":"ai-2222bbbb","timestamp":"2026-04-17T10:00:01Z","type":"gemini","content":"I'll look.","model":"gemini-3-flash-preview"},
493    {"id":"ai-3333cccc","timestamp":"2026-04-17T10:01:00Z","type":"gemini","content":"Writing fix.","model":"gemini-3-flash-preview","toolCalls":[
494      {"id":"w1","name":"write_file","args":{"file_path":"/abs/project/src/main.rs","content":"fn main(){}"},"status":"success","timestamp":"2026-04-17T10:01:00Z","result":[{"functionResponse":{"id":"w1","name":"write_file","response":{"output":"ok"}}}]}
495    ]}
496  ]
497}"#,
498        );
499        let mut convo = Conversation::new("uuid-1".to_string(), chat);
500        convo.project_path = Some("/abs/project".to_string());
501        convo
502    }
503
504    #[test]
505    fn test_derive_path_basic() {
506        let convo = main_only_convo();
507        let path = derive_path(&convo, &DeriveConfig::default());
508        assert!(path.path.id.starts_with("path-gemini-"));
509        assert_eq!(path.steps.len(), 3);
510        assert_eq!(path.steps[0].step.actor, "human:user");
511        assert!(path.steps[1].step.actor.starts_with("agent:"));
512    }
513
514    #[test]
515    fn test_derive_path_head_is_last_step() {
516        let convo = main_only_convo();
517        let path = derive_path(&convo, &DeriveConfig::default());
518        assert_eq!(path.path.head, path.steps.last().unwrap().step.id);
519    }
520
521    #[test]
522    fn test_derive_path_parents_chain() {
523        let convo = main_only_convo();
524        let path = derive_path(&convo, &DeriveConfig::default());
525        assert!(path.steps[0].step.parents.is_empty());
526        assert_eq!(
527            path.steps[1].step.parents,
528            vec![path.steps[0].step.id.clone()]
529        );
530        assert_eq!(
531            path.steps[2].step.parents,
532            vec![path.steps[1].step.id.clone()]
533        );
534    }
535
536    #[test]
537    fn test_derive_path_conversation_artifact() {
538        let convo = main_only_convo();
539        let path = derive_path(&convo, &DeriveConfig::default());
540        let artifact = "gemini://sess1";
541        assert!(path.steps[0].change.contains_key(artifact));
542        let structural = path.steps[0].change[artifact].structural.as_ref().unwrap();
543        assert_eq!(structural.change_type, "conversation.append");
544        assert_eq!(structural.extra["role"], "user");
545    }
546
547    #[test]
548    fn test_derive_path_file_write_artifact() {
549        let convo = main_only_convo();
550        let path = derive_path(&convo, &DeriveConfig::default());
551        let write_step = &path.steps[2];
552        assert!(write_step.change.contains_key("/abs/project/src/main.rs"));
553    }
554
555    #[test]
556    fn test_derive_path_actors_populated() {
557        let convo = main_only_convo();
558        let path = derive_path(&convo, &DeriveConfig::default());
559        let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
560        assert!(actors.contains_key("human:user"));
561        assert!(actors.contains_key("agent:gemini-3-flash-preview"));
562    }
563
564    #[test]
565    fn test_derive_path_base_from_project_path() {
566        let convo = main_only_convo();
567        let path = derive_path(
568            &convo,
569            &DeriveConfig {
570                project_path: Some("/override".to_string()),
571                include_thinking: false,
572            },
573        );
574        assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///override");
575    }
576
577    #[test]
578    fn test_derive_path_base_from_directories_fallback() {
579        // Scrub project_path from conversation: should fall back to directories[0]
580        let mut convo = main_only_convo();
581        convo.project_path = None;
582        let path = derive_path(&convo, &DeriveConfig::default());
583        assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///abs/project");
584    }
585
586    #[test]
587    fn test_derive_path_no_base_when_unknown() {
588        let mut convo = main_only_convo();
589        convo.project_path = None;
590        convo.main.directories = None;
591        let path = derive_path(&convo, &DeriveConfig::default());
592        assert!(path.path.base.is_none());
593    }
594
595    #[test]
596    fn test_derive_path_skips_empty_messages() {
597        let chat = parse_chat(
598            r#"{
599  "sessionId":"x","projectHash":"","messages":[
600    {"id":"m1","timestamp":"ts","type":"user","content":""},
601    {"id":"m2","timestamp":"ts","type":"user","content":[{"text":"   "}]},
602    {"id":"m3","timestamp":"ts","type":"user","content":[{"text":"hello"}]}
603  ]
604}"#,
605        );
606        let convo = Conversation::new("uuid".into(), chat);
607        let path = derive_path(&convo, &DeriveConfig::default());
608        assert_eq!(path.steps.len(), 1);
609        assert_eq!(path.steps[0].step.id, "step-m3");
610    }
611
612    #[test]
613    fn test_derive_path_falls_back_to_gemini_cli_actor() {
614        let chat = parse_chat(
615            r#"{
616  "sessionId":"x","projectHash":"","messages":[
617    {"id":"m1","timestamp":"ts","type":"gemini","content":"hello"}
618  ]
619}"#,
620        );
621        let convo = Conversation::new("uuid".into(), chat);
622        let path = derive_path(&convo, &DeriveConfig::default());
623        assert_eq!(path.steps[0].step.actor, "agent:gemini-cli");
624    }
625
626    #[test]
627    fn test_derive_path_with_replace_tool() {
628        let chat = parse_chat(
629            r#"{
630  "sessionId":"x","projectHash":"","messages":[
631    {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
632      {"id":"r","name":"replace","args":{"file_path":"src/a.rs","oldString":"x","newString":"y"},"status":"success","timestamp":"ts"}
633    ]}
634  ]
635}"#,
636        );
637        let convo = Conversation::new("uuid".into(), chat);
638        let path = derive_path(&convo, &DeriveConfig::default());
639        assert!(path.steps[0].change.contains_key("src/a.rs"));
640    }
641
642    #[test]
643    fn test_derive_path_thinking_included_when_enabled() {
644        let chat = parse_chat(
645            r#"{
646  "sessionId":"x","projectHash":"","messages":[
647    {"id":"m1","timestamp":"ts","type":"gemini","content":"plan","thoughts":[{"subject":"s","description":"deep thought","timestamp":"ts"}]}
648  ]
649}"#,
650        );
651        let convo = Conversation::new("uuid".into(), chat);
652        let path = derive_path(
653            &convo,
654            &DeriveConfig {
655                project_path: None,
656                include_thinking: true,
657            },
658        );
659        let text = path.steps[0].change["gemini://x"]
660            .structural
661            .as_ref()
662            .unwrap()
663            .extra["text"]
664            .as_str()
665            .unwrap();
666        assert!(text.contains("deep thought"));
667    }
668
669    #[test]
670    fn test_derive_path_thinking_omitted_by_default() {
671        let chat = parse_chat(
672            r#"{
673  "sessionId":"x","projectHash":"","messages":[
674    {"id":"m1","timestamp":"ts","type":"gemini","content":"plan","thoughts":[{"subject":"s","description":"deep thought","timestamp":"ts"}]}
675  ]
676}"#,
677        );
678        let convo = Conversation::new("uuid".into(), chat);
679        let path = derive_path(&convo, &DeriveConfig::default());
680        let text = path.steps[0].change["gemini://x"]
681            .structural
682            .as_ref()
683            .unwrap()
684            .extra["text"]
685            .as_str()
686            .unwrap();
687        assert!(!text.contains("deep thought"));
688        assert!(text.contains("plan"));
689    }
690
691    #[test]
692    fn test_derive_path_sub_agent_steps() {
693        // Main chat delegates via `task`; sub-agent messages become extra
694        // steps parented under the main step.
695        let main_chat = parse_chat(
696            r#"{
697  "sessionId":"m","projectHash":"","messages":[
698    {"id":"u1","timestamp":"ts","type":"user","content":[{"text":"go"}]},
699    {"id":"a1","timestamp":"ts","type":"gemini","content":"delegating","model":"gemini-3-flash-preview","toolCalls":[
700      {"id":"t","name":"task","args":{"prompt":"search"},"status":"success","timestamp":"ts"}
701    ]}
702  ]
703}"#,
704        );
705        let sub_chat = parse_chat(
706            r#"{
707  "sessionId":"subby","projectHash":"","kind":"subagent","summary":"found","startTime":"2026-04-17T10:00:00Z","messages":[
708    {"id":"sa","timestamp":"ts","type":"user","content":[{"text":"sub prompt"}]},
709    {"id":"sb","timestamp":"ts","type":"gemini","content":"sub response","model":"gemini-3-flash-preview"}
710  ]
711}"#,
712        );
713        let mut convo = Conversation::new("uuid".into(), main_chat);
714        convo.sub_agents.push(sub_chat);
715
716        let path = derive_path(&convo, &DeriveConfig::default());
717
718        // 2 main steps + 2 sub steps
719        assert_eq!(path.steps.len(), 4);
720        // Sub steps have IDs starting with "sub-"
721        assert!(path.steps[2].step.id.starts_with("sub-"));
722        assert!(path.steps[3].step.id.starts_with("sub-"));
723        // First sub step is parented under the main assistant step (a1 -> step-a1)
724        assert_eq!(path.steps[2].step.parents, vec!["step-a1".to_string()]);
725        // Second sub step is parented under the first sub step
726        assert_eq!(
727            path.steps[3].step.parents,
728            vec![path.steps[2].step.id.clone()]
729        );
730        // Sub-agent artifact URI distinct from main
731        assert!(path.steps[2].change.contains_key("gemini://subby"));
732        assert!(path.steps[0].change.contains_key("gemini://m"));
733    }
734
735    #[test]
736    fn test_derive_path_leftover_subagent_attaches_to_last() {
737        // No `task` invocation, but a sub-agent file exists.
738        let main_chat = parse_chat(
739            r#"{
740  "sessionId":"m","projectHash":"","messages":[
741    {"id":"u1","timestamp":"ts","type":"user","content":[{"text":"go"}]}
742  ]
743}"#,
744        );
745        let sub_chat = parse_chat(
746            r#"{
747  "sessionId":"unlinked","projectHash":"","kind":"subagent","startTime":"2026-04-17T10:00:00Z","messages":[
748    {"id":"sx","timestamp":"ts","type":"user","content":[{"text":"something"}]}
749  ]
750}"#,
751        );
752        let mut convo = Conversation::new("uuid".into(), main_chat);
753        convo.sub_agents.push(sub_chat);
754
755        let path = derive_path(&convo, &DeriveConfig::default());
756        // One main + one sub
757        assert_eq!(path.steps.len(), 2);
758        assert!(path.steps[1].step.id.starts_with("sub-"));
759        // Attached to the last main step (step-u1)
760        assert_eq!(path.steps[1].step.parents, vec!["step-u1".to_string()]);
761    }
762
763    #[test]
764    fn test_derive_project_multiple() {
765        let a = main_only_convo();
766        let b = {
767            let mut c = main_only_convo();
768            c.main.session_id = "sess2".into();
769            c.session_uuid = "uuid-2".into();
770            c
771        };
772        let paths = derive_project(&[a, b], &DeriveConfig::default());
773        assert_eq!(paths.len(), 2);
774        assert!(paths[0].path.id.contains("sess1"));
775        assert!(paths[1].path.id.contains("sess2"));
776    }
777
778    #[test]
779    fn test_safe_prefix_behaviour() {
780        assert_eq!(safe_prefix("abc", 8), "abc");
781        assert_eq!(safe_prefix("abcdefghij", 8), "abcdefgh");
782        assert_eq!(safe_prefix("日本語", 2), "日本");
783    }
784
785    #[test]
786    fn test_convo_artifact_uri_unknown_fallback() {
787        let chat = parse_chat(r#"{"sessionId":"","projectHash":"","messages":[]}"#);
788        assert_eq!(convo_artifact_uri(&chat), "gemini://unknown");
789    }
790
791    #[test]
792    fn test_path_id_falls_back_to_session_uuid() {
793        let chat = parse_chat(
794            r#"{"sessionId":"","projectHash":"","messages":[{"id":"m","timestamp":"ts","type":"user","content":[{"text":"hi"}]}]}"#,
795        );
796        let convo = Conversation::new("long-session-uuid-123".into(), chat);
797        let path = derive_path(&convo, &DeriveConfig::default());
798        assert!(path.path.id.starts_with("path-gemini-"));
799        // Should use a prefix of the session UUID when sessionId is empty
800        assert!(path.path.id.contains("long-ses"));
801    }
802
803    #[test]
804    fn test_conversation_artifact_extra_fields() {
805        let convo = main_only_convo();
806        let path = derive_path(&convo, &DeriveConfig::default());
807        let structural = path.steps[2].change["gemini://sess1"]
808            .structural
809            .as_ref()
810            .unwrap();
811        assert_eq!(structural.extra["role"], "gemini");
812        let calls = structural.extra["tool_calls"].as_array().unwrap();
813        assert_eq!(calls[0]["name"], Value::String("write_file".to_string()));
814        assert_eq!(calls[0]["summary"], "/abs/project/src/main.rs");
815    }
816
817    #[test]
818    fn test_info_message_becomes_system_step() {
819        let chat = parse_chat(
820            r#"{"sessionId":"s","projectHash":"","messages":[
821  {"id":"u1","timestamp":"ts","type":"user","content":[{"text":"hi"}]},
822  {"id":"i1","timestamp":"ts","type":"info","content":"Request cancelled."}
823]}"#,
824        );
825        let convo = Conversation::new("uuid".into(), chat);
826        let path = derive_path(&convo, &DeriveConfig::default());
827        assert_eq!(path.steps.len(), 2);
828        assert_eq!(path.steps[1].step.actor, "system:gemini-cli");
829    }
830
831    #[test]
832    fn test_file_write_change_has_perspectives() {
833        // Verify at least one change perspective per RFC §"Change Perspectives"
834        let chat = parse_chat(
835            r#"{"sessionId":"s","projectHash":"","messages":[
836  {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
837    {"id":"w1","name":"write_file","args":{"file_path":"src/main.rs","content":"fn main() {}\n"},"status":"success","timestamp":"ts"}
838  ]}
839]}"#,
840        );
841        let convo = Conversation::new("uuid".into(), chat);
842        let path = derive_path(&convo, &DeriveConfig::default());
843        let change = &path.steps[0].change["src/main.rs"];
844        assert!(
845            change.raw.is_some() || change.structural.is_some(),
846            "at least one perspective must be populated"
847        );
848        assert!(change.structural.is_some());
849        let structural = change.structural.as_ref().unwrap();
850        assert_eq!(structural.change_type, "gemini.write_file");
851        assert_eq!(structural.extra["operation"], "write");
852        assert_eq!(structural.extra["byte_count"], 13);
853        // Fallback raw diff constructed from content
854        assert!(change.raw.as_ref().unwrap().contains("+fn main() {}"));
855    }
856
857    #[test]
858    fn test_replace_change_has_diff() {
859        let chat = parse_chat(
860            r#"{"sessionId":"s","projectHash":"","messages":[
861  {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
862    {"id":"r1","name":"replace","args":{"file_path":"src/main.rs","old_string":"hello","new_string":"world","instruction":"swap"},"status":"success","timestamp":"ts"}
863  ]}
864]}"#,
865        );
866        let convo = Conversation::new("uuid".into(), chat);
867        let path = derive_path(&convo, &DeriveConfig::default());
868        let change = &path.steps[0].change["src/main.rs"];
869        let raw = change.raw.as_ref().unwrap();
870        assert!(raw.contains("-hello"));
871        assert!(raw.contains("+world"));
872        let structural = change.structural.as_ref().unwrap();
873        assert_eq!(structural.extra["operation"], "replace");
874        assert_eq!(structural.extra["instruction"], "swap");
875    }
876
877    #[test]
878    fn test_file_diff_preferred_over_fallback() {
879        // When Gemini provides resultDisplay.fileDiff, it should be used as
880        // the raw perspective verbatim.
881        let chat = parse_chat(
882            r#"{"sessionId":"s","projectHash":"","messages":[
883  {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
884    {"id":"r1","name":"replace","args":{"file_path":"a.rs","old_string":"x","new_string":"y"},"status":"success","timestamp":"ts","resultDisplay":{"fileDiff":"Index: a.rs\n...GEMINI DIFF..."}}
885  ]}
886]}"#,
887        );
888        let convo = Conversation::new("uuid".into(), chat);
889        let path = derive_path(&convo, &DeriveConfig::default());
890        let raw = path.steps[0].change["a.rs"].raw.as_ref().unwrap();
891        assert!(raw.contains("GEMINI DIFF"));
892    }
893
894    #[test]
895    fn test_tool_call_summary_preserves_shell_command() {
896        let chat = parse_chat(
897            r#"{"sessionId":"s","projectHash":"","messages":[
898  {"id":"m1","timestamp":"ts","type":"gemini","content":"building","toolCalls":[
899    {"id":"s1","name":"run_shell_command","args":{"command":"cargo build --release"},"status":"success","timestamp":"ts"}
900  ]}
901]}"#,
902        );
903        let convo = Conversation::new("uuid".into(), chat);
904        let path = derive_path(&convo, &DeriveConfig::default());
905        let structural = path.steps[0].change["gemini://s"]
906            .structural
907            .as_ref()
908            .unwrap();
909        let calls = structural.extra["tool_calls"].as_array().unwrap();
910        assert_eq!(calls[0]["summary"], "cargo build --release");
911    }
912}