Skip to main content

agx_core/
export.rs

1//! Session export — produces Markdown, HTML, or JSON representations of a
2//! parsed timeline. Used by the `--export md|html|json` flag.
3//!
4//! All three writers take the same inputs (steps + totals + no_cost flag)
5//! and return `String` — callers print to stdout or redirect to a file.
6//! No I/O happens inside this module.
7//!
8//! Schema stability: the JSON exporter's output is the reserved programmatic
9//! interface between agx and downstream consumers (Phase 7 library mode).
10//! Field renames or removals count as breaking changes.
11
12use crate::annotations::Annotations;
13use crate::timeline::{SessionTotals, Step, StepKind};
14use anyhow::Result;
15use serde::Serialize;
16
17#[derive(Debug, Serialize)]
18struct ExportJson<'a> {
19    totals: &'a SessionTotals,
20    steps: &'a [Step],
21    /// Per-step notes, indexed by 0-based step index. Only present when
22    /// the session has at least one annotation — absent (null) otherwise
23    /// to keep the common-case JSON small.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    annotations: Option<Vec<AnnotationLine>>,
26}
27
28/// JSON-friendly projection of a single note. Stable schema — downstream
29/// consumers (Phase 7 library mode) can rely on these field names.
30#[derive(Debug, Serialize)]
31struct AnnotationLine {
32    step_index: usize,
33    text: String,
34    created_at_ms: u64,
35    updated_at_ms: u64,
36}
37
38/// Serialize a session to stable-schema JSON. Pretty-printed for readability;
39/// callers that want compact output can `jq -c`. When `annotations` is
40/// `Some` and non-empty, adds an `annotations` array at the top level;
41/// omits the field entirely when there are no notes.
42pub fn json(
43    steps: &[Step],
44    totals: &SessionTotals,
45    annotations: Option<&Annotations>,
46) -> Result<String> {
47    let annotations_field = annotations.filter(|a| !a.is_empty()).map(|a| {
48        a.iter()
49            .map(|(idx, note)| AnnotationLine {
50                step_index: idx,
51                text: note.text.clone(),
52                created_at_ms: note.created_at_ms,
53                updated_at_ms: note.updated_at_ms,
54            })
55            .collect()
56    });
57    let payload = ExportJson {
58        totals,
59        steps,
60        annotations: annotations_field,
61    };
62    Ok(serde_json::to_string_pretty(&payload)?)
63}
64
65/// Render a session as a Markdown transcript. One H2 section per step, with
66/// metadata listed under the header and detail inside a code fence. Totals
67/// live in a short front-matter block at the top. When `annotations` is
68/// provided, a blockquote with the note text is emitted below the meta
69/// line for any step that has one.
70pub fn markdown(
71    steps: &[Step],
72    totals: &SessionTotals,
73    no_cost: bool,
74    annotations: Option<&Annotations>,
75) -> String {
76    let mut out = String::with_capacity(8 * 1024);
77    out.push_str("# agx session transcript\n\n");
78    out.push_str(&totals_header(totals, no_cost));
79    // Summary line mentioning annotation count (terse — skip entirely
80    // when there are none so the common case stays clean).
81    if let Some(a) = annotations
82        && !a.is_empty()
83    {
84        out.push_str(&format!("annotations: {}\n", a.iter().count()));
85    }
86    out.push('\n');
87    for (i, step) in steps.iter().enumerate() {
88        let (kind_label, kind_prefix) = md_kind(step.kind);
89        out.push_str(&format!(
90            "## {} — step {} {}\n\n",
91            kind_prefix,
92            i + 1,
93            kind_label
94        ));
95        let mut meta: Vec<String> = Vec::new();
96        if let Some(ms) = step.duration_ms {
97            meta.push(format!(
98                "**Δ**: {}",
99                crate::timeline::format_duration_ms(ms)
100            ));
101        }
102        if let Some(m) = &step.model {
103            meta.push(format!("**model**: `{m}`"));
104        }
105        if step.tokens_in.is_some() || step.tokens_out.is_some() {
106            meta.push(format!(
107                "**tokens**: in={} out={} cache_read={} cache_create={}",
108                step.tokens_in.unwrap_or(0),
109                step.tokens_out.unwrap_or(0),
110                step.cache_read.unwrap_or(0),
111                step.cache_create.unwrap_or(0),
112            ));
113        }
114        if !no_cost && let Some(c) = step.cost_usd() {
115            meta.push(format!("**cost**: ${c:.4}"));
116        }
117        if !meta.is_empty() {
118            out.push_str(&meta.join("  ·  "));
119            out.push_str("\n\n");
120        }
121        // Blockquote the annotation text (if any). Line-by-line so
122        // multi-line notes render as one quote block instead of one
123        // long paragraph.
124        if let Some(a) = annotations
125            && let Some(note) = a.get(i)
126        {
127            out.push_str("> **note**: ");
128            for (j, line) in note.text.lines().enumerate() {
129                if j > 0 {
130                    out.push_str("\n> ");
131                }
132                out.push_str(line);
133            }
134            out.push_str("\n\n");
135        }
136        out.push_str("```\n");
137        out.push_str(&step.detail);
138        if !step.detail.ends_with('\n') {
139            out.push('\n');
140        }
141        out.push_str("```\n\n");
142    }
143    out
144}
145
146/// Render a session as self-contained HTML with inline CSS, no JS, no
147/// external assets. Color palette mirrors the TUI — cyan/user,
148/// green/assistant, yellow/tool_use, magenta/tool_result. When
149/// `annotations` is provided, steps with a note render the text in a
150/// magenta-bordered `<div class="note">` below the meta line.
151pub fn html(
152    steps: &[Step],
153    totals: &SessionTotals,
154    no_cost: bool,
155    annotations: Option<&Annotations>,
156) -> String {
157    let mut out = String::with_capacity(16 * 1024);
158    out.push_str(
159        "<!DOCTYPE html>\n<html lang=\"en\"><head>\n\
160         <meta charset=\"utf-8\">\n\
161         <title>agx session</title>\n\
162         <style>\n\
163         body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; \
164               background: #0f0f12; color: #e0e0e0; margin: 0; padding: 2rem; }\n\
165         h1 { color: #fff; margin: 0 0 0.5rem 0; font-size: 1.3rem; }\n\
166         .totals { color: #b0b0b0; border-bottom: 1px solid #333; \
167                   padding-bottom: 1rem; margin-bottom: 1.5rem; }\n\
168         .step { margin: 1rem 0; padding: 0.75rem 1rem; border-left: 3px solid #444; \
169                 background: #16161a; }\n\
170         .step.user-text { border-left-color: #4dd0e1; }\n\
171         .step.assistant-text { border-left-color: #81c784; }\n\
172         .step.tool-use { border-left-color: #ffd54f; }\n\
173         .step.tool-result { border-left-color: #ba68c8; }\n\
174         .step h2 { margin: 0 0 0.5rem 0; font-size: 1rem; color: #ccc; }\n\
175         .meta { color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; }\n\
176         .meta code { color: #b0b0b0; }\n\
177         .note { border-left: 3px solid #ba68c8; padding: 0.4rem 0.6rem; \
178                 margin: 0 0 0.5rem 0; background: #1e1a22; color: #e0d0e8; \
179                 font-size: 0.9rem; }\n\
180         .note::before { content: \"note: \"; color: #ba68c8; font-weight: bold; }\n\
181         pre { white-space: pre-wrap; word-break: break-word; margin: 0; \
182               color: #d0d0d0; }\n\
183         </style>\n\
184         </head><body>\n",
185    );
186    out.push_str("<h1>agx session transcript</h1>\n<div class=\"totals\">\n");
187    out.push_str(&html_escape(&totals_header(totals, no_cost)).replace('\n', "<br>\n"));
188    if let Some(a) = annotations
189        && !a.is_empty()
190    {
191        out.push_str(&format!("<br>annotations: {}\n", a.iter().count()));
192    }
193    out.push_str("</div>\n");
194    for (i, step) in steps.iter().enumerate() {
195        let (kind_label, kind_class) = html_kind(step.kind);
196        out.push_str(&format!(
197            "<div class=\"step {kind_class}\"><h2>step {} — {kind_label}</h2>\n",
198            i + 1
199        ));
200        let mut meta: Vec<String> = Vec::new();
201        if let Some(ms) = step.duration_ms {
202            meta.push(format!("Δ {}", crate::timeline::format_duration_ms(ms)));
203        }
204        if let Some(m) = &step.model {
205            meta.push(format!("model <code>{}</code>", html_escape(m)));
206        }
207        if step.tokens_in.is_some() || step.tokens_out.is_some() {
208            meta.push(format!(
209                "tokens in={} out={} cache_r={} cache_c={}",
210                step.tokens_in.unwrap_or(0),
211                step.tokens_out.unwrap_or(0),
212                step.cache_read.unwrap_or(0),
213                step.cache_create.unwrap_or(0),
214            ));
215        }
216        if !no_cost && let Some(c) = step.cost_usd() {
217            meta.push(format!("cost ${c:.4}"));
218        }
219        if !meta.is_empty() {
220            out.push_str(&format!("<div class=\"meta\">{}</div>\n", meta.join(" · ")));
221        }
222        if let Some(a) = annotations
223            && let Some(note) = a.get(i)
224        {
225            out.push_str(&format!(
226                "<div class=\"note\">{}</div>\n",
227                html_escape(&note.text).replace('\n', "<br>\n")
228            ));
229        }
230        out.push_str(&format!(
231            "<pre>{}</pre>\n</div>\n",
232            html_escape(&step.detail)
233        ));
234    }
235    out.push_str("</body></html>\n");
236    out
237}
238
239// ---------- helpers ----------
240
241fn totals_header(totals: &SessionTotals, no_cost: bool) -> String {
242    let mut lines: Vec<String> = Vec::new();
243    if totals.has_tokens() {
244        lines.push(format!(
245            "tokens — in: {}, out: {}, cache_read: {}, cache_create: {}",
246            totals.tokens_in, totals.tokens_out, totals.cache_read, totals.cache_create,
247        ));
248    }
249    if !totals.unique_models.is_empty() {
250        lines.push(format!("models: {}", totals.unique_models.join(", ")));
251    }
252    if !no_cost {
253        match totals.cost_usd {
254            Some(c) => lines.push(format!("estimated cost: ${c:.4} USD")),
255            None if totals.has_tokens() => {
256                lines.push("estimated cost: (no pricing entry for model)".into());
257            }
258            None => {}
259        }
260    }
261    if lines.is_empty() {
262        String::new()
263    } else {
264        format!("{}\n", lines.join("\n"))
265    }
266}
267
268fn md_kind(kind: StepKind) -> (&'static str, &'static str) {
269    // ASCII-only prefixes per the project's terminal-native / no-emoji
270    // principle. See ROADMAP.md Phase 4.3 annotations subplan.
271    match kind {
272        StepKind::UserText => ("(user)", "[user]"),
273        StepKind::AssistantText => ("(assistant)", "[asst]"),
274        StepKind::ToolUse => ("(tool_use)", "[tool]"),
275        StepKind::ToolResult => ("(tool_result)", "[result]"),
276    }
277}
278
279fn html_kind(kind: StepKind) -> (&'static str, &'static str) {
280    match kind {
281        StepKind::UserText => ("user", "user-text"),
282        StepKind::AssistantText => ("assistant", "assistant-text"),
283        StepKind::ToolUse => ("tool_use", "tool-use"),
284        StepKind::ToolResult => ("tool_result", "tool-result"),
285    }
286}
287
288fn html_escape(s: &str) -> String {
289    let mut out = String::with_capacity(s.len());
290    for c in s.chars() {
291        match c {
292            '&' => out.push_str("&amp;"),
293            '<' => out.push_str("&lt;"),
294            '>' => out.push_str("&gt;"),
295            '"' => out.push_str("&quot;"),
296            '\'' => out.push_str("&#39;"),
297            _ => out.push(c),
298        }
299    }
300    out
301}
302
303// ---------- Phase 6 trajectory exports ----------
304
305/// Apply literal-substring redactions to a string. Every entry in
306/// `patterns` gets replaced with `[REDACTED]` wherever it appears.
307/// Order preserves the caller's list; each pattern is applied in
308/// turn so later redactions operate on the already-redacted text.
309///
310/// The `--redact` flag takes a `Vec<String>` that agx populates from
311/// the CLI; this helper is the single point where masking happens so
312/// every export format gets identical behavior.
313pub fn apply_redactions(text: &str, patterns: &[String]) -> String {
314    let mut out = text.to_string();
315    for pat in patterns {
316        if pat.is_empty() {
317            continue;
318        }
319        out = out.replace(pat, "[REDACTED]");
320    }
321    out
322}
323
324/// Extract the "Input:" section of a tool_use or tool_result step's
325/// detail string. The step constructors produce a deterministic format
326/// (`Tool: X\nID: Y\n\nInput:\n{input}\n\n...`) so this is safe to
327/// parse back out. Falls back to an empty string when the section is
328/// missing (e.g. a tool_result without captured input).
329fn extract_input_section(detail: &str) -> String {
330    // Split at the "Input:\n" marker; grab everything up to the next
331    // blank line or the "Result:" marker (whichever comes first).
332    let Some(after_input) = detail.split_once("\nInput:\n").map(|(_, s)| s) else {
333        return String::new();
334    };
335    let end = after_input
336        .find("\n\nResult:\n")
337        .or_else(|| after_input.find("\n\n"))
338        .unwrap_or(after_input.len());
339    after_input[..end].to_string()
340}
341
342/// Extract the "Result:" section of a tool_result step's detail. Same
343/// deterministic-format assumption as `extract_input_section`.
344fn extract_result_section(detail: &str) -> String {
345    detail
346        .split_once("\nResult:\n")
347        .map_or_else(String::new, |(_, s)| s.to_string())
348}
349
350/// One OpenAI fine-tuning `messages[]` entry. The schema matches the
351/// public OpenAI API (`role` + `content` + optional `tool_calls` on
352/// assistant, `tool_call_id` on role="tool") so the emitted JSONL is
353/// directly usable with the fine-tuning / batch endpoints.
354#[derive(Debug, Serialize)]
355struct OpenaiMessage<'a> {
356    role: &'static str,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    content: Option<String>,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    tool_calls: Option<Vec<OpenaiToolCall>>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    tool_call_id: Option<&'a str>,
363}
364
365#[derive(Debug, Serialize)]
366struct OpenaiToolCall {
367    id: String,
368    #[serde(rename = "type")]
369    type_: &'static str,
370    function: OpenaiFunction,
371}
372
373#[derive(Debug, Serialize)]
374struct OpenaiFunction {
375    name: String,
376    /// Arguments as a JSON-encoded string per the OpenAI spec (the
377    /// value is a *string*, not an object). Agx stores the pretty-
378    /// printed JSON in the step's detail section; we pass it through
379    /// verbatim, which OpenAI accepts.
380    arguments: String,
381}
382
383#[derive(Debug, Serialize)]
384struct OpenaiTrajectory<'a> {
385    messages: Vec<OpenaiMessage<'a>>,
386}
387
388/// Apply `--redact` patterns to a borrowed step slice, returning a
389/// redacted clone. Every exporter accepts an already-redacted `&[Step]`
390/// slice rather than the patterns themselves, so masking lives in one
391/// place and the format-specific code stays simple. Returns the
392/// original slice unchanged (via clone) when `patterns` is empty so
393/// the common case allocates nothing extra for unused features.
394pub fn redacted_steps(steps: &[Step], patterns: &[String]) -> Vec<Step> {
395    if patterns.is_empty() {
396        return steps.to_vec();
397    }
398    steps
399        .iter()
400        .map(|s| {
401            let mut c = s.clone();
402            c.label = apply_redactions(&c.label, patterns);
403            c.detail = apply_redactions(&c.detail, patterns);
404            c
405        })
406        .collect()
407}
408
409/// Render a session as one-line OpenAI fine-tuning JSONL. Emits a
410/// single JSON object per session (caller typically redirects to a
411/// `.jsonl` file). Each timeline step maps to one message:
412///
413/// - `UserText` → `{role: "user", content: …}`
414/// - `AssistantText` → `{role: "assistant", content: …}`
415/// - `ToolUse` → `{role: "assistant", tool_calls: [{id, type: "function", function: {name, arguments}}]}`
416/// - `ToolResult` → `{role: "tool", tool_call_id: …, content: …}`
417///
418/// Steps without a `tool_call_id` (produced by parsers that don't
419/// track one, though the shared helpers always set it) get a synthetic
420/// `call_{N}` ID derived from their position so pairing still works.
421/// Redaction is the caller's responsibility — run `redacted_steps`
422/// first if needed.
423pub fn trajectory_openai(steps: &[Step]) -> Result<String> {
424    let mut messages: Vec<OpenaiMessage<'_>> = Vec::with_capacity(steps.len());
425    // Stable ID fallback when a step lacks tool_call_id (shouldn't
426    // happen with the current parsers but keeps the exporter robust).
427    let fallback_ids: Vec<String> = (0..steps.len()).map(|i| format!("call_{i}")).collect();
428    for (i, step) in steps.iter().enumerate() {
429        let id: &str = step
430            .tool_call_id
431            .as_deref()
432            .unwrap_or_else(|| fallback_ids[i].as_str());
433        match step.kind {
434            StepKind::UserText => messages.push(OpenaiMessage {
435                role: "user",
436                content: Some(step.detail.clone()),
437                tool_calls: None,
438                tool_call_id: None,
439            }),
440            StepKind::AssistantText => messages.push(OpenaiMessage {
441                role: "assistant",
442                content: Some(step.detail.clone()),
443                tool_calls: None,
444                tool_call_id: None,
445            }),
446            StepKind::ToolUse => {
447                let input = extract_input_section(&step.detail);
448                messages.push(OpenaiMessage {
449                    role: "assistant",
450                    content: None,
451                    tool_calls: Some(vec![OpenaiToolCall {
452                        id: id.to_string(),
453                        type_: "function",
454                        function: OpenaiFunction {
455                            name: step.tool_name.clone().unwrap_or_default(),
456                            arguments: input,
457                        },
458                    }]),
459                    tool_call_id: None,
460                });
461            }
462            StepKind::ToolResult => {
463                let result = extract_result_section(&step.detail);
464                messages.push(OpenaiMessage {
465                    role: "tool",
466                    content: Some(result),
467                    tool_calls: None,
468                    tool_call_id: Some(id),
469                });
470            }
471        }
472    }
473    let payload = OpenaiTrajectory { messages };
474    // Single-line JSONL (no pretty-printing). `\n` terminator so
475    // concatenating many sessions into one file produces valid JSONL.
476    let mut out = serde_json::to_string(&payload)?;
477    out.push('\n');
478    Ok(out)
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::timeline::{
485        assistant_text_step, compute_session_totals, tool_use_step, user_text_step,
486    };
487
488    fn sample() -> (Vec<Step>, SessionTotals) {
489        let mut a = assistant_text_step("hi there");
490        a.model = Some("claude-opus-4-6".into());
491        a.tokens_in = Some(100);
492        a.tokens_out = Some(50);
493        let steps = vec![
494            user_text_step("hello"),
495            a,
496            tool_use_step("t1", "Read", "{}"),
497        ];
498        let totals = compute_session_totals(&steps);
499        (steps, totals)
500    }
501
502    #[test]
503    fn json_round_trips_through_value() {
504        let (steps, totals) = sample();
505        let out = json(&steps, &totals, None).unwrap();
506        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
507        assert_eq!(v["totals"]["tokens_in"], 100);
508        assert_eq!(v["steps"].as_array().unwrap().len(), 3);
509        assert_eq!(v["steps"][0]["kind"], "user_text");
510        assert_eq!(v["steps"][1]["kind"], "assistant_text");
511        assert_eq!(v["steps"][2]["kind"], "tool_use");
512        // Absent annotations → the field is omitted entirely from the
513        // output so the common case stays small.
514        assert!(
515            v.get("annotations").is_none(),
516            "expected no annotations field when none supplied"
517        );
518    }
519
520    #[test]
521    fn json_preserves_model_and_tokens_on_first_assistant_step() {
522        let (steps, totals) = sample();
523        let out = json(&steps, &totals, None).unwrap();
524        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
525        assert_eq!(v["steps"][1]["model"], "claude-opus-4-6");
526        assert_eq!(v["steps"][1]["tokens_in"], 100);
527        assert_eq!(v["steps"][1]["tokens_out"], 50);
528        assert_eq!(v["steps"][2]["tokens_in"], serde_json::Value::Null);
529    }
530
531    #[test]
532    fn json_emits_annotations_array_when_present() {
533        let (steps, totals) = sample();
534        let mut ann = Annotations::default();
535        ann.set(0, "user asked here");
536        ann.set(2, "tool call under review");
537        let out = json(&steps, &totals, Some(&ann)).unwrap();
538        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
539        let arr = v["annotations"].as_array().expect("annotations array");
540        assert_eq!(arr.len(), 2);
541        assert_eq!(arr[0]["step_index"], 0);
542        assert_eq!(arr[0]["text"], "user asked here");
543        assert_eq!(arr[1]["step_index"], 2);
544        assert_eq!(arr[1]["text"], "tool call under review");
545    }
546
547    #[test]
548    fn json_omits_annotations_when_supplied_but_empty() {
549        let (steps, totals) = sample();
550        let empty = Annotations::default();
551        let out = json(&steps, &totals, Some(&empty)).unwrap();
552        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
553        assert!(v.get("annotations").is_none());
554    }
555
556    #[test]
557    fn markdown_has_section_per_step() {
558        let (steps, totals) = sample();
559        let out = markdown(&steps, &totals, false, None);
560        assert!(out.contains("# agx session transcript"));
561        // One H2 per step
562        assert_eq!(out.matches("\n## ").count(), 3);
563        assert!(out.contains("step 1"));
564        assert!(out.contains("step 3"));
565    }
566
567    #[test]
568    fn markdown_includes_cost_line_when_cost_available() {
569        let (steps, totals) = sample();
570        let out = markdown(&steps, &totals, false, None);
571        assert!(out.contains("**cost**: $"));
572        assert!(out.contains("estimated cost:"));
573    }
574
575    #[test]
576    fn markdown_no_cost_suppresses_cost_but_keeps_tokens() {
577        let (steps, totals) = sample();
578        let out = markdown(&steps, &totals, true, None);
579        assert!(!out.contains("**cost**:"));
580        assert!(!out.contains("estimated cost:"));
581        assert!(out.contains("**tokens**:"));
582    }
583
584    #[test]
585    fn markdown_surfaces_annotations_as_blockquote() {
586        let (steps, totals) = sample();
587        let mut ann = Annotations::default();
588        ann.set(1, "revisit this");
589        let out = markdown(&steps, &totals, false, Some(&ann));
590        assert!(out.contains("annotations: 1"));
591        assert!(out.contains("> **note**: revisit this"));
592    }
593
594    #[test]
595    fn markdown_without_annotations_has_no_note_blockquote() {
596        let (steps, totals) = sample();
597        let out = markdown(&steps, &totals, false, None);
598        assert!(!out.contains("> **note**"));
599        assert!(!out.contains("annotations:"));
600    }
601
602    #[test]
603    fn markdown_preserves_multiline_annotation_text() {
604        let (steps, totals) = sample();
605        let mut ann = Annotations::default();
606        ann.set(0, "line one\nline two");
607        let out = markdown(&steps, &totals, false, Some(&ann));
608        // Multi-line notes should render one `> ` prefix per line.
609        assert!(out.contains("> **note**: line one\n> line two"));
610    }
611
612    #[test]
613    fn html_is_self_contained_no_external_assets() {
614        let (steps, totals) = sample();
615        let out = html(&steps, &totals, false, None);
616        assert!(out.starts_with("<!DOCTYPE html>"));
617        assert!(out.contains("<style>"));
618        assert!(!out.contains("<script"), "HTML export must not include JS");
619        assert!(!out.contains("<link"), "no external stylesheets");
620        assert!(out.contains("</html>"));
621    }
622
623    #[test]
624    fn html_escapes_step_detail() {
625        let mut s = user_text_step("<script>alert(1)</script>");
626        s.detail = "<script>alert(1)</script>".into();
627        let totals = compute_session_totals(&[s.clone()]);
628        let out = html(&[s], &totals, false, None);
629        assert!(
630            !out.contains("<script>alert"),
631            "unescaped script tag leaked: {out}"
632        );
633        assert!(out.contains("&lt;script&gt;"));
634    }
635
636    #[test]
637    fn html_color_classes_match_step_kinds() {
638        let (steps, totals) = sample();
639        let out = html(&steps, &totals, false, None);
640        assert!(out.contains("user-text"));
641        assert!(out.contains("assistant-text"));
642        assert!(out.contains("tool-use"));
643    }
644
645    #[test]
646    fn html_surfaces_annotation_div_and_escapes_content() {
647        let (steps, totals) = sample();
648        let mut ann = Annotations::default();
649        ann.set(0, "<b>revisit</b>");
650        let out = html(&steps, &totals, false, Some(&ann));
651        assert!(out.contains("class=\"note\""));
652        assert!(out.contains("annotations: 1"));
653        // Note text must be escaped like every other source of string input.
654        assert!(out.contains("&lt;b&gt;revisit&lt;/b&gt;"));
655        assert!(!out.contains("<b>revisit</b>"));
656    }
657
658    // -------- Phase 6.1 trajectory + redact --------
659
660    #[test]
661    fn apply_redactions_replaces_every_occurrence() {
662        let out = apply_redactions("api_key=abcdef and api_key=zzzz end", &["abcdef".into()]);
663        assert!(out.contains("[REDACTED]"));
664        assert!(!out.contains("abcdef"));
665        assert!(out.contains("zzzz"), "second literal is still there");
666    }
667
668    #[test]
669    fn apply_redactions_empty_patterns_is_identity() {
670        let s = "hello";
671        assert_eq!(apply_redactions(s, &[]), s);
672    }
673
674    #[test]
675    fn apply_redactions_skips_empty_needles() {
676        // An empty needle would otherwise match everywhere and replace
677        // every position with [REDACTED][REDACTED]…; skip instead.
678        let out = apply_redactions("hello", &[String::new()]);
679        assert_eq!(out, "hello");
680    }
681
682    #[test]
683    fn redacted_steps_clones_without_mutating_source() {
684        let (steps, _) = sample();
685        let out = redacted_steps(&steps, &["hello".into()]);
686        assert_eq!(out.len(), steps.len());
687        // Source is untouched
688        assert_eq!(steps[0].detail, "hello");
689        // Clone has redaction applied
690        assert_eq!(out[0].detail, "[REDACTED]");
691    }
692
693    #[test]
694    fn redacted_steps_with_empty_patterns_clones_identically() {
695        let (steps, _) = sample();
696        let out = redacted_steps(&steps, &[]);
697        assert_eq!(out.len(), steps.len());
698        for (a, b) in steps.iter().zip(out.iter()) {
699            assert_eq!(a.detail, b.detail);
700            assert_eq!(a.kind, b.kind);
701        }
702    }
703
704    #[test]
705    fn trajectory_openai_produces_valid_single_line_jsonl() {
706        let (steps, _) = sample();
707        let out = trajectory_openai(&steps).unwrap();
708        assert!(out.ends_with('\n'), "missing JSONL terminator");
709        // Single-line JSONL (no pretty-printing) → exactly one newline,
710        // at the end.
711        assert_eq!(out.matches('\n').count(), 1);
712        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
713        let msgs = v["messages"].as_array().expect("messages array");
714        assert_eq!(msgs.len(), 3);
715    }
716
717    #[test]
718    fn trajectory_openai_maps_step_kinds_to_roles() {
719        let (steps, _) = sample();
720        let out = trajectory_openai(&steps).unwrap();
721        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
722        let msgs = v["messages"].as_array().unwrap();
723        assert_eq!(msgs[0]["role"], "user");
724        assert_eq!(msgs[0]["content"], "hello");
725        assert_eq!(msgs[1]["role"], "assistant");
726        assert_eq!(msgs[1]["content"], "hi there");
727        assert_eq!(msgs[2]["role"], "assistant");
728        // Tool-use messages carry tool_calls, no content.
729        assert!(msgs[2]["content"].is_null());
730        let calls = msgs[2]["tool_calls"].as_array().expect("tool_calls array");
731        assert_eq!(calls.len(), 1);
732        assert_eq!(calls[0]["id"], "t1");
733        assert_eq!(calls[0]["type"], "function");
734        assert_eq!(calls[0]["function"]["name"], "Read");
735        // arguments is the extracted input section — a string per OpenAI spec.
736        assert!(calls[0]["function"]["arguments"].is_string());
737    }
738
739    #[test]
740    fn trajectory_openai_tool_result_carries_tool_call_id() {
741        use crate::timeline::tool_result_step;
742        let steps: Vec<Step> = vec![
743            tool_use_step("t42", "Bash", "{\"cmd\":\"ls\"}"),
744            tool_result_step("t42", "a.txt\nb.txt", Some("Bash"), Some("{}")),
745        ];
746        let out = trajectory_openai(&steps).unwrap();
747        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
748        let msgs = v["messages"].as_array().unwrap();
749        assert_eq!(msgs[1]["role"], "tool");
750        assert_eq!(msgs[1]["tool_call_id"], "t42");
751        // content is the extracted "Result:" section, without the
752        // "Tool: / ID: / Input:" chrome.
753        assert_eq!(msgs[1]["content"], "a.txt\nb.txt");
754    }
755
756    #[test]
757    fn redacted_trajectory_masks_tool_input_and_output() {
758        use crate::timeline::tool_result_step;
759        let steps: Vec<Step> = vec![
760            tool_use_step(
761                "t1",
762                "Bash",
763                "{\"cmd\":\"curl -H 'Authorization: secret123'\"}",
764            ),
765            tool_result_step("t1", "OK secret123 received", Some("Bash"), None),
766        ];
767        let redacted = redacted_steps(&steps, &["secret123".into()]);
768        let out = trajectory_openai(&redacted).unwrap();
769        assert!(!out.contains("secret123"), "secret leaked: {out}");
770        assert!(out.contains("[REDACTED]"));
771    }
772
773    #[test]
774    fn extract_input_section_handles_missing_marker() {
775        assert_eq!(extract_input_section("no input here"), "");
776    }
777
778    #[test]
779    fn extract_result_section_handles_missing_marker() {
780        assert_eq!(extract_result_section("no result here"), "");
781    }
782}