Skip to main content

toolpath_convo/
extract.rs

1//! Reconstruct a [`ConversationView`] from a toolpath [`Path`] using the
2//! conversation sub-protocol.
3//!
4//! The sub-protocol uses three structural change types:
5//!
6//! - **`conversation.init`** — sets session metadata (provider, session ID)
7//! - **`conversation.append`** — adds a turn (user or assistant message)
8//! - **`tool.invoke`** — attaches a tool invocation to a parent turn
9
10use std::collections::{HashMap, HashSet};
11
12use chrono::DateTime;
13use toolpath::v1::{Path, Step};
14
15use crate::{
16    ConversationEvent, ConversationView, DelegatedWork, EnvironmentSnapshot, FileMutation,
17    ProducerInfo, Role, SessionBase, TokenUsage, ToolCategory, ToolInvocation, ToolResult, Turn,
18};
19
20/// Extract a [`ConversationView`] from a toolpath [`Path`] document.
21///
22/// Steps are walked in order (they are already topologically sorted in the
23/// path). Structural changes with types `conversation.init`,
24/// `conversation.append`, and `tool.invoke` are recognized; everything else
25/// is silently skipped.
26pub fn extract_conversation(path: &Path) -> ConversationView {
27    let mut view = ConversationView::default();
28
29    // Project `path.base` back to `view.base`.
30    if let Some(base) = &path.path.base {
31        let working_dir = base
32            .uri
33            .strip_prefix("file://")
34            .map(|s| s.to_string())
35            .or_else(|| {
36                if base.uri.is_empty() {
37                    None
38                } else {
39                    Some(base.uri.clone())
40                }
41            });
42        let vcs_remote = path
43            .meta
44            .as_ref()
45            .and_then(|m| m.extra.get("vcs_remote"))
46            .and_then(|v| v.as_str())
47            .map(|s| s.to_string());
48        let sb = SessionBase {
49            working_dir,
50            vcs_revision: base.ref_str.clone(),
51            vcs_branch: base.branch.clone(),
52            vcs_remote,
53        };
54        if sb.working_dir.is_some()
55            || sb.vcs_revision.is_some()
56            || sb.vcs_branch.is_some()
57            || sb.vcs_remote.is_some()
58        {
59            view.base = Some(sb);
60        }
61    }
62
63    // Recover canonical session-level fields from `path.meta.extra`.
64    // Unrecognized keys are dropped — the IR is the cross-harness contract.
65    if let Some(meta) = &path.meta
66        && let Some(p) = meta
67            .extra
68            .get("producer")
69            .and_then(|v| serde_json::from_value::<ProducerInfo>(v.clone()).ok())
70    {
71        view.producer = Some(p);
72    }
73
74    // Map from step ID → index into view.turns, for parent lookups.
75    let mut step_to_turn: HashMap<&str, usize> = HashMap::new();
76    // Track files_changed for dedup in insertion order.
77    let mut files_seen: HashSet<String> = HashSet::new();
78
79    for step in &path.steps {
80        // Pre-collect file.write entries on this step. They attach to the
81        // turn built from this step's `conversation.append` change (below);
82        // the iteration order of `step.change` (HashMap) is non-deterministic
83        // so a pre-pass keeps the attach step simple. Sorted by path for
84        // determinism on the way back out.
85        let mut step_mutations: Vec<FileMutation> = Vec::new();
86        for (key, ch) in &step.change {
87            let Some(s) = &ch.structural else { continue };
88            if s.change_type != "file.write" {
89                continue;
90            }
91            let fm = FileMutation {
92                path: key.clone(),
93                tool_id: s
94                    .extra
95                    .get("tool_id")
96                    .and_then(|v| v.as_str())
97                    .map(|s| s.to_string()),
98                operation: s
99                    .extra
100                    .get("operation")
101                    .and_then(|v| v.as_str())
102                    .map(|s| s.to_string()),
103                raw_diff: ch.raw.clone(),
104                before: s
105                    .extra
106                    .get("before")
107                    .and_then(|v| v.as_str())
108                    .map(|s| s.to_string()),
109                after: s
110                    .extra
111                    .get("after")
112                    .and_then(|v| v.as_str())
113                    .map(|s| s.to_string()),
114                rename_to: s
115                    .extra
116                    .get("rename_to")
117                    .and_then(|v| v.as_str())
118                    .map(|s| s.to_string()),
119            };
120            step_mutations.push(fm);
121        }
122        step_mutations.sort_by(|a, b| a.path.cmp(&b.path));
123
124        for (artifact_key, artifact_change) in &step.change {
125            let structural = match &artifact_change.structural {
126                Some(s) => s,
127                None => continue,
128            };
129
130            match structural.change_type.as_str() {
131                "conversation.init" => {
132                    handle_init(&mut view, artifact_key, &structural.extra);
133                }
134                "conversation.append" => {
135                    // The shared-derive path doesn't emit conversation.init;
136                    // it encodes provider + session in the artifact key of
137                    // each append step (e.g. `gemini-cli://<session>`).
138                    // Pick them up the first time we see one.
139                    if view.id.is_empty()
140                        && let Some((provider, session)) = artifact_key.split_once("://")
141                        && !provider.is_empty()
142                        && !session.is_empty()
143                    {
144                        view.provider_id = Some(provider.to_string());
145                        view.id = session.to_string();
146                    }
147
148                    let mut turn = build_turn(step, &structural.extra);
149                    // Attach pre-collected file mutations to the turn.
150                    // `tool_id` on each mutation links back to the
151                    // specific `ToolInvocation` (when set by derive).
152                    if !step_mutations.is_empty() {
153                        turn.file_mutations = std::mem::take(&mut step_mutations);
154                    }
155                    let idx = view.turns.len();
156                    step_to_turn.insert(&step.step.id, idx);
157                    view.turns.push(turn);
158                }
159                "conversation.event" => {
160                    let event_type = structural
161                        .extra
162                        .get("entry_type")
163                        .and_then(|v| v.as_str())
164                        .unwrap_or("unknown")
165                        .to_string();
166                    // Restore the provider's original event id (e.g. the
167                    // source UUID for a Claude attachment). Falls back to
168                    // the synthetic step id for events that didn't have one.
169                    let id = structural
170                        .extra
171                        .get("event_source_id")
172                        .and_then(|v| v.as_str())
173                        .map(|s| s.to_string())
174                        .unwrap_or_else(|| step.step.id.clone());
175                    // Strip the housekeeping keys we added in derive so the
176                    // event's data round-trips clean. Restore the original
177                    // `type` key from `event_data_type` if it was stashed.
178                    let mut data = structural.extra.clone();
179                    data.remove("entry_type");
180                    data.remove("event_source_id");
181                    if let Some(t) = data.remove("event_data_type") {
182                        data.insert("type".to_string(), t);
183                    }
184
185                    let event = ConversationEvent {
186                        id,
187                        timestamp: step.step.timestamp.clone(),
188                        parent_id: step.step.parents.first().cloned(),
189                        event_type,
190                        data,
191                    };
192                    view.events.push(event);
193                }
194                "tool.invoke" => {
195                    let invocation = build_tool_invocation(&structural.extra);
196
197                    // Track files_changed for file_write tools with non agent:// keys.
198                    let category = parse_category(structural.extra.get("category"));
199                    if category == Some(ToolCategory::FileWrite)
200                        && !artifact_key.starts_with("agent://")
201                        && files_seen.insert(artifact_key.clone())
202                    {
203                        view.files_changed.push(artifact_key.clone());
204                    }
205
206                    // Attach to parent turn.
207                    if let Some(parent_id) = step.step.parents.first()
208                        && let Some(&turn_idx) = step_to_turn.get(parent_id.as_str())
209                    {
210                        view.turns[turn_idx].tool_uses.push(invocation);
211                    }
212                }
213                _ => {
214                    // Unknown structural change type — silently skip.
215                }
216            }
217        }
218    }
219
220    // Compute total_usage by summing across turns.
221    let mut has_any_usage = false;
222    let mut total = TokenUsage::default();
223    for turn in &view.turns {
224        if let Some(usage) = &turn.token_usage {
225            has_any_usage = true;
226            total.input_tokens = add_opt(total.input_tokens, usage.input_tokens);
227            total.output_tokens = add_opt(total.output_tokens, usage.output_tokens);
228            total.cache_read_tokens = add_opt(total.cache_read_tokens, usage.cache_read_tokens);
229            total.cache_write_tokens = add_opt(total.cache_write_tokens, usage.cache_write_tokens);
230        }
231    }
232    if has_any_usage {
233        view.total_usage = Some(total);
234    }
235
236    // Parse timestamps from first/last turns.
237    if let Some(first) = view.turns.first() {
238        view.started_at = DateTime::parse_from_rfc3339(&first.timestamp)
239            .ok()
240            .map(|dt| dt.with_timezone(&chrono::Utc));
241    }
242    if let Some(last) = view.turns.last() {
243        view.last_activity = DateTime::parse_from_rfc3339(&last.timestamp)
244            .ok()
245            .map(|dt| dt.with_timezone(&chrono::Utc));
246    }
247
248    view
249}
250
251fn handle_init(
252    view: &mut ConversationView,
253    artifact_key: &str,
254    extra: &HashMap<String, serde_json::Value>,
255) {
256    // Artifact key: agent://<provider>/<session-id>
257    if let Some(rest) = artifact_key.strip_prefix("agent://") {
258        let parts: Vec<&str> = rest.splitn(2, '/').collect();
259        if parts.len() == 2 {
260            view.provider_id = Some(parts[0].to_string());
261            view.id = parts[1].to_string();
262        }
263    }
264
265    // Also check extra for explicit values.
266    if let Some(serde_json::Value::String(v)) = extra.get("version") {
267        // Store version in session_ids as a convention, or just note it.
268        // For now, version is informational and not mapped to ConversationView fields.
269        let _ = v;
270    }
271}
272
273fn build_turn(step: &Step, extra: &HashMap<String, serde_json::Value>) -> Turn {
274    let role = if let Some(serde_json::Value::String(r)) = extra.get("role") {
275        parse_role(r)
276    } else {
277        role_from_actor(&step.step.actor)
278    };
279
280    let text = extra
281        .get("text")
282        .and_then(|v| v.as_str())
283        .unwrap_or("")
284        .to_string();
285
286    let thinking = extra
287        .get("thinking")
288        .and_then(|v| v.as_str())
289        .map(|s| s.to_string());
290
291    // Model is attributed via the step actor (`agent:{model}`).
292    let model = model_from_actor(&step.step.actor);
293
294    let stop_reason = extra
295        .get("stop_reason")
296        .and_then(|v| v.as_str())
297        .map(|s| s.to_string());
298
299    let token_usage = build_token_usage(extra);
300
301    let environment = build_environment(extra);
302
303    let tool_uses = build_inline_tool_uses(extra);
304
305    let delegations = build_delegations(extra);
306
307    let parent_id = step.step.parents.first().cloned();
308
309    let group_id = extra
310        .get("group_id")
311        .and_then(|v| v.as_str())
312        .map(|s| s.to_string());
313
314    let attributed_token_usage = extra
315        .get("attributed_token_usage")
316        .and_then(|v| serde_json::from_value::<TokenUsage>(v.clone()).ok());
317
318    Turn {
319        id: step.step.id.clone(),
320        parent_id,
321        group_id,
322        role,
323        timestamp: step.step.timestamp.clone(),
324        text,
325        thinking,
326        tool_uses,
327        model,
328        stop_reason,
329        token_usage,
330        attributed_token_usage,
331        environment,
332        delegations,
333        file_mutations: Vec::new(),
334    }
335}
336
337/// Build `Turn.environment` by preferring a nested `environment` object
338/// (shared-derive schema) and falling back to top-level `cwd`/`git_branch`
339/// (Claude's bespoke schema).
340fn build_environment(extra: &HashMap<String, serde_json::Value>) -> Option<EnvironmentSnapshot> {
341    if let Some(v) = extra.get("environment")
342        && let Ok(env) = serde_json::from_value::<EnvironmentSnapshot>(v.clone())
343    {
344        return Some(env);
345    }
346    let cwd = extra
347        .get("cwd")
348        .and_then(|v| v.as_str())
349        .map(|s| s.to_string());
350    let branch = extra
351        .get("git_branch")
352        .and_then(|v| v.as_str())
353        .map(|s| s.to_string());
354    if cwd.is_some() || branch.is_some() {
355        Some(EnvironmentSnapshot {
356            working_dir: cwd,
357            vcs_branch: branch,
358            vcs_revision: None,
359        })
360    } else {
361        None
362    }
363}
364
365/// Rehydrate tool invocations stored inline on a `conversation.append` step
366/// by the shared derive pipeline. Each entry carries `id`, `name`, `input`,
367/// `category`, and optionally `result`.
368fn build_inline_tool_uses(extra: &HashMap<String, serde_json::Value>) -> Vec<ToolInvocation> {
369    let Some(arr) = extra.get("tool_uses").and_then(|v| v.as_array()) else {
370        return Vec::new();
371    };
372    arr.iter()
373        .filter_map(|entry| {
374            let obj = entry.as_object()?;
375            let id = obj.get("id")?.as_str()?.to_string();
376            let name = obj.get("name")?.as_str()?.to_string();
377            let input = obj.get("input").cloned().unwrap_or(serde_json::Value::Null);
378            let category = parse_category(obj.get("category"));
379            let result = obj
380                .get("result")
381                .and_then(|v| serde_json::from_value::<ToolResult>(v.clone()).ok());
382            Some(ToolInvocation {
383                id,
384                name,
385                input,
386                result,
387                category,
388            })
389        })
390        .collect()
391}
392
393/// Rehydrate `Turn.delegations` stored on a `conversation.append` step.
394fn build_delegations(extra: &HashMap<String, serde_json::Value>) -> Vec<DelegatedWork> {
395    extra
396        .get("delegations")
397        .and_then(|v| serde_json::from_value::<Vec<DelegatedWork>>(v.clone()).ok())
398        .unwrap_or_default()
399}
400
401fn build_token_usage(extra: &HashMap<String, serde_json::Value>) -> Option<TokenUsage> {
402    // Shared-derive schema: nested `token_usage` object.
403    if let Some(v) = extra.get("token_usage")
404        && let Ok(usage) = serde_json::from_value::<TokenUsage>(v.clone())
405    {
406        return Some(usage);
407    }
408
409    // Claude bespoke schema: fields live at the top level of the extras.
410    let input = extra
411        .get("input_tokens")
412        .and_then(|v| v.as_u64())
413        .map(|n| n as u32);
414    let output = extra
415        .get("output_tokens")
416        .and_then(|v| v.as_u64())
417        .map(|n| n as u32);
418    let cache_read = extra
419        .get("cache_read_tokens")
420        .and_then(|v| v.as_u64())
421        .map(|n| n as u32);
422    let cache_write = extra
423        .get("cache_write_tokens")
424        .and_then(|v| v.as_u64())
425        .map(|n| n as u32);
426
427    if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() {
428        Some(TokenUsage {
429            input_tokens: input,
430            output_tokens: output,
431            cache_read_tokens: cache_read,
432            cache_write_tokens: cache_write,
433            ..Default::default()
434        })
435    } else {
436        None
437    }
438}
439
440fn build_tool_invocation(extra: &HashMap<String, serde_json::Value>) -> ToolInvocation {
441    let id = extra
442        .get("tool_use_id")
443        .and_then(|v| v.as_str())
444        .unwrap_or("")
445        .to_string();
446
447    let name = extra
448        .get("name")
449        .and_then(|v| v.as_str())
450        .unwrap_or("")
451        .to_string();
452
453    let input = extra
454        .get("input")
455        .cloned()
456        .unwrap_or(serde_json::Value::Null);
457
458    let is_error = extra
459        .get("is_error")
460        .and_then(|v| v.as_bool())
461        .unwrap_or(false);
462
463    let result_content = extra.get("result").and_then(|v| v.as_str());
464    let result = result_content.map(|content| ToolResult {
465        content: content.to_string(),
466        is_error,
467    });
468
469    let category = parse_category(extra.get("category"));
470
471    ToolInvocation {
472        id,
473        name,
474        input,
475        result,
476        category,
477    }
478}
479
480fn parse_category(value: Option<&serde_json::Value>) -> Option<ToolCategory> {
481    value
482        .and_then(|v| v.as_str())
483        .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
484}
485
486fn parse_role(s: &str) -> Role {
487    match s {
488        "user" => Role::User,
489        "assistant" => Role::Assistant,
490        "system" => Role::System,
491        other => Role::Other(other.to_string()),
492    }
493}
494
495/// Pull the model name out of a step actor string like `agent:claude-opus-4-7`.
496///
497/// Conventions:
498/// - `agent:{model}` → `Some("{model}")` (the standard attribution shape)
499/// - `agent:{model}/tool:…` → model is the part before the `/` (Claude's
500///   sub-actor style; only appears on non-turn tool steps, but handled for
501///   robustness)
502/// - `agent:unknown` → `None` — "unknown" is the sentinel the deriver writes
503///   when the source has no model
504/// - anything else (`human:…`, `system:…`, empty) → `None`
505fn model_from_actor(actor: &str) -> Option<String> {
506    let rest = actor.strip_prefix("agent:")?;
507    let model = match rest.split_once('/') {
508        Some((m, _)) => m,
509        None => rest,
510    };
511    if model.is_empty() || model == "unknown" {
512        None
513    } else {
514        Some(model.to_string())
515    }
516}
517
518fn role_from_actor(actor: &str) -> Role {
519    if actor.contains("/tool:") {
520        // Tool step — shouldn't be a turn, but if it is, treat as Other.
521        Role::Other("tool".to_string())
522    } else if actor.starts_with("human:") {
523        Role::User
524    } else if actor.starts_with("agent:") {
525        Role::Assistant
526    } else if actor.starts_with("tool:") {
527        Role::System
528    } else {
529        Role::Other(actor.to_string())
530    }
531}
532
533fn add_opt(a: Option<u32>, b: Option<u32>) -> Option<u32> {
534    match (a, b) {
535        (Some(x), Some(y)) => Some(x + y),
536        (Some(x), None) => Some(x),
537        (None, Some(y)) => Some(y),
538        (None, None) => None,
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use std::collections::HashMap;
546    use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange};
547
548    #[test]
549    fn test_model_from_actor_variants() {
550        assert_eq!(
551            model_from_actor("agent:claude-opus-4-7"),
552            Some("claude-opus-4-7".to_string())
553        );
554        assert_eq!(
555            model_from_actor("agent:gemini-3-flash-preview"),
556            Some("gemini-3-flash-preview".to_string())
557        );
558        // Sub-actor form (Claude tool steps): model is the part before "/".
559        assert_eq!(
560            model_from_actor("agent:claude-code/tool:Write"),
561            Some("claude-code".to_string())
562        );
563        // `unknown` is the deriver's sentinel for "no model"; decode to None.
564        assert_eq!(model_from_actor("agent:unknown"), None);
565        // Non-agent actors carry no model.
566        assert_eq!(model_from_actor("human:user"), None);
567        assert_eq!(model_from_actor("system:gemini-cli"), None);
568        assert_eq!(model_from_actor("tool:rustfmt"), None);
569        // Malformed / empty.
570        assert_eq!(model_from_actor(""), None);
571        assert_eq!(model_from_actor("agent:"), None);
572    }
573
574    fn make_path(steps: Vec<Step>) -> Path {
575        let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
576        Path {
577            path: PathIdentity {
578                id: "test-path".into(),
579                base: None,
580                head,
581                graph_ref: None,
582            },
583            steps,
584            meta: None,
585        }
586    }
587
588    fn make_step(
589        id: &str,
590        actor: &str,
591        timestamp: &str,
592        parents: Vec<&str>,
593        changes: Vec<(&str, &str, HashMap<String, serde_json::Value>)>,
594    ) -> Step {
595        let mut change = HashMap::new();
596        for (key, change_type, extra) in changes {
597            change.insert(
598                key.to_string(),
599                ArtifactChange {
600                    raw: None,
601                    structural: Some(StructuralChange {
602                        change_type: change_type.to_string(),
603                        extra,
604                    }),
605                },
606            );
607        }
608        Step {
609            step: toolpath::v1::StepIdentity {
610                id: id.to_string(),
611                parents: parents.into_iter().map(String::from).collect(),
612                actor: actor.to_string(),
613                timestamp: timestamp.to_string(),
614            },
615            change,
616            meta: None,
617        }
618    }
619
620    fn extras(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
621        pairs
622            .iter()
623            .map(|(k, v)| (k.to_string(), v.clone()))
624            .collect()
625    }
626
627    #[test]
628    fn test_empty_path() {
629        let path = make_path(vec![]);
630        let view = extract_conversation(&path);
631        assert!(view.id.is_empty());
632        assert!(view.turns.is_empty());
633        assert!(view.total_usage.is_none());
634        assert!(view.started_at.is_none());
635        assert!(view.last_activity.is_none());
636        assert!(view.files_changed.is_empty());
637    }
638
639    #[test]
640    fn test_init_sets_metadata() {
641        let path = make_path(vec![make_step(
642            "step-001",
643            "tool:claude-code",
644            "2026-01-01T00:00:00Z",
645            vec![],
646            vec![(
647                "agent://claude-code/sess-abc",
648                "conversation.init",
649                extras(&[("version", serde_json::json!("1.0"))]),
650            )],
651        )]);
652
653        let view = extract_conversation(&path);
654        assert_eq!(view.id, "sess-abc");
655        assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
656    }
657
658    #[test]
659    fn test_simple_conversation() {
660        let path = make_path(vec![
661            make_step(
662                "step-001",
663                "tool:claude-code",
664                "2026-01-01T00:00:00Z",
665                vec![],
666                vec![(
667                    "agent://claude-code/sess-1",
668                    "conversation.init",
669                    HashMap::new(),
670                )],
671            ),
672            make_step(
673                "step-002",
674                "human:alex",
675                "2026-01-01T00:00:01Z",
676                vec!["step-001"],
677                vec![(
678                    "agent://claude-code/sess-1",
679                    "conversation.append",
680                    extras(&[
681                        ("role", serde_json::json!("user")),
682                        ("text", serde_json::json!("Fix the bug")),
683                    ]),
684                )],
685            ),
686            make_step(
687                "step-003",
688                "agent:claude-opus-4-6",
689                "2026-01-01T00:00:02Z",
690                vec!["step-002"],
691                vec![(
692                    "agent://claude-code/sess-1",
693                    "conversation.append",
694                    extras(&[
695                        ("role", serde_json::json!("assistant")),
696                        ("text", serde_json::json!("I'll fix that.")),
697                    ]),
698                )],
699            ),
700        ]);
701
702        let view = extract_conversation(&path);
703        assert_eq!(view.turns.len(), 2);
704        assert_eq!(view.turns[0].role, Role::User);
705        assert_eq!(view.turns[0].text, "Fix the bug");
706        assert_eq!(view.turns[0].id, "step-002");
707        assert_eq!(view.turns[1].role, Role::Assistant);
708        assert_eq!(view.turns[1].text, "I'll fix that.");
709        assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
710    }
711
712    #[test]
713    fn test_group_id_round_trips_through_extraction() {
714        let path = make_path(vec![make_step(
715            "step-001",
716            "agent:claude-opus-4-6",
717            "2026-01-01T00:00:00Z",
718            vec![],
719            vec![(
720                "claude-code://sess-1",
721                "conversation.append",
722                extras(&[
723                    ("role", serde_json::json!("assistant")),
724                    ("text", serde_json::json!("")),
725                    ("group_id", serde_json::json!("msg_01abc")),
726                ]),
727            )],
728        )]);
729
730        let view = extract_conversation(&path);
731        assert_eq!(view.turns[0].group_id.as_deref(), Some("msg_01abc"));
732    }
733
734    #[test]
735    fn test_tool_invocations_attached_to_parent() {
736        let path = make_path(vec![
737            make_step(
738                "step-001",
739                "agent:claude-opus-4-6",
740                "2026-01-01T00:00:00Z",
741                vec![],
742                vec![(
743                    "agent://claude-code/sess-1",
744                    "conversation.append",
745                    extras(&[
746                        ("role", serde_json::json!("assistant")),
747                        ("text", serde_json::json!("Let me read the file.")),
748                    ]),
749                )],
750            ),
751            make_step(
752                "step-002",
753                "agent:claude-opus-4-6/tool:Read",
754                "2026-01-01T00:00:01Z",
755                vec!["step-001"],
756                vec![(
757                    "src/main.rs",
758                    "tool.invoke",
759                    extras(&[
760                        ("tool_use_id", serde_json::json!("tu-001")),
761                        ("name", serde_json::json!("Read")),
762                        ("input", serde_json::json!({"file_path": "src/main.rs"})),
763                        ("result", serde_json::json!("fn main() {}")),
764                        ("is_error", serde_json::json!(false)),
765                        ("category", serde_json::json!("file_read")),
766                    ]),
767                )],
768            ),
769        ]);
770
771        let view = extract_conversation(&path);
772        assert_eq!(view.turns.len(), 1);
773        assert_eq!(view.turns[0].tool_uses.len(), 1);
774        assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
775        assert_eq!(view.turns[0].tool_uses[0].name, "Read");
776        assert_eq!(
777            view.turns[0].tool_uses[0].category,
778            Some(ToolCategory::FileRead)
779        );
780        assert!(view.turns[0].tool_uses[0].result.is_some());
781        assert!(!view.turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
782    }
783
784    #[test]
785    fn test_token_usage_extracted_and_totaled() {
786        let path = make_path(vec![
787            make_step(
788                "step-001",
789                "human:alex",
790                "2026-01-01T00:00:00Z",
791                vec![],
792                vec![(
793                    "agent://claude-code/sess-1",
794                    "conversation.append",
795                    extras(&[
796                        ("role", serde_json::json!("user")),
797                        ("text", serde_json::json!("hello")),
798                    ]),
799                )],
800            ),
801            make_step(
802                "step-002",
803                "agent:claude-opus-4-6",
804                "2026-01-01T00:00:01Z",
805                vec!["step-001"],
806                vec![(
807                    "agent://claude-code/sess-1",
808                    "conversation.append",
809                    extras(&[
810                        ("role", serde_json::json!("assistant")),
811                        ("text", serde_json::json!("hi")),
812                        ("input_tokens", serde_json::json!(100)),
813                        ("output_tokens", serde_json::json!(50)),
814                        ("cache_read_tokens", serde_json::json!(80)),
815                    ]),
816                )],
817            ),
818            make_step(
819                "step-003",
820                "agent:claude-opus-4-6",
821                "2026-01-01T00:00:02Z",
822                vec!["step-002"],
823                vec![(
824                    "agent://claude-code/sess-1",
825                    "conversation.append",
826                    extras(&[
827                        ("role", serde_json::json!("assistant")),
828                        ("text", serde_json::json!("more")),
829                        ("input_tokens", serde_json::json!(200)),
830                        ("output_tokens", serde_json::json!(100)),
831                    ]),
832                )],
833            ),
834        ]);
835
836        let view = extract_conversation(&path);
837        let total = view.total_usage.as_ref().unwrap();
838        assert_eq!(total.input_tokens, Some(300));
839        assert_eq!(total.output_tokens, Some(150));
840        assert_eq!(total.cache_read_tokens, Some(80));
841        assert!(total.cache_write_tokens.is_none());
842    }
843
844    #[test]
845    fn test_thinking_blocks_extracted() {
846        let path = make_path(vec![make_step(
847            "step-001",
848            "agent:claude-opus-4-6",
849            "2026-01-01T00:00:00Z",
850            vec![],
851            vec![(
852                "agent://claude-code/sess-1",
853                "conversation.append",
854                extras(&[
855                    ("role", serde_json::json!("assistant")),
856                    ("text", serde_json::json!("The answer is 42.")),
857                    (
858                        "thinking",
859                        serde_json::json!("Let me think about this carefully..."),
860                    ),
861                ]),
862            )],
863        )]);
864
865        let view = extract_conversation(&path);
866        assert_eq!(view.turns.len(), 1);
867        assert_eq!(
868            view.turns[0].thinking.as_deref(),
869            Some("Let me think about this carefully...")
870        );
871    }
872
873    #[test]
874    fn test_parent_chain_preserved() {
875        let path = make_path(vec![
876            make_step(
877                "step-001",
878                "human:alex",
879                "2026-01-01T00:00:00Z",
880                vec![],
881                vec![(
882                    "agent://claude-code/sess-1",
883                    "conversation.append",
884                    extras(&[
885                        ("role", serde_json::json!("user")),
886                        ("text", serde_json::json!("first")),
887                    ]),
888                )],
889            ),
890            make_step(
891                "step-002",
892                "agent:claude-opus-4-6",
893                "2026-01-01T00:00:01Z",
894                vec!["step-001"],
895                vec![(
896                    "agent://claude-code/sess-1",
897                    "conversation.append",
898                    extras(&[
899                        ("role", serde_json::json!("assistant")),
900                        ("text", serde_json::json!("second")),
901                    ]),
902                )],
903            ),
904        ]);
905
906        let view = extract_conversation(&path);
907        assert!(view.turns[0].parent_id.is_none());
908        assert_eq!(view.turns[1].parent_id.as_deref(), Some("step-001"));
909    }
910
911    #[test]
912    fn test_unknown_structural_change_skipped() {
913        let path = make_path(vec![
914            make_step(
915                "step-001",
916                "human:alex",
917                "2026-01-01T00:00:00Z",
918                vec![],
919                vec![(
920                    "agent://claude-code/sess-1",
921                    "conversation.append",
922                    extras(&[
923                        ("role", serde_json::json!("user")),
924                        ("text", serde_json::json!("hello")),
925                    ]),
926                )],
927            ),
928            make_step(
929                "step-002",
930                "agent:claude-opus-4-6",
931                "2026-01-01T00:00:01Z",
932                vec!["step-001"],
933                vec![(
934                    "agent://claude-code/sess-1",
935                    "some.future.type",
936                    extras(&[("data", serde_json::json!("whatever"))]),
937                )],
938            ),
939        ]);
940
941        let view = extract_conversation(&path);
942        // Only the conversation.append step becomes a turn.
943        assert_eq!(view.turns.len(), 1);
944        assert_eq!(view.turns[0].text, "hello");
945    }
946
947    #[test]
948    fn test_role_fallback_from_actor() {
949        // No "role" extra — should infer from actor pattern.
950        let path = make_path(vec![
951            make_step(
952                "step-001",
953                "human:alex",
954                "2026-01-01T00:00:00Z",
955                vec![],
956                vec![(
957                    "agent://claude-code/sess-1",
958                    "conversation.append",
959                    extras(&[("text", serde_json::json!("hello"))]),
960                )],
961            ),
962            make_step(
963                "step-002",
964                "agent:claude-opus-4-6",
965                "2026-01-01T00:00:01Z",
966                vec!["step-001"],
967                vec![(
968                    "agent://claude-code/sess-1",
969                    "conversation.append",
970                    extras(&[("text", serde_json::json!("hi back"))]),
971                )],
972            ),
973            make_step(
974                "step-003",
975                "tool:system-prompt",
976                "2026-01-01T00:00:02Z",
977                vec!["step-002"],
978                vec![(
979                    "agent://claude-code/sess-1",
980                    "conversation.append",
981                    extras(&[("text", serde_json::json!("system message"))]),
982                )],
983            ),
984        ]);
985
986        let view = extract_conversation(&path);
987        assert_eq!(view.turns[0].role, Role::User);
988        assert_eq!(view.turns[1].role, Role::Assistant);
989        assert_eq!(view.turns[2].role, Role::System);
990    }
991
992    #[test]
993    fn test_multiple_tool_invocations_same_turn() {
994        let path = make_path(vec![
995            make_step(
996                "step-001",
997                "agent:claude-opus-4-6",
998                "2026-01-01T00:00:00Z",
999                vec![],
1000                vec![(
1001                    "agent://claude-code/sess-1",
1002                    "conversation.append",
1003                    extras(&[
1004                        ("role", serde_json::json!("assistant")),
1005                        ("text", serde_json::json!("Let me check two files.")),
1006                    ]),
1007                )],
1008            ),
1009            make_step(
1010                "step-002",
1011                "agent:claude-opus-4-6/tool:Read",
1012                "2026-01-01T00:00:01Z",
1013                vec!["step-001"],
1014                vec![(
1015                    "src/main.rs",
1016                    "tool.invoke",
1017                    extras(&[
1018                        ("tool_use_id", serde_json::json!("tu-001")),
1019                        ("name", serde_json::json!("Read")),
1020                        ("input", serde_json::json!({"file_path": "src/main.rs"})),
1021                        ("result", serde_json::json!("fn main() {}")),
1022                        ("category", serde_json::json!("file_read")),
1023                    ]),
1024                )],
1025            ),
1026            make_step(
1027                "step-003",
1028                "agent:claude-opus-4-6/tool:Read",
1029                "2026-01-01T00:00:02Z",
1030                vec!["step-001"],
1031                vec![(
1032                    "src/lib.rs",
1033                    "tool.invoke",
1034                    extras(&[
1035                        ("tool_use_id", serde_json::json!("tu-002")),
1036                        ("name", serde_json::json!("Read")),
1037                        ("input", serde_json::json!({"file_path": "src/lib.rs"})),
1038                        ("result", serde_json::json!("pub mod foo;")),
1039                        ("category", serde_json::json!("file_read")),
1040                    ]),
1041                )],
1042            ),
1043        ]);
1044
1045        let view = extract_conversation(&path);
1046        assert_eq!(view.turns.len(), 1);
1047        assert_eq!(view.turns[0].tool_uses.len(), 2);
1048        assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
1049        assert_eq!(view.turns[0].tool_uses[1].id, "tu-002");
1050    }
1051
1052    #[test]
1053    fn test_files_changed_from_file_write_tools() {
1054        let path = make_path(vec![
1055            make_step(
1056                "step-001",
1057                "agent:claude-opus-4-6",
1058                "2026-01-01T00:00:00Z",
1059                vec![],
1060                vec![(
1061                    "agent://claude-code/sess-1",
1062                    "conversation.append",
1063                    extras(&[
1064                        ("role", serde_json::json!("assistant")),
1065                        ("text", serde_json::json!("Writing files.")),
1066                    ]),
1067                )],
1068            ),
1069            make_step(
1070                "step-002",
1071                "agent:claude-opus-4-6/tool:Edit",
1072                "2026-01-01T00:00:01Z",
1073                vec!["step-001"],
1074                vec![(
1075                    "src/main.rs",
1076                    "tool.invoke",
1077                    extras(&[
1078                        ("tool_use_id", serde_json::json!("tu-001")),
1079                        ("name", serde_json::json!("Edit")),
1080                        ("input", serde_json::json!({})),
1081                        ("category", serde_json::json!("file_write")),
1082                    ]),
1083                )],
1084            ),
1085            make_step(
1086                "step-003",
1087                "agent:claude-opus-4-6/tool:Edit",
1088                "2026-01-01T00:00:02Z",
1089                vec!["step-001"],
1090                vec![(
1091                    "src/main.rs",
1092                    "tool.invoke",
1093                    extras(&[
1094                        ("tool_use_id", serde_json::json!("tu-002")),
1095                        ("name", serde_json::json!("Edit")),
1096                        ("input", serde_json::json!({})),
1097                        ("category", serde_json::json!("file_write")),
1098                    ]),
1099                )],
1100            ),
1101        ]);
1102
1103        let view = extract_conversation(&path);
1104        // Deduped — src/main.rs appears only once.
1105        assert_eq!(view.files_changed, vec!["src/main.rs"]);
1106    }
1107
1108    #[test]
1109    fn test_timestamps_parsed() {
1110        let path = make_path(vec![
1111            make_step(
1112                "step-001",
1113                "human:alex",
1114                "2026-01-01T10:00:00Z",
1115                vec![],
1116                vec![(
1117                    "agent://claude-code/sess-1",
1118                    "conversation.append",
1119                    extras(&[
1120                        ("role", serde_json::json!("user")),
1121                        ("text", serde_json::json!("hello")),
1122                    ]),
1123                )],
1124            ),
1125            make_step(
1126                "step-002",
1127                "agent:claude-opus-4-6",
1128                "2026-01-01T10:05:00Z",
1129                vec!["step-001"],
1130                vec![(
1131                    "agent://claude-code/sess-1",
1132                    "conversation.append",
1133                    extras(&[
1134                        ("role", serde_json::json!("assistant")),
1135                        ("text", serde_json::json!("hi")),
1136                    ]),
1137                )],
1138            ),
1139        ]);
1140
1141        let view = extract_conversation(&path);
1142        assert!(view.started_at.is_some());
1143        assert!(view.last_activity.is_some());
1144        assert!(view.last_activity.unwrap() > view.started_at.unwrap());
1145    }
1146
1147    #[test]
1148    fn test_steps_without_structural_changes_skipped() {
1149        let path = make_path(vec![make_step(
1150            "step-001",
1151            "human:alex",
1152            "2026-01-01T00:00:00Z",
1153            vec![],
1154            vec![], // no changes at all
1155        )]);
1156
1157        let view = extract_conversation(&path);
1158        assert!(view.turns.is_empty());
1159    }
1160
1161    #[test]
1162    fn test_environment_from_cwd_and_git_branch() {
1163        let path = make_path(vec![make_step(
1164            "step-001",
1165            "human:alex",
1166            "2026-01-01T00:00:00Z",
1167            vec![],
1168            vec![(
1169                "agent://claude-code/sess-1",
1170                "conversation.append",
1171                extras(&[
1172                    ("role", serde_json::json!("user")),
1173                    ("text", serde_json::json!("hello")),
1174                    ("cwd", serde_json::json!("/home/alex/project")),
1175                    ("git_branch", serde_json::json!("feature/cool")),
1176                ]),
1177            )],
1178        )]);
1179
1180        let view = extract_conversation(&path);
1181        let env = view.turns[0].environment.as_ref().unwrap();
1182        assert_eq!(env.working_dir.as_deref(), Some("/home/alex/project"));
1183        assert_eq!(env.vcs_branch.as_deref(), Some("feature/cool"));
1184        assert!(env.vcs_revision.is_none());
1185    }
1186
1187    #[test]
1188    fn test_environment_none_when_absent() {
1189        let path = make_path(vec![make_step(
1190            "step-001",
1191            "human:alex",
1192            "2026-01-01T00:00:00Z",
1193            vec![],
1194            vec![(
1195                "agent://claude-code/sess-1",
1196                "conversation.append",
1197                extras(&[
1198                    ("role", serde_json::json!("user")),
1199                    ("text", serde_json::json!("hello")),
1200                ]),
1201            )],
1202        )]);
1203
1204        let view = extract_conversation(&path);
1205        assert!(view.turns[0].environment.is_none());
1206    }
1207
1208    #[test]
1209    fn test_agent_url_tool_not_in_files_changed() {
1210        let path = make_path(vec![
1211            make_step(
1212                "step-001",
1213                "agent:claude-opus-4-6",
1214                "2026-01-01T00:00:00Z",
1215                vec![],
1216                vec![(
1217                    "agent://claude-code/sess-1",
1218                    "conversation.append",
1219                    extras(&[
1220                        ("role", serde_json::json!("assistant")),
1221                        ("text", serde_json::json!("Searching...")),
1222                    ]),
1223                )],
1224            ),
1225            make_step(
1226                "step-002",
1227                "agent:claude-opus-4-6/tool:WebSearch",
1228                "2026-01-01T00:00:01Z",
1229                vec!["step-001"],
1230                vec![(
1231                    "agent://claude-code/sess-1/tool/network/tu-001",
1232                    "tool.invoke",
1233                    extras(&[
1234                        ("tool_use_id", serde_json::json!("tu-001")),
1235                        ("name", serde_json::json!("WebSearch")),
1236                        ("input", serde_json::json!({"query": "rust async"})),
1237                        ("category", serde_json::json!("file_write")),
1238                    ]),
1239                )],
1240            ),
1241        ]);
1242
1243        let view = extract_conversation(&path);
1244        // agent:// URL should NOT appear in files_changed even with file_write category.
1245        assert!(view.files_changed.is_empty());
1246    }
1247
1248    #[test]
1249    fn test_conversation_event_extracted() {
1250        let path = make_path(vec![
1251            make_step(
1252                "step-001",
1253                "tool:claude-code",
1254                "2026-01-01T00:00:00Z",
1255                vec![],
1256                vec![(
1257                    "agent://claude-code/sess-1",
1258                    "conversation.event",
1259                    extras(&[
1260                        ("entry_type", serde_json::json!("attachment")),
1261                        ("cwd", serde_json::json!("/home/alex/project")),
1262                        ("version", serde_json::json!("1.0.30")),
1263                        (
1264                            "entry_extra",
1265                            serde_json::json!({"attachment": {"fileName": "test.png"}}),
1266                        ),
1267                    ]),
1268                )],
1269            ),
1270            make_step(
1271                "step-002",
1272                "tool:claude-code",
1273                "2026-01-01T00:00:01Z",
1274                vec!["step-001"],
1275                vec![(
1276                    "agent://claude-code/sess-1",
1277                    "conversation.event",
1278                    extras(&[
1279                        ("entry_type", serde_json::json!("file-history-snapshot")),
1280                        ("snapshot", serde_json::json!({"files": []})),
1281                    ]),
1282                )],
1283            ),
1284        ]);
1285
1286        let view = extract_conversation(&path);
1287        assert!(view.turns.is_empty());
1288        assert_eq!(view.events.len(), 2);
1289
1290        assert_eq!(view.events[0].id, "step-001");
1291        assert_eq!(view.events[0].event_type, "attachment");
1292        assert_eq!(
1293            view.events[0].data["cwd"],
1294            serde_json::json!("/home/alex/project")
1295        );
1296        assert_eq!(view.events[0].data["version"], serde_json::json!("1.0.30"));
1297        assert!(view.events[0].parent_id.is_none());
1298
1299        assert_eq!(view.events[1].id, "step-002");
1300        assert_eq!(view.events[1].event_type, "file-history-snapshot");
1301        assert_eq!(view.events[1].parent_id.as_deref(), Some("step-001"));
1302        assert!(view.events[1].data.contains_key("snapshot"));
1303    }
1304
1305    #[test]
1306    fn test_conversation_event_with_unknown_type() {
1307        let path = make_path(vec![make_step(
1308            "step-001",
1309            "tool:claude-code",
1310            "2026-01-01T00:00:00Z",
1311            vec![],
1312            vec![(
1313                "agent://claude-code/sess-1",
1314                "conversation.event",
1315                extras(&[("cwd", serde_json::json!("/tmp"))]),
1316            )],
1317        )]);
1318
1319        let view = extract_conversation(&path);
1320        assert_eq!(view.events.len(), 1);
1321        assert_eq!(view.events[0].event_type, "unknown");
1322    }
1323
1324    #[test]
1325    fn test_conversation_event_mixed_with_turns() {
1326        let path = make_path(vec![
1327            make_step(
1328                "step-001",
1329                "tool:claude-code",
1330                "2026-01-01T00:00:00Z",
1331                vec![],
1332                vec![(
1333                    "agent://claude-code/sess-1",
1334                    "conversation.event",
1335                    extras(&[("entry_type", serde_json::json!("system"))]),
1336                )],
1337            ),
1338            make_step(
1339                "step-002",
1340                "human:alex",
1341                "2026-01-01T00:00:01Z",
1342                vec!["step-001"],
1343                vec![(
1344                    "agent://claude-code/sess-1",
1345                    "conversation.append",
1346                    extras(&[
1347                        ("role", serde_json::json!("user")),
1348                        ("text", serde_json::json!("hello")),
1349                    ]),
1350                )],
1351            ),
1352        ]);
1353
1354        let view = extract_conversation(&path);
1355        assert_eq!(view.turns.len(), 1);
1356        assert_eq!(view.events.len(), 1);
1357        assert_eq!(view.turns[0].text, "hello");
1358        assert_eq!(view.events[0].event_type, "system");
1359    }
1360}