1use 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#[derive(Debug, Clone, Default)]
25pub struct DeriveConfig {
26 pub project_path: Option<String>,
28 pub include_thinking: bool,
30}
31
32pub 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 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 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 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
136pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
138 conversations
139 .iter()
140 .map(|c| derive_path(c, config))
141 .collect()
142}
143
144fn 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 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
239fn 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
257fn 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
326fn 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
364fn 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 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 (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#[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 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 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 assert_eq!(path.steps.len(), 4);
716 assert!(path.steps[2].step.id.starts_with("sub-"));
718 assert!(path.steps[3].step.id.starts_with("sub-"));
719 assert_eq!(path.steps[2].step.parents, vec!["step-a1".to_string()]);
721 assert_eq!(
723 path.steps[3].step.parents,
724 vec![path.steps[2].step.id.clone()]
725 );
726 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 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 assert_eq!(path.steps.len(), 2);
754 assert!(path.steps[1].step.id.starts_with("sub-"));
755 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 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 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 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 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}