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    Turn {
310        id: step.step.id.clone(),
311        parent_id,
312        role,
313        timestamp: step.step.timestamp.clone(),
314        text,
315        thinking,
316        tool_uses,
317        model,
318        stop_reason,
319        token_usage,
320        environment,
321        delegations,
322        file_mutations: Vec::new(),
323    }
324}
325
326/// Build `Turn.environment` by preferring a nested `environment` object
327/// (shared-derive schema) and falling back to top-level `cwd`/`git_branch`
328/// (Claude's bespoke schema).
329fn build_environment(extra: &HashMap<String, serde_json::Value>) -> Option<EnvironmentSnapshot> {
330    if let Some(v) = extra.get("environment")
331        && let Ok(env) = serde_json::from_value::<EnvironmentSnapshot>(v.clone())
332    {
333        return Some(env);
334    }
335    let cwd = extra
336        .get("cwd")
337        .and_then(|v| v.as_str())
338        .map(|s| s.to_string());
339    let branch = extra
340        .get("git_branch")
341        .and_then(|v| v.as_str())
342        .map(|s| s.to_string());
343    if cwd.is_some() || branch.is_some() {
344        Some(EnvironmentSnapshot {
345            working_dir: cwd,
346            vcs_branch: branch,
347            vcs_revision: None,
348        })
349    } else {
350        None
351    }
352}
353
354/// Rehydrate tool invocations stored inline on a `conversation.append` step
355/// by the shared derive pipeline. Each entry carries `id`, `name`, `input`,
356/// `category`, and optionally `result`.
357fn build_inline_tool_uses(extra: &HashMap<String, serde_json::Value>) -> Vec<ToolInvocation> {
358    let Some(arr) = extra.get("tool_uses").and_then(|v| v.as_array()) else {
359        return Vec::new();
360    };
361    arr.iter()
362        .filter_map(|entry| {
363            let obj = entry.as_object()?;
364            let id = obj.get("id")?.as_str()?.to_string();
365            let name = obj.get("name")?.as_str()?.to_string();
366            let input = obj.get("input").cloned().unwrap_or(serde_json::Value::Null);
367            let category = parse_category(obj.get("category"));
368            let result = obj
369                .get("result")
370                .and_then(|v| serde_json::from_value::<ToolResult>(v.clone()).ok());
371            Some(ToolInvocation {
372                id,
373                name,
374                input,
375                result,
376                category,
377            })
378        })
379        .collect()
380}
381
382/// Rehydrate `Turn.delegations` stored on a `conversation.append` step.
383fn build_delegations(extra: &HashMap<String, serde_json::Value>) -> Vec<DelegatedWork> {
384    extra
385        .get("delegations")
386        .and_then(|v| serde_json::from_value::<Vec<DelegatedWork>>(v.clone()).ok())
387        .unwrap_or_default()
388}
389
390fn build_token_usage(extra: &HashMap<String, serde_json::Value>) -> Option<TokenUsage> {
391    // Shared-derive schema: nested `token_usage` object.
392    if let Some(v) = extra.get("token_usage")
393        && let Ok(usage) = serde_json::from_value::<TokenUsage>(v.clone())
394    {
395        return Some(usage);
396    }
397
398    // Claude bespoke schema: fields live at the top level of the extras.
399    let input = extra
400        .get("input_tokens")
401        .and_then(|v| v.as_u64())
402        .map(|n| n as u32);
403    let output = extra
404        .get("output_tokens")
405        .and_then(|v| v.as_u64())
406        .map(|n| n as u32);
407    let cache_read = extra
408        .get("cache_read_tokens")
409        .and_then(|v| v.as_u64())
410        .map(|n| n as u32);
411    let cache_write = extra
412        .get("cache_write_tokens")
413        .and_then(|v| v.as_u64())
414        .map(|n| n as u32);
415
416    if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() {
417        Some(TokenUsage {
418            input_tokens: input,
419            output_tokens: output,
420            cache_read_tokens: cache_read,
421            cache_write_tokens: cache_write,
422        })
423    } else {
424        None
425    }
426}
427
428fn build_tool_invocation(extra: &HashMap<String, serde_json::Value>) -> ToolInvocation {
429    let id = extra
430        .get("tool_use_id")
431        .and_then(|v| v.as_str())
432        .unwrap_or("")
433        .to_string();
434
435    let name = extra
436        .get("name")
437        .and_then(|v| v.as_str())
438        .unwrap_or("")
439        .to_string();
440
441    let input = extra
442        .get("input")
443        .cloned()
444        .unwrap_or(serde_json::Value::Null);
445
446    let is_error = extra
447        .get("is_error")
448        .and_then(|v| v.as_bool())
449        .unwrap_or(false);
450
451    let result_content = extra.get("result").and_then(|v| v.as_str());
452    let result = result_content.map(|content| ToolResult {
453        content: content.to_string(),
454        is_error,
455    });
456
457    let category = parse_category(extra.get("category"));
458
459    ToolInvocation {
460        id,
461        name,
462        input,
463        result,
464        category,
465    }
466}
467
468fn parse_category(value: Option<&serde_json::Value>) -> Option<ToolCategory> {
469    value
470        .and_then(|v| v.as_str())
471        .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
472}
473
474fn parse_role(s: &str) -> Role {
475    match s {
476        "user" => Role::User,
477        "assistant" => Role::Assistant,
478        "system" => Role::System,
479        other => Role::Other(other.to_string()),
480    }
481}
482
483/// Pull the model name out of a step actor string like `agent:claude-opus-4-7`.
484///
485/// Conventions:
486/// - `agent:{model}` → `Some("{model}")` (the standard attribution shape)
487/// - `agent:{model}/tool:…` → model is the part before the `/` (Claude's
488///   sub-actor style; only appears on non-turn tool steps, but handled for
489///   robustness)
490/// - `agent:unknown` → `None` — "unknown" is the sentinel the deriver writes
491///   when the source has no model
492/// - anything else (`human:…`, `system:…`, empty) → `None`
493fn model_from_actor(actor: &str) -> Option<String> {
494    let rest = actor.strip_prefix("agent:")?;
495    let model = match rest.split_once('/') {
496        Some((m, _)) => m,
497        None => rest,
498    };
499    if model.is_empty() || model == "unknown" {
500        None
501    } else {
502        Some(model.to_string())
503    }
504}
505
506fn role_from_actor(actor: &str) -> Role {
507    if actor.contains("/tool:") {
508        // Tool step — shouldn't be a turn, but if it is, treat as Other.
509        Role::Other("tool".to_string())
510    } else if actor.starts_with("human:") {
511        Role::User
512    } else if actor.starts_with("agent:") {
513        Role::Assistant
514    } else if actor.starts_with("tool:") {
515        Role::System
516    } else {
517        Role::Other(actor.to_string())
518    }
519}
520
521fn add_opt(a: Option<u32>, b: Option<u32>) -> Option<u32> {
522    match (a, b) {
523        (Some(x), Some(y)) => Some(x + y),
524        (Some(x), None) => Some(x),
525        (None, Some(y)) => Some(y),
526        (None, None) => None,
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::collections::HashMap;
534    use toolpath::v1::{ArtifactChange, PathIdentity, StructuralChange};
535
536    #[test]
537    fn test_model_from_actor_variants() {
538        assert_eq!(
539            model_from_actor("agent:claude-opus-4-7"),
540            Some("claude-opus-4-7".to_string())
541        );
542        assert_eq!(
543            model_from_actor("agent:gemini-3-flash-preview"),
544            Some("gemini-3-flash-preview".to_string())
545        );
546        // Sub-actor form (Claude tool steps): model is the part before "/".
547        assert_eq!(
548            model_from_actor("agent:claude-code/tool:Write"),
549            Some("claude-code".to_string())
550        );
551        // `unknown` is the deriver's sentinel for "no model"; decode to None.
552        assert_eq!(model_from_actor("agent:unknown"), None);
553        // Non-agent actors carry no model.
554        assert_eq!(model_from_actor("human:user"), None);
555        assert_eq!(model_from_actor("system:gemini-cli"), None);
556        assert_eq!(model_from_actor("tool:rustfmt"), None);
557        // Malformed / empty.
558        assert_eq!(model_from_actor(""), None);
559        assert_eq!(model_from_actor("agent:"), None);
560    }
561
562    fn make_path(steps: Vec<Step>) -> Path {
563        let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default();
564        Path {
565            path: PathIdentity {
566                id: "test-path".into(),
567                base: None,
568                head,
569                graph_ref: None,
570            },
571            steps,
572            meta: None,
573        }
574    }
575
576    fn make_step(
577        id: &str,
578        actor: &str,
579        timestamp: &str,
580        parents: Vec<&str>,
581        changes: Vec<(&str, &str, HashMap<String, serde_json::Value>)>,
582    ) -> Step {
583        let mut change = HashMap::new();
584        for (key, change_type, extra) in changes {
585            change.insert(
586                key.to_string(),
587                ArtifactChange {
588                    raw: None,
589                    structural: Some(StructuralChange {
590                        change_type: change_type.to_string(),
591                        extra,
592                    }),
593                },
594            );
595        }
596        Step {
597            step: toolpath::v1::StepIdentity {
598                id: id.to_string(),
599                parents: parents.into_iter().map(String::from).collect(),
600                actor: actor.to_string(),
601                timestamp: timestamp.to_string(),
602            },
603            change,
604            meta: None,
605        }
606    }
607
608    fn extras(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
609        pairs
610            .iter()
611            .map(|(k, v)| (k.to_string(), v.clone()))
612            .collect()
613    }
614
615    #[test]
616    fn test_empty_path() {
617        let path = make_path(vec![]);
618        let view = extract_conversation(&path);
619        assert!(view.id.is_empty());
620        assert!(view.turns.is_empty());
621        assert!(view.total_usage.is_none());
622        assert!(view.started_at.is_none());
623        assert!(view.last_activity.is_none());
624        assert!(view.files_changed.is_empty());
625    }
626
627    #[test]
628    fn test_init_sets_metadata() {
629        let path = make_path(vec![make_step(
630            "step-001",
631            "tool:claude-code",
632            "2026-01-01T00:00:00Z",
633            vec![],
634            vec![(
635                "agent://claude-code/sess-abc",
636                "conversation.init",
637                extras(&[("version", serde_json::json!("1.0"))]),
638            )],
639        )]);
640
641        let view = extract_conversation(&path);
642        assert_eq!(view.id, "sess-abc");
643        assert_eq!(view.provider_id.as_deref(), Some("claude-code"));
644    }
645
646    #[test]
647    fn test_simple_conversation() {
648        let path = make_path(vec![
649            make_step(
650                "step-001",
651                "tool:claude-code",
652                "2026-01-01T00:00:00Z",
653                vec![],
654                vec![(
655                    "agent://claude-code/sess-1",
656                    "conversation.init",
657                    HashMap::new(),
658                )],
659            ),
660            make_step(
661                "step-002",
662                "human:alex",
663                "2026-01-01T00:00:01Z",
664                vec!["step-001"],
665                vec![(
666                    "agent://claude-code/sess-1",
667                    "conversation.append",
668                    extras(&[
669                        ("role", serde_json::json!("user")),
670                        ("text", serde_json::json!("Fix the bug")),
671                    ]),
672                )],
673            ),
674            make_step(
675                "step-003",
676                "agent:claude-opus-4-6",
677                "2026-01-01T00:00:02Z",
678                vec!["step-002"],
679                vec![(
680                    "agent://claude-code/sess-1",
681                    "conversation.append",
682                    extras(&[
683                        ("role", serde_json::json!("assistant")),
684                        ("text", serde_json::json!("I'll fix that.")),
685                    ]),
686                )],
687            ),
688        ]);
689
690        let view = extract_conversation(&path);
691        assert_eq!(view.turns.len(), 2);
692        assert_eq!(view.turns[0].role, Role::User);
693        assert_eq!(view.turns[0].text, "Fix the bug");
694        assert_eq!(view.turns[0].id, "step-002");
695        assert_eq!(view.turns[1].role, Role::Assistant);
696        assert_eq!(view.turns[1].text, "I'll fix that.");
697        assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
698    }
699
700    #[test]
701    fn test_tool_invocations_attached_to_parent() {
702        let path = make_path(vec![
703            make_step(
704                "step-001",
705                "agent:claude-opus-4-6",
706                "2026-01-01T00:00:00Z",
707                vec![],
708                vec![(
709                    "agent://claude-code/sess-1",
710                    "conversation.append",
711                    extras(&[
712                        ("role", serde_json::json!("assistant")),
713                        ("text", serde_json::json!("Let me read the file.")),
714                    ]),
715                )],
716            ),
717            make_step(
718                "step-002",
719                "agent:claude-opus-4-6/tool:Read",
720                "2026-01-01T00:00:01Z",
721                vec!["step-001"],
722                vec![(
723                    "src/main.rs",
724                    "tool.invoke",
725                    extras(&[
726                        ("tool_use_id", serde_json::json!("tu-001")),
727                        ("name", serde_json::json!("Read")),
728                        ("input", serde_json::json!({"file_path": "src/main.rs"})),
729                        ("result", serde_json::json!("fn main() {}")),
730                        ("is_error", serde_json::json!(false)),
731                        ("category", serde_json::json!("file_read")),
732                    ]),
733                )],
734            ),
735        ]);
736
737        let view = extract_conversation(&path);
738        assert_eq!(view.turns.len(), 1);
739        assert_eq!(view.turns[0].tool_uses.len(), 1);
740        assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
741        assert_eq!(view.turns[0].tool_uses[0].name, "Read");
742        assert_eq!(
743            view.turns[0].tool_uses[0].category,
744            Some(ToolCategory::FileRead)
745        );
746        assert!(view.turns[0].tool_uses[0].result.is_some());
747        assert!(!view.turns[0].tool_uses[0].result.as_ref().unwrap().is_error);
748    }
749
750    #[test]
751    fn test_token_usage_extracted_and_totaled() {
752        let path = make_path(vec![
753            make_step(
754                "step-001",
755                "human:alex",
756                "2026-01-01T00:00:00Z",
757                vec![],
758                vec![(
759                    "agent://claude-code/sess-1",
760                    "conversation.append",
761                    extras(&[
762                        ("role", serde_json::json!("user")),
763                        ("text", serde_json::json!("hello")),
764                    ]),
765                )],
766            ),
767            make_step(
768                "step-002",
769                "agent:claude-opus-4-6",
770                "2026-01-01T00:00:01Z",
771                vec!["step-001"],
772                vec![(
773                    "agent://claude-code/sess-1",
774                    "conversation.append",
775                    extras(&[
776                        ("role", serde_json::json!("assistant")),
777                        ("text", serde_json::json!("hi")),
778                        ("input_tokens", serde_json::json!(100)),
779                        ("output_tokens", serde_json::json!(50)),
780                        ("cache_read_tokens", serde_json::json!(80)),
781                    ]),
782                )],
783            ),
784            make_step(
785                "step-003",
786                "agent:claude-opus-4-6",
787                "2026-01-01T00:00:02Z",
788                vec!["step-002"],
789                vec![(
790                    "agent://claude-code/sess-1",
791                    "conversation.append",
792                    extras(&[
793                        ("role", serde_json::json!("assistant")),
794                        ("text", serde_json::json!("more")),
795                        ("input_tokens", serde_json::json!(200)),
796                        ("output_tokens", serde_json::json!(100)),
797                    ]),
798                )],
799            ),
800        ]);
801
802        let view = extract_conversation(&path);
803        let total = view.total_usage.as_ref().unwrap();
804        assert_eq!(total.input_tokens, Some(300));
805        assert_eq!(total.output_tokens, Some(150));
806        assert_eq!(total.cache_read_tokens, Some(80));
807        assert!(total.cache_write_tokens.is_none());
808    }
809
810    #[test]
811    fn test_thinking_blocks_extracted() {
812        let path = make_path(vec![make_step(
813            "step-001",
814            "agent:claude-opus-4-6",
815            "2026-01-01T00:00:00Z",
816            vec![],
817            vec![(
818                "agent://claude-code/sess-1",
819                "conversation.append",
820                extras(&[
821                    ("role", serde_json::json!("assistant")),
822                    ("text", serde_json::json!("The answer is 42.")),
823                    (
824                        "thinking",
825                        serde_json::json!("Let me think about this carefully..."),
826                    ),
827                ]),
828            )],
829        )]);
830
831        let view = extract_conversation(&path);
832        assert_eq!(view.turns.len(), 1);
833        assert_eq!(
834            view.turns[0].thinking.as_deref(),
835            Some("Let me think about this carefully...")
836        );
837    }
838
839    #[test]
840    fn test_parent_chain_preserved() {
841        let path = make_path(vec![
842            make_step(
843                "step-001",
844                "human:alex",
845                "2026-01-01T00:00:00Z",
846                vec![],
847                vec![(
848                    "agent://claude-code/sess-1",
849                    "conversation.append",
850                    extras(&[
851                        ("role", serde_json::json!("user")),
852                        ("text", serde_json::json!("first")),
853                    ]),
854                )],
855            ),
856            make_step(
857                "step-002",
858                "agent:claude-opus-4-6",
859                "2026-01-01T00:00:01Z",
860                vec!["step-001"],
861                vec![(
862                    "agent://claude-code/sess-1",
863                    "conversation.append",
864                    extras(&[
865                        ("role", serde_json::json!("assistant")),
866                        ("text", serde_json::json!("second")),
867                    ]),
868                )],
869            ),
870        ]);
871
872        let view = extract_conversation(&path);
873        assert!(view.turns[0].parent_id.is_none());
874        assert_eq!(view.turns[1].parent_id.as_deref(), Some("step-001"));
875    }
876
877    #[test]
878    fn test_unknown_structural_change_skipped() {
879        let path = make_path(vec![
880            make_step(
881                "step-001",
882                "human:alex",
883                "2026-01-01T00:00:00Z",
884                vec![],
885                vec![(
886                    "agent://claude-code/sess-1",
887                    "conversation.append",
888                    extras(&[
889                        ("role", serde_json::json!("user")),
890                        ("text", serde_json::json!("hello")),
891                    ]),
892                )],
893            ),
894            make_step(
895                "step-002",
896                "agent:claude-opus-4-6",
897                "2026-01-01T00:00:01Z",
898                vec!["step-001"],
899                vec![(
900                    "agent://claude-code/sess-1",
901                    "some.future.type",
902                    extras(&[("data", serde_json::json!("whatever"))]),
903                )],
904            ),
905        ]);
906
907        let view = extract_conversation(&path);
908        // Only the conversation.append step becomes a turn.
909        assert_eq!(view.turns.len(), 1);
910        assert_eq!(view.turns[0].text, "hello");
911    }
912
913    #[test]
914    fn test_role_fallback_from_actor() {
915        // No "role" extra — should infer from actor pattern.
916        let path = make_path(vec![
917            make_step(
918                "step-001",
919                "human:alex",
920                "2026-01-01T00:00:00Z",
921                vec![],
922                vec![(
923                    "agent://claude-code/sess-1",
924                    "conversation.append",
925                    extras(&[("text", serde_json::json!("hello"))]),
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                    "conversation.append",
936                    extras(&[("text", serde_json::json!("hi back"))]),
937                )],
938            ),
939            make_step(
940                "step-003",
941                "tool:system-prompt",
942                "2026-01-01T00:00:02Z",
943                vec!["step-002"],
944                vec![(
945                    "agent://claude-code/sess-1",
946                    "conversation.append",
947                    extras(&[("text", serde_json::json!("system message"))]),
948                )],
949            ),
950        ]);
951
952        let view = extract_conversation(&path);
953        assert_eq!(view.turns[0].role, Role::User);
954        assert_eq!(view.turns[1].role, Role::Assistant);
955        assert_eq!(view.turns[2].role, Role::System);
956    }
957
958    #[test]
959    fn test_multiple_tool_invocations_same_turn() {
960        let path = make_path(vec![
961            make_step(
962                "step-001",
963                "agent:claude-opus-4-6",
964                "2026-01-01T00:00:00Z",
965                vec![],
966                vec![(
967                    "agent://claude-code/sess-1",
968                    "conversation.append",
969                    extras(&[
970                        ("role", serde_json::json!("assistant")),
971                        ("text", serde_json::json!("Let me check two files.")),
972                    ]),
973                )],
974            ),
975            make_step(
976                "step-002",
977                "agent:claude-opus-4-6/tool:Read",
978                "2026-01-01T00:00:01Z",
979                vec!["step-001"],
980                vec![(
981                    "src/main.rs",
982                    "tool.invoke",
983                    extras(&[
984                        ("tool_use_id", serde_json::json!("tu-001")),
985                        ("name", serde_json::json!("Read")),
986                        ("input", serde_json::json!({"file_path": "src/main.rs"})),
987                        ("result", serde_json::json!("fn main() {}")),
988                        ("category", serde_json::json!("file_read")),
989                    ]),
990                )],
991            ),
992            make_step(
993                "step-003",
994                "agent:claude-opus-4-6/tool:Read",
995                "2026-01-01T00:00:02Z",
996                vec!["step-001"],
997                vec![(
998                    "src/lib.rs",
999                    "tool.invoke",
1000                    extras(&[
1001                        ("tool_use_id", serde_json::json!("tu-002")),
1002                        ("name", serde_json::json!("Read")),
1003                        ("input", serde_json::json!({"file_path": "src/lib.rs"})),
1004                        ("result", serde_json::json!("pub mod foo;")),
1005                        ("category", serde_json::json!("file_read")),
1006                    ]),
1007                )],
1008            ),
1009        ]);
1010
1011        let view = extract_conversation(&path);
1012        assert_eq!(view.turns.len(), 1);
1013        assert_eq!(view.turns[0].tool_uses.len(), 2);
1014        assert_eq!(view.turns[0].tool_uses[0].id, "tu-001");
1015        assert_eq!(view.turns[0].tool_uses[1].id, "tu-002");
1016    }
1017
1018    #[test]
1019    fn test_files_changed_from_file_write_tools() {
1020        let path = make_path(vec![
1021            make_step(
1022                "step-001",
1023                "agent:claude-opus-4-6",
1024                "2026-01-01T00:00:00Z",
1025                vec![],
1026                vec![(
1027                    "agent://claude-code/sess-1",
1028                    "conversation.append",
1029                    extras(&[
1030                        ("role", serde_json::json!("assistant")),
1031                        ("text", serde_json::json!("Writing files.")),
1032                    ]),
1033                )],
1034            ),
1035            make_step(
1036                "step-002",
1037                "agent:claude-opus-4-6/tool:Edit",
1038                "2026-01-01T00:00:01Z",
1039                vec!["step-001"],
1040                vec![(
1041                    "src/main.rs",
1042                    "tool.invoke",
1043                    extras(&[
1044                        ("tool_use_id", serde_json::json!("tu-001")),
1045                        ("name", serde_json::json!("Edit")),
1046                        ("input", serde_json::json!({})),
1047                        ("category", serde_json::json!("file_write")),
1048                    ]),
1049                )],
1050            ),
1051            make_step(
1052                "step-003",
1053                "agent:claude-opus-4-6/tool:Edit",
1054                "2026-01-01T00:00:02Z",
1055                vec!["step-001"],
1056                vec![(
1057                    "src/main.rs",
1058                    "tool.invoke",
1059                    extras(&[
1060                        ("tool_use_id", serde_json::json!("tu-002")),
1061                        ("name", serde_json::json!("Edit")),
1062                        ("input", serde_json::json!({})),
1063                        ("category", serde_json::json!("file_write")),
1064                    ]),
1065                )],
1066            ),
1067        ]);
1068
1069        let view = extract_conversation(&path);
1070        // Deduped — src/main.rs appears only once.
1071        assert_eq!(view.files_changed, vec!["src/main.rs"]);
1072    }
1073
1074    #[test]
1075    fn test_timestamps_parsed() {
1076        let path = make_path(vec![
1077            make_step(
1078                "step-001",
1079                "human:alex",
1080                "2026-01-01T10:00:00Z",
1081                vec![],
1082                vec![(
1083                    "agent://claude-code/sess-1",
1084                    "conversation.append",
1085                    extras(&[
1086                        ("role", serde_json::json!("user")),
1087                        ("text", serde_json::json!("hello")),
1088                    ]),
1089                )],
1090            ),
1091            make_step(
1092                "step-002",
1093                "agent:claude-opus-4-6",
1094                "2026-01-01T10:05:00Z",
1095                vec!["step-001"],
1096                vec![(
1097                    "agent://claude-code/sess-1",
1098                    "conversation.append",
1099                    extras(&[
1100                        ("role", serde_json::json!("assistant")),
1101                        ("text", serde_json::json!("hi")),
1102                    ]),
1103                )],
1104            ),
1105        ]);
1106
1107        let view = extract_conversation(&path);
1108        assert!(view.started_at.is_some());
1109        assert!(view.last_activity.is_some());
1110        assert!(view.last_activity.unwrap() > view.started_at.unwrap());
1111    }
1112
1113    #[test]
1114    fn test_steps_without_structural_changes_skipped() {
1115        let path = make_path(vec![make_step(
1116            "step-001",
1117            "human:alex",
1118            "2026-01-01T00:00:00Z",
1119            vec![],
1120            vec![], // no changes at all
1121        )]);
1122
1123        let view = extract_conversation(&path);
1124        assert!(view.turns.is_empty());
1125    }
1126
1127    #[test]
1128    fn test_environment_from_cwd_and_git_branch() {
1129        let path = make_path(vec![make_step(
1130            "step-001",
1131            "human:alex",
1132            "2026-01-01T00:00:00Z",
1133            vec![],
1134            vec![(
1135                "agent://claude-code/sess-1",
1136                "conversation.append",
1137                extras(&[
1138                    ("role", serde_json::json!("user")),
1139                    ("text", serde_json::json!("hello")),
1140                    ("cwd", serde_json::json!("/home/alex/project")),
1141                    ("git_branch", serde_json::json!("feature/cool")),
1142                ]),
1143            )],
1144        )]);
1145
1146        let view = extract_conversation(&path);
1147        let env = view.turns[0].environment.as_ref().unwrap();
1148        assert_eq!(env.working_dir.as_deref(), Some("/home/alex/project"));
1149        assert_eq!(env.vcs_branch.as_deref(), Some("feature/cool"));
1150        assert!(env.vcs_revision.is_none());
1151    }
1152
1153    #[test]
1154    fn test_environment_none_when_absent() {
1155        let path = make_path(vec![make_step(
1156            "step-001",
1157            "human:alex",
1158            "2026-01-01T00:00:00Z",
1159            vec![],
1160            vec![(
1161                "agent://claude-code/sess-1",
1162                "conversation.append",
1163                extras(&[
1164                    ("role", serde_json::json!("user")),
1165                    ("text", serde_json::json!("hello")),
1166                ]),
1167            )],
1168        )]);
1169
1170        let view = extract_conversation(&path);
1171        assert!(view.turns[0].environment.is_none());
1172    }
1173
1174    #[test]
1175    fn test_agent_url_tool_not_in_files_changed() {
1176        let path = make_path(vec![
1177            make_step(
1178                "step-001",
1179                "agent:claude-opus-4-6",
1180                "2026-01-01T00:00:00Z",
1181                vec![],
1182                vec![(
1183                    "agent://claude-code/sess-1",
1184                    "conversation.append",
1185                    extras(&[
1186                        ("role", serde_json::json!("assistant")),
1187                        ("text", serde_json::json!("Searching...")),
1188                    ]),
1189                )],
1190            ),
1191            make_step(
1192                "step-002",
1193                "agent:claude-opus-4-6/tool:WebSearch",
1194                "2026-01-01T00:00:01Z",
1195                vec!["step-001"],
1196                vec![(
1197                    "agent://claude-code/sess-1/tool/network/tu-001",
1198                    "tool.invoke",
1199                    extras(&[
1200                        ("tool_use_id", serde_json::json!("tu-001")),
1201                        ("name", serde_json::json!("WebSearch")),
1202                        ("input", serde_json::json!({"query": "rust async"})),
1203                        ("category", serde_json::json!("file_write")),
1204                    ]),
1205                )],
1206            ),
1207        ]);
1208
1209        let view = extract_conversation(&path);
1210        // agent:// URL should NOT appear in files_changed even with file_write category.
1211        assert!(view.files_changed.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_conversation_event_extracted() {
1216        let path = make_path(vec![
1217            make_step(
1218                "step-001",
1219                "tool:claude-code",
1220                "2026-01-01T00:00:00Z",
1221                vec![],
1222                vec![(
1223                    "agent://claude-code/sess-1",
1224                    "conversation.event",
1225                    extras(&[
1226                        ("entry_type", serde_json::json!("attachment")),
1227                        ("cwd", serde_json::json!("/home/alex/project")),
1228                        ("version", serde_json::json!("1.0.30")),
1229                        (
1230                            "entry_extra",
1231                            serde_json::json!({"attachment": {"fileName": "test.png"}}),
1232                        ),
1233                    ]),
1234                )],
1235            ),
1236            make_step(
1237                "step-002",
1238                "tool:claude-code",
1239                "2026-01-01T00:00:01Z",
1240                vec!["step-001"],
1241                vec![(
1242                    "agent://claude-code/sess-1",
1243                    "conversation.event",
1244                    extras(&[
1245                        ("entry_type", serde_json::json!("file-history-snapshot")),
1246                        ("snapshot", serde_json::json!({"files": []})),
1247                    ]),
1248                )],
1249            ),
1250        ]);
1251
1252        let view = extract_conversation(&path);
1253        assert!(view.turns.is_empty());
1254        assert_eq!(view.events.len(), 2);
1255
1256        assert_eq!(view.events[0].id, "step-001");
1257        assert_eq!(view.events[0].event_type, "attachment");
1258        assert_eq!(
1259            view.events[0].data["cwd"],
1260            serde_json::json!("/home/alex/project")
1261        );
1262        assert_eq!(view.events[0].data["version"], serde_json::json!("1.0.30"));
1263        assert!(view.events[0].parent_id.is_none());
1264
1265        assert_eq!(view.events[1].id, "step-002");
1266        assert_eq!(view.events[1].event_type, "file-history-snapshot");
1267        assert_eq!(view.events[1].parent_id.as_deref(), Some("step-001"));
1268        assert!(view.events[1].data.contains_key("snapshot"));
1269    }
1270
1271    #[test]
1272    fn test_conversation_event_with_unknown_type() {
1273        let path = make_path(vec![make_step(
1274            "step-001",
1275            "tool:claude-code",
1276            "2026-01-01T00:00:00Z",
1277            vec![],
1278            vec![(
1279                "agent://claude-code/sess-1",
1280                "conversation.event",
1281                extras(&[("cwd", serde_json::json!("/tmp"))]),
1282            )],
1283        )]);
1284
1285        let view = extract_conversation(&path);
1286        assert_eq!(view.events.len(), 1);
1287        assert_eq!(view.events[0].event_type, "unknown");
1288    }
1289
1290    #[test]
1291    fn test_conversation_event_mixed_with_turns() {
1292        let path = make_path(vec![
1293            make_step(
1294                "step-001",
1295                "tool:claude-code",
1296                "2026-01-01T00:00:00Z",
1297                vec![],
1298                vec![(
1299                    "agent://claude-code/sess-1",
1300                    "conversation.event",
1301                    extras(&[("entry_type", serde_json::json!("system"))]),
1302                )],
1303            ),
1304            make_step(
1305                "step-002",
1306                "human:alex",
1307                "2026-01-01T00:00:01Z",
1308                vec!["step-001"],
1309                vec![(
1310                    "agent://claude-code/sess-1",
1311                    "conversation.append",
1312                    extras(&[
1313                        ("role", serde_json::json!("user")),
1314                        ("text", serde_json::json!("hello")),
1315                    ]),
1316                )],
1317            ),
1318        ]);
1319
1320        let view = extract_conversation(&path);
1321        assert_eq!(view.turns.len(), 1);
1322        assert_eq!(view.events.len(), 1);
1323        assert_eq!(view.turns[0].text, "hello");
1324        assert_eq!(view.events[0].event_type, "system");
1325    }
1326}