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