1use 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 #[serde(skip_serializing_if = "Option::is_none")]
25 annotations: Option<Vec<AnnotationLine>>,
26}
27
28#[derive(Debug, Serialize)]
31struct AnnotationLine {
32 step_index: usize,
33 text: String,
34 created_at_ms: u64,
35 updated_at_ms: u64,
36}
37
38pub 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
65pub 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 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 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
146pub 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(¬e.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
239fn 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 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("&"),
293 '<' => out.push_str("<"),
294 '>' => out.push_str(">"),
295 '"' => out.push_str("""),
296 '\'' => out.push_str("'"),
297 _ => out.push(c),
298 }
299 }
300 out
301}
302
303pub 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
324fn extract_input_section(detail: &str) -> String {
330 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
342fn 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#[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: String,
381}
382
383#[derive(Debug, Serialize)]
384struct OpenaiTrajectory<'a> {
385 messages: Vec<OpenaiMessage<'a>>,
386}
387
388pub 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
409pub fn trajectory_openai(steps: &[Step]) -> Result<String> {
424 let mut messages: Vec<OpenaiMessage<'_>> = Vec::with_capacity(steps.len());
425 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 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 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 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 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("<script>"));
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 assert!(out.contains("<b>revisit</b>"));
655 assert!(!out.contains("<b>revisit</b>"));
656 }
657
658 #[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 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 assert_eq!(steps[0].detail, "hello");
689 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 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 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 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 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}