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