Skip to main content

zeph_core/debug_dump/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Debug dump writer for a single agent session.
5//!
6//! When active, every LLM request/response pair and raw tool output is written to
7//! numbered files in a timestamped subdirectory of the configured output directory.
8//! Intended for context debugging only — do not use in production.
9
10pub mod trace;
11
12use std::borrow::Cow;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU32, Ordering};
16
17use base64::Engine as _;
18use zeph_llm::provider::{ChatResponse, Message, MessagePart, Role, ToolDefinition};
19
20use crate::redact::{redact_binary_blobs, scrub_content};
21
22pub use zeph_config::DumpFormat;
23
24/// Cloneable debug dump writer; clones share the same atomic counter.
25#[derive(Clone)]
26pub struct DebugDumper {
27    dir: PathBuf,
28    counter: Arc<AtomicU32>,
29    format: DumpFormat,
30    include_raw_images: bool,
31}
32
33pub struct RequestDebugDump<'a> {
34    pub model_name: &'a str,
35    pub messages: &'a [Message],
36    pub tools: &'a [ToolDefinition],
37    pub provider_request: serde_json::Value,
38    /// Current `MemCoT` semantic state buffer at the time of this request, if any.
39    ///
40    /// `Some` when `memory.memcot.enabled = true` and at least one distillation has run.
41    /// Written to the dump so offline analysis can correlate state with LLM payloads.
42    pub memcot_state: Option<&'a str>,
43}
44
45impl DebugDumper {
46    /// Create a new dumper, creating a timestamped subdirectory under `base_dir`.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the directory cannot be created.
51    pub fn new(base_dir: &Path, format: DumpFormat) -> std::io::Result<Self> {
52        let ts = std::time::SystemTime::now()
53            .duration_since(std::time::UNIX_EPOCH)
54            .map_or(0, |d| d.as_secs());
55        let dir = base_dir.join(ts.to_string());
56        std::fs::create_dir_all(&dir)?;
57        tracing::info!(path = %dir.display(), format = ?format, "debug dump directory created");
58        Ok(Self {
59            dir,
60            counter: Arc::new(AtomicU32::new(0)),
61            format,
62            include_raw_images: false,
63        })
64    }
65
66    /// Sets whether `MessagePart::Image` payloads are written to debug dumps as full raw
67    /// base64 bytes instead of a redacted `<redacted image: ...>` marker.
68    ///
69    /// Default: `false` (redacted). Mirrors [`zeph_config::DebugConfig::include_raw_images`];
70    /// enable only when a developer explicitly needs full wire-payload fidelity for
71    /// image-related debugging (#6306). Logs a warning when enabled so operators have a
72    /// visible signal that this security tradeoff is active.
73    #[must_use]
74    pub fn with_include_raw_images(mut self, include: bool) -> Self {
75        if include {
76            tracing::warn!(
77                "debug dumps: include_raw_images=true — image payloads will be written to disk unredacted"
78            );
79        }
80        self.include_raw_images = include;
81        self
82    }
83
84    /// Return the session dump directory.
85    #[must_use]
86    pub fn dir(&self) -> &Path {
87        &self.dir
88    }
89
90    /// Returns `true` when the dump format is [`DumpFormat::Trace`].
91    ///
92    /// In Trace mode `dump_request` returns early without using `provider_request`, so callers
93    /// can skip the expensive `debug_request_json` serialization.
94    #[must_use]
95    pub fn is_trace_format(&self) -> bool {
96        self.format == DumpFormat::Trace
97    }
98
99    fn next_id(&self) -> u32 {
100        self.counter.fetch_add(1, Ordering::Relaxed)
101    }
102
103    /// Offload the write to the blocking thread pool. Fire-and-forget: the returned
104    /// `JoinHandle` is intentionally dropped — callers never wait on debug dump I/O, and a
105    /// failed write is only logged, never propagated (this is an opt-in debugging aid, not a
106    /// correctness-critical path). Dropping the handle does not cancel the task; it still runs
107    /// to completion on the blocking pool.
108    ///
109    /// Requires an active Tokio runtime. Used only by dump methods reachable from the async
110    /// per-turn hot path (#6029); call sites reachable from synchronous contexts (e.g. a plain
111    /// `Fn` callback) must use [`Self::write_sync`] instead.
112    fn write(&self, filename: &str, content: &[u8]) {
113        let path = self.dir.join(filename);
114        let content = content.to_vec();
115        // Ask-First exception to rust-code.md's Await Discipline rule #2 ("fire-and-forget
116        // tasks MUST be tracked, never drop a JoinHandle"): this handle is deliberately
117        // dropped, untracked. Rationale: this is a debug-only diagnostic path (opt-in,
118        // disabled by default), write failures are already logged via `tracing::warn!` below
119        // and never need to surface to a caller, and routing it through `BackgroundSupervisor`
120        // would require threading a `&mut BackgroundSupervisor` through 5 otherwise-unrelated
121        // hot-path call sites (llm_dispatch.rs, tool_result.rs, tier_loop.rs, focus.rs,
122        // state/mod.rs) for a debug feature most users never enable — judged disproportionate
123        // for this fix. Recorded here per the exception clause in
124        // .claude/rules/continuous-improvement.md rather than left as a silent deviation.
125        tokio::task::spawn_blocking(move || {
126            if let Err(e) = zeph_common::fs_secure::atomic_write_private(&path, &content) {
127                tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
128            }
129        });
130    }
131
132    /// Synchronous write, used by dump methods invoked from non-async call sites (a plain
133    /// `Fn` callback, or test-only code paths not reachable from the agent turn loop). These are
134    /// out of scope for the #6029 hot-path fix — see the issue for the tracked follow-up.
135    fn write_sync(&self, filename: &str, content: &[u8]) {
136        let path = self.dir.join(filename);
137        if let Err(e) = zeph_common::fs_secure::atomic_write_private(&path, content) {
138            tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
139        }
140    }
141
142    /// Dump the messages about to be sent to the LLM.
143    ///
144    /// Returns an ID that must be passed to `dump_response` to correlate request and response.
145    /// When `format = Trace`, no file is written (spans are collected by `trace::TracingCollector`).
146    #[must_use]
147    pub fn dump_request(&self, request: &RequestDebugDump<'_>) -> u32 {
148        let id = self.next_id();
149        // In Trace format, skip legacy numbered files — span data lives in TracingCollector.
150        if self.format == DumpFormat::Trace {
151            return id;
152        }
153        let json = match self.format {
154            DumpFormat::Raw => raw_dump(request, self.include_raw_images),
155            DumpFormat::Trace => unreachable!("handled above"),
156            _ => json_dump(request, self.include_raw_images),
157        };
158        self.write(&format!("{id:04}-request.json"), json.as_bytes());
159        id
160    }
161
162    /// Dump the LLM response corresponding to a prior `dump_request` call.
163    /// When `format = Trace`, this is a no-op.
164    pub fn dump_response(&self, id: u32, response: &str) {
165        if self.format == DumpFormat::Trace {
166            return;
167        }
168        let redacted = scrub_content(response);
169        let redacted = redact_binary_blobs(&redacted);
170        self.write(&format!("{id:04}-response.txt"), redacted.as_bytes());
171    }
172
173    /// Response text extracted from a `ChatResponse`, mirroring
174    /// [`DebugState::write_chat_debug_dump`](crate::agent::state::DebugState) — kept as a free
175    /// function so both the plain [`DebugDumpSink`] impl on [`DebugDumper`] (baseline
176    /// `scrub_content`/`redact_binary_blobs` only) and [`PiiScrubbingDumpSink`] (adds the
177    /// optional `PiiFilter` layer, see #6407) can reuse it.
178    pub(crate) fn chat_response_dump_text(response: &ChatResponse) -> String {
179        match response {
180            ChatResponse::Text(t) => t.clone(),
181            ChatResponse::ToolUse {
182                text, tool_calls, ..
183            } => {
184                let calls = serde_json::to_string_pretty(tool_calls).unwrap_or_default();
185                format!(
186                    "{}\n\n---TOOL_CALLS---\n{calls}",
187                    text.as_deref().unwrap_or("")
188                )
189            }
190            _ => String::new(),
191        }
192    }
193
194    /// Dump raw tool output before any truncation or summarization.
195    /// When `format = Trace`, this is a no-op (tool output is recorded via `TracingCollector`).
196    pub fn dump_tool_output(&self, tool_name: &str, output: &str) {
197        if self.format == DumpFormat::Trace {
198            return;
199        }
200        let id = self.next_id();
201        let safe_name = sanitize_dump_name(tool_name);
202        let redacted = scrub_content(output);
203        let redacted = redact_binary_blobs(&redacted);
204        self.write(
205            &format!("{id:04}-tool-{safe_name}.txt"),
206            redacted.as_bytes(),
207        );
208    }
209
210    /// Dump pruning scores computed by task-aware or MIG scoring.
211    /// When `format = Trace`, this is a no-op.
212    #[cfg(test)]
213    pub(crate) fn dump_pruning_scores(&self, scores: &[zeph_agent_context::BlockScore]) {
214        if self.format == DumpFormat::Trace {
215            return;
216        }
217        let id = self.next_id();
218        let payload: Vec<serde_json::Value> = scores
219            .iter()
220            .map(|s| {
221                serde_json::json!({
222                    "msg_index": s.msg_index,
223                    "relevance": s.relevance,
224                    "redundancy": s.redundancy,
225                    "mig": s.mig,
226                })
227            })
228            .collect();
229        match serde_json::to_string_pretty(&serde_json::json!({ "scores": payload })) {
230            Ok(json) => self.write_sync(&format!("{id:04}-pruning-scores.json"), json.as_bytes()),
231            Err(e) => tracing::warn!("dump_pruning_scores: serialize failed: {e}"),
232        }
233    }
234
235    /// Dump an `AnchoredSummary` produced during structured compaction.
236    ///
237    /// Includes completeness metrics and a fallback flag.
238    /// When `format = Trace`, this is a no-op.
239    pub(crate) fn dump_anchored_summary(
240        &self,
241        summary: &zeph_memory::AnchoredSummary,
242        fallback: bool,
243        token_counter: &zeph_memory::TokenCounter,
244    ) {
245        if self.format == DumpFormat::Trace {
246            return;
247        }
248        let id = self.next_id();
249        let section_completeness = serde_json::json!({
250            "session_intent": !summary.session_intent.trim().is_empty(),
251            "files_modified": !summary.files_modified.is_empty(),
252            "decisions_made": !summary.decisions_made.is_empty(),
253            "open_questions": !summary.open_questions.is_empty(),
254            "next_steps": !summary.next_steps.is_empty(),
255        });
256        let total_items = summary.files_modified.len()
257            + summary.decisions_made.len()
258            + summary.open_questions.len()
259            + summary.next_steps.len();
260        let markdown = summary.to_markdown();
261        let token_estimate = token_counter.count_tokens(&markdown);
262        let payload = serde_json::json!({
263            "summary": summary,
264            "section_completeness": section_completeness,
265            "total_items": total_items,
266            "token_estimate": token_estimate,
267            "fallback": fallback,
268        });
269        match serde_json::to_string_pretty(&payload) {
270            Ok(json) => self.write_sync(&format!("{id:04}-anchored-summary.json"), json.as_bytes()),
271            Err(e) => tracing::warn!("dump_anchored_summary: serialize failed: {e}"),
272        }
273    }
274
275    /// Dump the compaction probe result for a hard compaction event (#1609).
276    /// When `format = Trace`, this is a no-op.
277    pub(crate) fn dump_compaction_probe(&self, result: &zeph_memory::CompactionProbeResult) {
278        if self.format == DumpFormat::Trace {
279            return;
280        }
281        let id = self.next_id();
282        let questions: Vec<serde_json::Value> = result
283            .questions
284            .iter()
285            .zip(
286                result
287                    .answers
288                    .iter()
289                    .chain(std::iter::repeat(&String::new())),
290            )
291            .zip(
292                result
293                    .per_question_scores
294                    .iter()
295                    .chain(std::iter::repeat(&0.0_f32)),
296            )
297            .map(|((q, a), &s)| {
298                serde_json::json!({
299                    "question": scrub_content(&q.question),
300                    "expected": scrub_content(&q.expected_answer),
301                    "actual": scrub_content(a),
302                    "score": s,
303                    "category": format!("{:?}", q.category),
304                })
305            })
306            .collect();
307        let category_scores: Vec<serde_json::Value> = result
308            .category_scores
309            .iter()
310            .map(|cs| {
311                serde_json::json!({
312                    "category": format!("{:?}", cs.category),
313                    "score": cs.score,
314                    "probes_run": cs.probes_run,
315                })
316            })
317            .collect();
318        let payload = serde_json::json!({
319            "score": result.score,
320            "category_scores": category_scores,
321            "threshold": result.threshold,
322            "hard_fail_threshold": result.hard_fail_threshold,
323            "verdict": format!("{:?}", result.verdict),
324            "model": result.model,
325            "duration_ms": result.duration_ms,
326            "questions": questions,
327        });
328        match serde_json::to_string_pretty(&payload) {
329            Ok(json) => {
330                self.write_sync(&format!("{id:04}-compaction-probe.json"), json.as_bytes());
331            }
332            Err(e) => tracing::warn!("dump_compaction_probe: serialize failed: {e}"),
333        }
334    }
335
336    /// Dump the accumulated Focus Agent knowledge blocks.
337    /// When `format = Trace`, this is a no-op.
338    pub fn dump_focus_knowledge(&self, knowledge: &str) {
339        if self.format == DumpFormat::Trace {
340            return;
341        }
342        let id = self.next_id();
343        let redacted = scrub_content(knowledge);
344        let redacted = redact_binary_blobs(&redacted);
345        self.write(&format!("{id:04}-focus-knowledge.txt"), redacted.as_bytes());
346    }
347
348    /// Dump `SideQuest` eviction state: cursor list with eviction flags and freed token count.
349    /// When `format = Trace`, this is a no-op.
350    pub(crate) fn dump_sidequest_eviction(
351        &self,
352        cursors: &[crate::agent::sidequest::ToolOutputCursor],
353        evicted_indices: &[usize],
354        freed_tokens: usize,
355    ) {
356        if self.format == DumpFormat::Trace {
357            return;
358        }
359        let id = self.next_id();
360        let cursor_info: Vec<serde_json::Value> = cursors
361            .iter()
362            .enumerate()
363            .map(|(i, c)| {
364                serde_json::json!({
365                    "cursor_id": i,
366                    "msg_index": c.msg_index,
367                    "part_index": c.part_index,
368                    "tool_name": c.tool_name,
369                    "token_count": c.token_count,
370                    "evicted": evicted_indices.contains(&i),
371                })
372            })
373            .collect();
374        let payload = serde_json::json!({
375            "cursors": cursor_info,
376            "evicted_indices": evicted_indices,
377            "freed_tokens": freed_tokens,
378        });
379        match serde_json::to_string_pretty(&payload) {
380            Ok(json) => {
381                self.write_sync(&format!("{id:04}-sidequest-eviction.json"), json.as_bytes());
382            }
383            Err(e) => tracing::warn!("dump_sidequest_eviction: serialize failed: {e}"),
384        }
385    }
386
387    /// Dump the subgoal registry state alongside a compaction event (#2022).
388    ///
389    /// Writes a human-readable text file listing each subgoal with its state and message span.
390    /// When `format = Trace`, this is a no-op.
391    #[cfg(test)]
392    pub(crate) fn dump_subgoal_registry(&self, registry: &zeph_agent_context::SubgoalRegistry) {
393        if self.format == DumpFormat::Trace {
394            return;
395        }
396        let id = self.next_id();
397        let mut output = String::from("=== Subgoal Registry ===\n");
398        if registry.subgoals.is_empty() {
399            output.push_str("(no subgoals tracked yet)\n");
400        } else {
401            for sg in &registry.subgoals {
402                let state_str = match sg.state {
403                    zeph_agent_context::SubgoalState::Active => "Active   ",
404                    zeph_agent_context::SubgoalState::Completed => "Completed",
405                    _ => "Unknown  ",
406                };
407                let _ = std::fmt::write(
408                    &mut output,
409                    format_args!(
410                        "[{}] {state_str}: \"{}\" (msgs {}-{})\n",
411                        sg.id.0, sg.description, sg.start_msg_index, sg.end_msg_index,
412                    ),
413                );
414            }
415        }
416        self.write_sync(&format!("{id:04}-subgoal-registry.txt"), output.as_bytes());
417    }
418
419    /// Dump a tool error with error classification for debugging transient/permanent failures.
420    /// When `format = Trace`, this is a no-op.
421    pub fn dump_tool_error(&self, tool_name: &str, error: &zeph_tools::ToolError) {
422        if self.format == DumpFormat::Trace {
423            return;
424        }
425        let id = self.next_id();
426        let safe_name = sanitize_dump_name(tool_name);
427        let error_text = error.to_string();
428        let error_text = scrub_content(&error_text);
429        let error_text = redact_binary_blobs(&error_text);
430        let payload = serde_json::json!({
431            "tool": tool_name,
432            "error": error_text,
433            "kind": error.kind().to_string(),
434        });
435        match serde_json::to_string_pretty(&payload) {
436            Ok(json) => {
437                self.write(
438                    &format!("{id:04}-tool-error-{safe_name}.json"),
439                    json.as_bytes(),
440                );
441            }
442            Err(e) => {
443                tracing::warn!("dump_tool_error: failed to serialize error payload: {e}");
444            }
445        }
446    }
447}
448
449/// Lets `zeph-subagent`'s agent loop write sub-agent LLM request/response pairs through the
450/// same [`DebugDumper`] the top-level agent loop uses, without `zeph-subagent` depending on
451/// `zeph-core` (#6391). See [`zeph_llm::debug_dump::DebugDumpSink`] for the contract.
452impl zeph_llm::debug_dump::DebugDumpSink for DebugDumper {
453    fn is_trace_format(&self) -> bool {
454        self.is_trace_format()
455    }
456
457    fn dump_request(
458        &self,
459        model_name: &str,
460        messages: &[Message],
461        tools: &[ToolDefinition],
462        provider_request: serde_json::Value,
463    ) -> u32 {
464        DebugDumper::dump_request(
465            self,
466            &RequestDebugDump {
467                model_name,
468                messages,
469                tools,
470                provider_request,
471                // Sub-agents have no `MemCoT` accumulator of their own (that state lives on
472                // the top-level `Agent`'s `services.memory.extraction`), so there is nothing
473                // to attach here.
474                memcot_state: None,
475            },
476        )
477    }
478
479    fn dump_response(&self, id: u32, response: &ChatResponse) {
480        DebugDumper::dump_response(self, id, &DebugDumper::chat_response_dump_text(response));
481    }
482}
483
484/// Wraps a [`DebugDumper`] with a [`zeph_sanitizer::pii::PiiFilter`] so sub-agent-executed LLM
485/// calls get the same optional PII-redaction layer top-level dumps receive via
486/// `DebugState::write_chat_debug_dump`
487/// — the baseline `scrub_content`/`redact_binary_blobs` pass inside [`DebugDumper::dump_response`]
488/// always applies, but the extra `PiiFilter.scrub()` layer is opt-in per config and was
489/// previously only wired into the top-level path (#6407).
490pub struct PiiScrubbingDumpSink {
491    inner: DebugDumper,
492    pii_filter: zeph_sanitizer::pii::PiiFilter,
493}
494
495impl PiiScrubbingDumpSink {
496    /// Wraps `inner` so every [`DebugDumpSink::dump_response`](zeph_llm::debug_dump::DebugDumpSink::dump_response)
497    /// call is scrubbed through `pii_filter` before being written to disk.
498    #[must_use]
499    pub fn new(inner: DebugDumper, pii_filter: zeph_sanitizer::pii::PiiFilter) -> Self {
500        Self { inner, pii_filter }
501    }
502}
503
504impl zeph_llm::debug_dump::DebugDumpSink for PiiScrubbingDumpSink {
505    fn is_trace_format(&self) -> bool {
506        self.inner.is_trace_format()
507    }
508
509    fn dump_request(
510        &self,
511        model_name: &str,
512        messages: &[Message],
513        tools: &[ToolDefinition],
514        provider_request: serde_json::Value,
515    ) -> u32 {
516        <DebugDumper as zeph_llm::debug_dump::DebugDumpSink>::dump_request(
517            &self.inner,
518            model_name,
519            messages,
520            tools,
521            provider_request,
522        )
523    }
524
525    fn dump_response(&self, id: u32, response: &ChatResponse) {
526        let raw = DebugDumper::chat_response_dump_text(response);
527        let text = if self.pii_filter.is_enabled() {
528            self.pii_filter.scrub(&raw).into_owned()
529        } else {
530            raw
531        };
532        self.inner.dump_response(id, &text);
533    }
534}
535
536fn json_dump(request: &RequestDebugDump<'_>, include_raw_images: bool) -> String {
537    let mut payload = serde_json::json!({
538        "model": extract_model(&request.provider_request, request.model_name),
539        "max_tokens": extract_max_tokens(&request.provider_request),
540        "messages": serde_json::to_value(request.messages)
541            .unwrap_or(serde_json::Value::Array(vec![])),
542        "tools": extract_tools(&request.provider_request, request.tools),
543        "temperature": request
544            .provider_request
545            .get("temperature")
546            .cloned()
547            .unwrap_or(serde_json::Value::Null),
548        "cache_control": request
549            .provider_request
550            .get("cache_control")
551            .cloned()
552            .unwrap_or(serde_json::Value::Null),
553        "memcot_state": request.memcot_state,
554    });
555    redact_dump_tree(&mut payload, include_raw_images);
556    serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
557}
558
559fn raw_dump(request: &RequestDebugDump<'_>, include_raw_images: bool) -> String {
560    let mut payload = if request.provider_request.is_object() {
561        request.provider_request.clone()
562    } else {
563        serde_json::json!({})
564    };
565    if let Some(obj) = payload.as_object_mut() {
566        obj.entry("model")
567            .or_insert_with(|| extract_model(&request.provider_request, request.model_name));
568        obj.entry("max_tokens")
569            .or_insert_with(|| extract_max_tokens(&request.provider_request));
570        obj.entry("tools")
571            .or_insert_with(|| extract_tools(&request.provider_request, request.tools));
572        obj.entry("temperature").or_insert_with(|| {
573            request
574                .provider_request
575                .get("temperature")
576                .cloned()
577                .unwrap_or(serde_json::Value::Null)
578        });
579        obj.entry("cache_control").or_insert_with(|| {
580            request
581                .provider_request
582                .get("cache_control")
583                .cloned()
584                .unwrap_or(serde_json::Value::Null)
585        });
586        obj.insert(
587            "memcot_state".to_owned(),
588            match request.memcot_state {
589                Some(s) => serde_json::Value::String(s.to_owned()),
590                None => serde_json::Value::Null,
591            },
592        );
593        if !obj.contains_key("messages") && !obj.contains_key("system") {
594            let generic = messages_to_api_value(request.messages);
595            if let Some(generic_obj) = generic.as_object() {
596                for (key, value) in generic_obj {
597                    obj.insert(key.clone(), value.clone());
598                }
599            }
600        }
601    }
602    redact_dump_tree(&mut payload, include_raw_images);
603    serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
604}
605
606/// Minimum base64-encoded length treated as plausible image data under an `"images"` key
607/// (Ollama's per-message image array). Guards against clobbering unrelated short strings
608/// that happen to occupy a key named `images` (e.g. a tool argument) with a redaction marker.
609const MIN_IMAGE_BASE64_LEN: usize = 64;
610
611/// Recursively walks a dumped JSON tree, redacting both image payloads and free-text
612/// secrets/binary blobs before the tree is written to disk.
613///
614/// `MessagePart::Image` bytes reach the dump under different key shapes depending on the
615/// dump format and target LLM provider:
616/// - This crate's internal `MessagePart` serde form (`json_dump`, `part_to_block`) and
617///   Claude/Anthropic content blocks: `{"type":"image","source":{"data":...,"media_type":...}}`
618///   or `{"kind":"image","data":...,"mime_type":...}`.
619/// - `OpenAI`: `{"image_url":{"url":"data:<mime>;base64,<data>"}}`.
620/// - Gemini: `{"inlineData":{"mimeType":...,"data":...}}`.
621/// - Ollama: `{"images":["<base64>", ...]}`.
622///
623/// For every other string in the tree, [`scrub_content`] (secrets/JWTs/paths) always runs, and
624/// [`redact_binary_blobs`] (the 200+ char base64-run heuristic, #6315) always runs too.
625///
626/// `include_raw_images` (#6306) is scoped narrowly, matching its documented contract
627/// (`DebugConfig::include_raw_images`: "full wire-payload fidelity for image-related
628/// debugging"): when `true`, only the single leaf value actually recognized as image data
629/// (e.g. `source.data`, `image_url.url` when it's a `data:` URL, `inlineData.data`, a long-enough
630/// `images[]` element) is left completely untouched — every other string in the tree, *including
631/// siblings inside the same recognized container*, is still fully redacted regardless of the
632/// flag.
633///
634/// Exemption is tracked at leaf granularity, not container granularity: a container key
635/// (`source`, `image_url`, `inlineData`, `images`) is only ever skipped by the *outer* recursion
636/// loop below after this function has already recursed into every one of that container's other
637/// fields/elements itself. Two prior versions of this function got progressively looser and had
638/// to be tightened back down after critic review:
639/// - v1 skipped `redact_binary_blobs` for the *entire* tree whenever `include_raw_images` was
640///   `true`, reopening #6315's leak for any non-image tool returning binary-looking freeform
641///   text.
642/// - v2 fixed that but exempted the *whole subtree* under a recognized container key (e.g. all
643///   of `image_url`, not just its `url` field) — regardless of whether that container's leaf
644///   value even matched an image shape. `OpenAI`'s Vision API accepts `image_url.url` as either a
645///   `data:` URL *or* a plain external URL; a plain URL with embedded HTTP basic-auth credentials
646///   (`https://user:pass@host/img`) would hit the `image_url` branch, fail the `data:` check
647///   (correctly, since it isn't image data), but still get its whole container exempted from
648///   `scrub_content` — leaking live credentials unconditionally, even with `include_raw_images =
649///   false` (the secure default). This version fixes that by only exempting the one leaf that
650///   was actually matched, per container, and fully recursing into everything else.
651fn redact_dump_tree(value: &mut serde_json::Value, include_raw_images: bool) {
652    match value {
653        serde_json::Value::Object(map) => {
654            let is_image_kind =
655                map.get("kind").and_then(serde_json::Value::as_str) == Some("image");
656            let is_image_type =
657                map.get("type").and_then(serde_json::Value::as_str) == Some("image");
658
659            // Container keys fully handled below (recognized leaf redacted/preserved per
660            // `include_raw_images`, every other field already recursed into normally by the
661            // per-field helpers themselves) are skipped by the outer loop so they aren't
662            // double-processed.
663            let mut exempt_keys: Vec<&'static str> = Vec::new();
664
665            if is_image_kind {
666                redact_image_kind_field(map, include_raw_images);
667                // "data" is a same-level sibling of "mime_type" on `map` itself (not a nested
668                // container), so the outer loop below already reaches "mime_type" normally —
669                // only "data" itself needs exempting here.
670                exempt_keys.push("data");
671            }
672            if is_image_type && redact_image_type_source_field(map, include_raw_images) {
673                exempt_keys.push("source");
674            }
675            if redact_image_url_field(map, include_raw_images) {
676                exempt_keys.push("image_url");
677            }
678            if redact_inline_data_field(map, include_raw_images) {
679                exempt_keys.push("inlineData");
680            }
681            if redact_images_array_field(map, include_raw_images) {
682                exempt_keys.push("images");
683            }
684
685            for (key, v) in map.iter_mut() {
686                if exempt_keys.contains(&key.as_str()) {
687                    continue;
688                }
689                redact_dump_tree(v, include_raw_images);
690            }
691        }
692        serde_json::Value::Array(items) => {
693            for v in items.iter_mut() {
694                redact_dump_tree(v, include_raw_images);
695            }
696        }
697        serde_json::Value::String(s) => {
698            let scrubbed = scrub_content(s);
699            match redact_binary_blobs(scrubbed.as_ref()) {
700                Cow::Borrowed(_) => {
701                    if let Cow::Owned(owned) = scrubbed {
702                        *s = owned;
703                    }
704                }
705                Cow::Owned(owned) => *s = owned,
706            }
707        }
708        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
709    }
710}
711
712/// Handles a `{"kind":"image","data":...,"mime_type":...}` object: redacts `data` in place
713/// unless `include_raw_images`. `data` is a same-level field on the caller's map, so the caller
714/// exempts it from its own generic recursion directly.
715fn redact_image_kind_field(
716    map: &mut serde_json::Map<String, serde_json::Value>,
717    include_raw_images: bool,
718) {
719    if !include_raw_images {
720        redact_base64_field(map, "data", "mime_type");
721    }
722}
723
724/// Handles `{"type":"image","source":{"data":...,"media_type":...}}`. Returns `true` if a
725/// `source` object was present (so the caller can exempt it from its own generic recursion --
726/// every field of `source` other than `data` has already been recursed into here).
727fn redact_image_type_source_field(
728    map: &mut serde_json::Map<String, serde_json::Value>,
729    include_raw_images: bool,
730) -> bool {
731    let Some(source) = map
732        .get_mut("source")
733        .and_then(serde_json::Value::as_object_mut)
734    else {
735        return false;
736    };
737    if !include_raw_images {
738        redact_base64_field(source, "data", "media_type");
739    }
740    redact_object_fields_except(source, "data", include_raw_images);
741    true
742}
743
744/// Handles `{"image_url":{"url":...}}` (`OpenAI`). `url` may be a `data:` URL (recognized image
745/// data, exempted per `include_raw_images`) or a plain external URL (never image data, always
746/// gets full `redact_dump_tree` treatment). Returns `true` if an `image_url` object was present.
747fn redact_image_url_field(
748    map: &mut serde_json::Map<String, serde_json::Value>,
749    include_raw_images: bool,
750) -> bool {
751    let Some(image_url) = map
752        .get_mut("image_url")
753        .and_then(serde_json::Value::as_object_mut)
754    else {
755        return false;
756    };
757    let is_data_url = image_url
758        .get("url")
759        .and_then(serde_json::Value::as_str)
760        .is_some_and(|u| u.starts_with("data:"));
761    if is_data_url {
762        if !include_raw_images
763            && let Some(marker) = image_url
764                .get("url")
765                .and_then(serde_json::Value::as_str)
766                .and_then(redact_data_url)
767        {
768            image_url.insert("url".to_owned(), serde_json::Value::String(marker));
769        }
770        // else (include_raw_images=true): deliberately leave the recognized data: URL raw.
771    } else if let Some(url) = image_url.get_mut("url") {
772        // Not a data: URL (e.g. a plain external image URL, also valid per OpenAI's Vision
773        // API) -- never image data, so it still needs full secret/blob redaction regardless
774        // of include_raw_images.
775        redact_dump_tree(url, include_raw_images);
776    }
777    redact_object_fields_except(image_url, "url", include_raw_images);
778    true
779}
780
781/// Handles `{"inlineData":{"mimeType":...,"data":...}}` (Gemini). Returns `true` if an
782/// `inlineData` object was present.
783fn redact_inline_data_field(
784    map: &mut serde_json::Map<String, serde_json::Value>,
785    include_raw_images: bool,
786) -> bool {
787    let Some(inline) = map
788        .get_mut("inlineData")
789        .and_then(serde_json::Value::as_object_mut)
790    else {
791        return false;
792    };
793    if !include_raw_images {
794        redact_base64_field(inline, "data", "mimeType");
795    }
796    redact_object_fields_except(inline, "data", include_raw_images);
797    true
798}
799
800/// Handles `{"images":["<base64>", ...]}` (Ollama). Only elements at least
801/// [`MIN_IMAGE_BASE64_LEN`] long are treated as recognized image data; shorter elements still
802/// get the full generic pass. Returns `true` if an `images` array was present.
803fn redact_images_array_field(
804    map: &mut serde_json::Map<String, serde_json::Value>,
805    include_raw_images: bool,
806) -> bool {
807    let Some(images) = map
808        .get_mut("images")
809        .and_then(serde_json::Value::as_array_mut)
810    else {
811        return false;
812    };
813    for img in images.iter_mut() {
814        let is_recognized = img
815            .as_str()
816            .is_some_and(|s| s.len() >= MIN_IMAGE_BASE64_LEN);
817        if is_recognized {
818            if !include_raw_images && let Some(encoded) = img.as_str() {
819                *img = serde_json::Value::String(image_marker_from_base64("image", encoded));
820            }
821            // else (include_raw_images=true): deliberately leave recognized image bytes raw.
822        } else {
823            // Too short to plausibly be image data -- not exempted, still gets the full
824            // generic pass (e.g. a stray secret sitting under an "images" key).
825            redact_dump_tree(img, include_raw_images);
826        }
827    }
828    true
829}
830
831/// Recurses into every field of `obj` except `skip_key` (the one field the caller already
832/// handled directly as a recognized image-data leaf), applying [`redact_dump_tree`] to each.
833///
834/// Ensures sibling fields inside a recognized image-shaped container — or the leaf itself when
835/// it turns out not to be image data after all — still receive `scrub_content` +
836/// `redact_binary_blobs`, instead of being silently exempted along with the whole container.
837fn redact_object_fields_except(
838    obj: &mut serde_json::Map<String, serde_json::Value>,
839    skip_key: &str,
840    include_raw_images: bool,
841) {
842    for (k, v) in obj.iter_mut() {
843        if k == skip_key {
844            continue;
845        }
846        redact_dump_tree(v, include_raw_images);
847    }
848}
849
850fn redact_base64_field(
851    obj: &mut serde_json::Map<String, serde_json::Value>,
852    data_key: &str,
853    mime_key: &str,
854) {
855    let mime_type = obj
856        .get(mime_key)
857        .and_then(serde_json::Value::as_str)
858        .unwrap_or("unknown")
859        .to_owned();
860    if let Some(marker) = obj
861        .get(data_key)
862        .and_then(serde_json::Value::as_str)
863        .map(|encoded| image_marker_from_base64(&mime_type, encoded))
864    {
865        obj.insert(data_key.to_owned(), serde_json::Value::String(marker));
866    }
867}
868
869fn redact_data_url(url: &str) -> Option<String> {
870    let rest = url.strip_prefix("data:")?;
871    let (mime_type, encoded) = rest.split_once(";base64,")?;
872    Some(image_marker_from_base64(mime_type, encoded))
873}
874
875fn image_marker_from_base64(mime_type: &str, encoded: &str) -> String {
876    base64::engine::general_purpose::STANDARD
877        .decode(encoded)
878        .map_or_else(
879            |_| {
880                format!(
881                    "<redacted image: {mime_type}, undecodable base64 ({} chars)>",
882                    encoded.len()
883                )
884            },
885            |bytes| image_marker(mime_type, &bytes),
886        )
887}
888
889/// Builds a placeholder for a redacted image, retaining enough context (MIME type, exact
890/// byte size, content fingerprint) to correlate or diff dumps without ever persisting the
891/// raw bytes to disk.
892fn image_marker(mime_type: &str, data: &[u8]) -> String {
893    let hash = blake3::hash(data).to_hex();
894    format!(
895        "<redacted image: {mime_type}, {} bytes, blake3:{}>",
896        data.len(),
897        &hash[..16]
898    )
899}
900
901fn extract_model(payload: &serde_json::Value, fallback: &str) -> serde_json::Value {
902    payload
903        .get("model")
904        .cloned()
905        .unwrap_or_else(|| serde_json::json!(fallback))
906}
907
908fn extract_max_tokens(payload: &serde_json::Value) -> serde_json::Value {
909    payload
910        .get("max_tokens")
911        .cloned()
912        .or_else(|| payload.get("max_completion_tokens").cloned())
913        .unwrap_or(serde_json::Value::Null)
914}
915
916fn extract_tools(payload: &serde_json::Value, fallback: &[ToolDefinition]) -> serde_json::Value {
917    payload.get("tools").cloned().unwrap_or_else(|| {
918        serde_json::to_value(fallback).unwrap_or(serde_json::Value::Array(vec![]))
919    })
920}
921
922fn sanitize_dump_name(name: &str) -> String {
923    name.chars()
924        .map(|c| {
925            if c.is_alphanumeric() || c == '-' {
926                c
927            } else {
928                '_'
929            }
930        })
931        .collect()
932}
933
934/// Render messages as the API payload format (mirrors `split_messages_structured` in the
935/// Claude provider): system extracted, `agent_visible = false` messages filtered out,
936/// parts converted to typed content blocks (`text`, `tool_use`, `tool_result`, etc.).
937fn messages_to_api_value(messages: &[Message]) -> serde_json::Value {
938    let system: String = messages
939        .iter()
940        .filter(|m| m.metadata.visibility.is_agent_visible() && m.role == Role::System)
941        .map(zeph_llm::provider::Message::to_llm_content)
942        .collect::<Vec<_>>()
943        .join("\n\n");
944
945    let chat: Vec<serde_json::Value> = messages
946        .iter()
947        .filter(|m| m.metadata.visibility.is_agent_visible() && m.role != Role::System)
948        .filter_map(|m| {
949            let role = match m.role {
950                Role::User => "user",
951                Role::Assistant => "assistant",
952                Role::System | _ => return None,
953            };
954            let is_assistant = m.role == Role::Assistant;
955            let has_structured = m.parts.iter().any(|p| {
956                matches!(
957                    p,
958                    MessagePart::ToolUse { .. }
959                        | MessagePart::ToolResult { .. }
960                        | MessagePart::Image(_)
961                        | MessagePart::ThinkingBlock { .. }
962                        | MessagePart::RedactedThinkingBlock { .. }
963                )
964            });
965            let content: serde_json::Value = if !has_structured || m.parts.is_empty() {
966                let text = m.to_llm_content();
967                if text.trim().is_empty() {
968                    return None;
969                }
970                serde_json::json!(text)
971            } else {
972                let blocks: Vec<serde_json::Value> = m
973                    .parts
974                    .iter()
975                    .filter_map(|p| part_to_block(p, is_assistant))
976                    .collect();
977                if blocks.is_empty() {
978                    return None;
979                }
980                serde_json::Value::Array(blocks)
981            };
982            Some(serde_json::json!({ "role": role, "content": content }))
983        })
984        .collect();
985
986    serde_json::json!({ "system": system, "messages": chat })
987}
988
989fn part_to_block(part: &MessagePart, is_assistant: bool) -> Option<serde_json::Value> {
990    match part {
991        MessagePart::Text { text }
992        | MessagePart::Recall { text }
993        | MessagePart::CodeContext { text }
994        | MessagePart::Summary { text }
995        | MessagePart::CrossSession { text } => {
996            if text.trim().is_empty() {
997                None
998            } else {
999                Some(serde_json::json!({ "type": "text", "text": text }))
1000            }
1001        }
1002        MessagePart::ToolOutput {
1003            tool_name,
1004            body,
1005            compacted_at,
1006        } => {
1007            let text = if compacted_at.is_some() {
1008                if body.is_empty() {
1009                    format!("[tool output: {tool_name}] (pruned)")
1010                } else {
1011                    format!("[tool output: {tool_name}] {body}")
1012                }
1013            } else {
1014                format!("[tool output: {tool_name}]\n{body}")
1015            };
1016            Some(serde_json::json!({ "type": "text", "text": text }))
1017        }
1018        MessagePart::ToolUse { id, name, input } if is_assistant => {
1019            Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input }))
1020        }
1021        MessagePart::ToolUse { name, input, .. } => Some(
1022            serde_json::json!({ "type": "text", "text": format!("[tool_use: {name}] {input}") }),
1023        ),
1024        MessagePart::ToolResult {
1025            tool_use_id,
1026            content,
1027            is_error,
1028        } if !is_assistant => Some(
1029            serde_json::json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error }),
1030        ),
1031        MessagePart::ToolResult { content, .. } => {
1032            if content.trim().is_empty() {
1033                None
1034            } else {
1035                Some(serde_json::json!({ "type": "text", "text": content }))
1036            }
1037        }
1038        MessagePart::ThinkingBlock {
1039            thinking,
1040            signature,
1041        } if is_assistant => Some(
1042            serde_json::json!({ "type": "thinking", "thinking": thinking, "signature": signature }),
1043        ),
1044        MessagePart::RedactedThinkingBlock { data } if is_assistant => {
1045            Some(serde_json::json!({ "type": "redacted_thinking", "data": data }))
1046        }
1047        MessagePart::ThinkingBlock { .. }
1048        | MessagePart::RedactedThinkingBlock { .. }
1049        | MessagePart::Compaction { .. }
1050            if !is_assistant =>
1051        {
1052            None
1053        }
1054        MessagePart::Compaction { summary } => {
1055            Some(serde_json::json!({ "type": "compaction", "summary": summary }))
1056        }
1057        MessagePart::Image(img) => Some(serde_json::json!({
1058            "type": "image",
1059            "source": {
1060                "type": "base64",
1061                "media_type": img.mime_type,
1062                // Real base64 here is intentional — `raw_dump` runs the whole payload
1063                // through `redact_image_payloads` before writing, which redacts this `data`
1064                // field via the `media_type` sibling signal (spec-072 FR-012/C4).
1065                "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
1066            },
1067        })),
1068        _ => None,
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use tempfile::tempdir;
1076
1077    #[test]
1078    fn dump_format_from_str_valid() {
1079        assert_eq!("json".parse::<DumpFormat>().unwrap(), DumpFormat::Json);
1080        assert_eq!("raw".parse::<DumpFormat>().unwrap(), DumpFormat::Raw);
1081        assert_eq!("trace".parse::<DumpFormat>().unwrap(), DumpFormat::Trace);
1082    }
1083
1084    #[test]
1085    fn dump_format_from_str_invalid_returns_error() {
1086        let err = "binary".parse::<DumpFormat>().unwrap_err();
1087        assert!(
1088            err.contains("unknown dump format"),
1089            "error must mention unknown dump format: {err}"
1090        );
1091    }
1092
1093    fn sample_messages() -> Vec<Message> {
1094        vec![
1095            Message::from_legacy(Role::System, "system prompt"),
1096            Message::from_legacy(Role::User, "hello"),
1097        ]
1098    }
1099
1100    fn sample_tools() -> Vec<ToolDefinition> {
1101        vec![ToolDefinition {
1102            name: "read_file".into(),
1103            description: "Read a file".into(),
1104            parameters: serde_json::json!({
1105                "type": "object",
1106                "properties": { "path": { "type": "string" } },
1107            }),
1108            output_schema: None,
1109        }]
1110    }
1111
1112    const SAMPLE_IMAGE_BYTES: &[u8] = b"not-a-real-png-but-long-enough-to-look-like-image-bytes";
1113    const SAMPLE_IMAGE_MIME: &str = "image/png";
1114
1115    fn sample_image_messages() -> Vec<Message> {
1116        vec![
1117            Message::from_legacy(Role::System, "system prompt"),
1118            Message::from_parts(
1119                Role::User,
1120                vec![
1121                    MessagePart::Text {
1122                        text: "describe this".to_owned(),
1123                    },
1124                    MessagePart::Image(Box::new(zeph_llm::provider::ImageData {
1125                        data: SAMPLE_IMAGE_BYTES.to_vec(),
1126                        mime_type: SAMPLE_IMAGE_MIME.to_owned(),
1127                    })),
1128                ],
1129            ),
1130        ]
1131    }
1132
1133    fn sample_image_base64() -> String {
1134        base64::engine::general_purpose::STANDARD.encode(SAMPLE_IMAGE_BYTES)
1135    }
1136
1137    /// Polls for the dump file rather than reading it immediately: `write()` now offloads to
1138    /// `spawn_blocking` and is fire-and-forget (#6029), so the file may not exist yet the
1139    /// instant `dump_request` returns.
1140    async fn read_request_dump(dir: &Path) -> serde_json::Value {
1141        let session = std::fs::read_dir(dir)
1142            .unwrap()
1143            .next()
1144            .unwrap()
1145            .unwrap()
1146            .path();
1147        let path = session.join("0000-request.json");
1148        for _ in 0..200 {
1149            if let Ok(content) = std::fs::read_to_string(&path) {
1150                return serde_json::from_str(&content).unwrap();
1151            }
1152            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1153        }
1154        panic!(
1155            "debug dump file not written within timeout: {}",
1156            path.display()
1157        );
1158    }
1159
1160    /// Polls for a plain-text dump file by name, mirroring `read_request_dump`. Requires
1161    /// non-empty content (not just a successful open) as defense-in-depth; `write`/`write_sync`
1162    /// now use `atomic_write_private` (write-to-`.tmp`-then-rename, #6327), so the target path
1163    /// only ever becomes visible once fully written.
1164    async fn read_dump_file(dir: &Path, filename: &str) -> String {
1165        let session = std::fs::read_dir(dir)
1166            .unwrap()
1167            .next()
1168            .unwrap()
1169            .unwrap()
1170            .path();
1171        let path = session.join(filename);
1172        for _ in 0..200 {
1173            if let Ok(content) = std::fs::read_to_string(&path)
1174                && !content.is_empty()
1175            {
1176                return content;
1177            }
1178            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1179        }
1180        panic!(
1181            "debug dump file not written within timeout: {}",
1182            path.display()
1183        );
1184    }
1185
1186    #[tokio::test]
1187    async fn json_dump_request_includes_request_metadata() {
1188        let dir = tempdir().unwrap();
1189        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1190        let messages = sample_messages();
1191        let tools = sample_tools();
1192
1193        let _ = dumper.dump_request(&RequestDebugDump {
1194            model_name: "claude-sonnet-test",
1195            messages: &messages,
1196            tools: &tools,
1197            provider_request: serde_json::json!({
1198                "model": "claude-sonnet-test",
1199                "max_tokens": 4096,
1200                "tools": [{ "name": "read_file" }],
1201                "temperature": 0.7,
1202                "cache_control": { "type": "ephemeral" }
1203            }),
1204            memcot_state: None,
1205        });
1206
1207        let payload = read_request_dump(dir.path()).await;
1208        assert_eq!(payload["model"], "claude-sonnet-test");
1209        assert_eq!(payload["max_tokens"], 4096);
1210        assert_eq!(payload["tools"][0]["name"], "read_file");
1211        assert_eq!(payload["temperature"], 0.7);
1212        assert_eq!(payload["cache_control"]["type"], "ephemeral");
1213        assert_eq!(payload["messages"][1]["content"], "hello");
1214    }
1215
1216    #[tokio::test]
1217    async fn raw_dump_request_includes_request_metadata() {
1218        let dir = tempdir().unwrap();
1219        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1220        let messages = sample_messages();
1221        let tools = sample_tools();
1222
1223        let _ = dumper.dump_request(&RequestDebugDump {
1224            model_name: "gpt-5-mini",
1225            messages: &messages,
1226            tools: &tools,
1227            provider_request: serde_json::json!({
1228                "model": "gpt-5-mini",
1229                "max_completion_tokens": 2048,
1230                "messages": [{ "role": "user", "content": "hello" }],
1231                "tools": [{ "type": "function", "function": { "name": "read_file" } }],
1232                "temperature": 0.3,
1233                "cache_control": null
1234            }),
1235            memcot_state: None,
1236        });
1237
1238        let payload = read_request_dump(dir.path()).await;
1239        assert_eq!(payload["model"], "gpt-5-mini");
1240        assert_eq!(payload["max_tokens"], 2048);
1241        assert_eq!(payload["tools"][0]["function"]["name"], "read_file");
1242        assert_eq!(payload["temperature"], 0.3);
1243        assert_eq!(payload["messages"][0]["content"], "hello");
1244    }
1245
1246    /// #6315 (critic follow-up, C1): a binary blob redacted out of `dump_tool_output` must not
1247    /// reappear unredacted in the next turn's `dump_request` once it becomes message history.
1248    #[tokio::test]
1249    async fn json_dump_request_redacts_binary_blob_in_message_history() {
1250        let dir = tempdir().unwrap();
1251        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1252        let blob = "A".repeat(300);
1253        let messages = vec![
1254            Message::from_legacy(Role::System, "system prompt"),
1255            Message::from_legacy(Role::User, format!("tool output: {blob}")),
1256        ];
1257        let tools = sample_tools();
1258
1259        let _ = dumper.dump_request(&RequestDebugDump {
1260            model_name: "test-model",
1261            messages: &messages,
1262            tools: &tools,
1263            provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1264            memcot_state: None,
1265        });
1266
1267        let payload = read_request_dump(dir.path()).await;
1268        let content = payload["messages"][1]["content"].as_str().unwrap();
1269        assert!(
1270            content.contains("<redacted possible binary data:"),
1271            "message history must carry the redaction marker: {content}"
1272        );
1273        assert!(
1274            !content.contains(&blob),
1275            "raw blob must not reappear in dump_request: {content}"
1276        );
1277    }
1278
1279    /// #6315 (critic follow-up, C1): `raw_dump` clones `provider_request` verbatim, so any
1280    /// secret embedded in its `messages` field must also be scrubbed before writing to disk.
1281    #[tokio::test]
1282    async fn raw_dump_request_redacts_secrets_in_provider_request_messages() {
1283        let dir = tempdir().unwrap();
1284        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1285        let messages = sample_messages();
1286        let tools = sample_tools();
1287
1288        let _ = dumper.dump_request(&RequestDebugDump {
1289            model_name: "gpt-5-mini",
1290            messages: &messages,
1291            tools: &tools,
1292            provider_request: serde_json::json!({
1293                "model": "gpt-5-mini",
1294                "messages": [{ "role": "user", "content": "use key sk-abc123def456 please" }],
1295            }),
1296            memcot_state: None,
1297        });
1298
1299        let payload = read_request_dump(dir.path()).await;
1300        let content = payload["messages"][0]["content"].as_str().unwrap();
1301        assert!(
1302            content.contains("[REDACTED]"),
1303            "secret must be redacted: {content}"
1304        );
1305        assert!(
1306            !content.contains("sk-abc123def456"),
1307            "raw secret must not appear: {content}"
1308        );
1309    }
1310
1311    /// #6315 code review (I1): end-to-end coverage that `dump_tool_output` -- the issue's
1312    /// literal reported symptom -- actually redacts through the public method, not just via
1313    /// `redact_binary_blobs`/`scrub_content` unit tests in isolation.
1314    #[tokio::test]
1315    async fn dump_tool_output_redacts_binary_blob_before_writing() {
1316        let dir = tempdir().unwrap();
1317        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1318        let blob = "A".repeat(300);
1319        dumper.dump_tool_output("ocr_tool", &format!("output: {blob}"));
1320
1321        let content = read_dump_file(dir.path(), "0000-tool-ocr_tool.txt").await;
1322        assert!(
1323            content.contains("<redacted possible binary data:"),
1324            "tool output must carry the redaction marker: {content}"
1325        );
1326        assert!(
1327            !content.contains(&blob),
1328            "raw blob must not reach disk: {content}"
1329        );
1330    }
1331
1332    /// #6315 code review (I1 follow-up): same end-to-end coverage for `dump_response`.
1333    #[tokio::test]
1334    async fn dump_response_redacts_secret_before_writing() {
1335        let dir = tempdir().unwrap();
1336        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1337        dumper.dump_response(0, "here is the key: sk-abc123def456");
1338
1339        let content = read_dump_file(dir.path(), "0000-response.txt").await;
1340        assert!(
1341            content.contains("[REDACTED]"),
1342            "secret must be redacted: {content}"
1343        );
1344        assert!(
1345            !content.contains("sk-abc123def456"),
1346            "raw secret must not reach disk: {content}"
1347        );
1348    }
1349
1350    /// #6407: sub-agent dumps (routed through the `DebugDumpSink` trait via
1351    /// `PiiScrubbingDumpSink`) must get the same optional `PiiFilter` scrub top-level dumps
1352    /// get via `DebugState::write_chat_debug_dump`, not just the baseline secret/JWT scrub.
1353    #[tokio::test]
1354    async fn pii_scrubbing_dump_sink_scrubs_pii_when_filter_enabled() {
1355        use zeph_llm::debug_dump::DebugDumpSink as _;
1356        use zeph_llm::provider::ChatResponse;
1357        use zeph_sanitizer::pii::{PiiFilter, PiiFilterConfig};
1358
1359        let dir = tempdir().unwrap();
1360        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1361        let pii_filter = PiiFilter::new(PiiFilterConfig {
1362            enabled: true,
1363            ..Default::default()
1364        });
1365        let sink = PiiScrubbingDumpSink::new(dumper, pii_filter);
1366
1367        sink.dump_response(
1368            0,
1369            &ChatResponse::Text("contact me at someone@example.com".to_owned()),
1370        );
1371
1372        let content = read_dump_file(dir.path(), "0000-response.txt").await;
1373        assert!(
1374            content.contains("[PII:email]"),
1375            "email must be scrubbed by the PiiFilter layer: {content}"
1376        );
1377        assert!(
1378            !content.contains("someone@example.com"),
1379            "raw email must not reach disk: {content}"
1380        );
1381    }
1382
1383    /// Mirrors the above with the filter disabled (config default: `enabled = true`, so this
1384    /// exercises the explicit opt-out) — only baseline `scrub_content`/`redact_binary_blobs`
1385    /// applies, matching `DebugState::write_chat_debug_dump`'s `is_enabled()` gate.
1386    #[tokio::test]
1387    async fn pii_scrubbing_dump_sink_skips_pii_scrub_when_filter_disabled() {
1388        use zeph_llm::debug_dump::DebugDumpSink as _;
1389        use zeph_llm::provider::ChatResponse;
1390        use zeph_sanitizer::pii::{PiiFilter, PiiFilterConfig};
1391
1392        let dir = tempdir().unwrap();
1393        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1394        let pii_filter = PiiFilter::new(PiiFilterConfig {
1395            enabled: false,
1396            ..Default::default()
1397        });
1398        let sink = PiiScrubbingDumpSink::new(dumper, pii_filter);
1399
1400        sink.dump_response(
1401            0,
1402            &ChatResponse::Text("contact me at someone@example.com".to_owned()),
1403        );
1404
1405        let content = read_dump_file(dir.path(), "0000-response.txt").await;
1406        assert!(
1407            content.contains("someone@example.com"),
1408            "disabled PiiFilter must not scrub email: {content}"
1409        );
1410    }
1411
1412    /// #6315 code review (I1 follow-up): same end-to-end coverage for `dump_tool_error`.
1413    #[tokio::test]
1414    async fn dump_tool_error_redacts_binary_blob_before_writing() {
1415        let dir = tempdir().unwrap();
1416        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1417        let blob = "B".repeat(300);
1418        let error = zeph_tools::ToolError::InvalidParams {
1419            message: format!("failed on input: {blob}"),
1420        };
1421        dumper.dump_tool_error("vision_tool", &error);
1422
1423        let content = read_dump_file(dir.path(), "0000-tool-error-vision_tool.json").await;
1424        assert!(
1425            content.contains("<redacted possible binary data:"),
1426            "tool error must carry the redaction marker: {content}"
1427        );
1428        assert!(
1429            !content.contains(&blob),
1430            "raw blob must not reach disk: {content}"
1431        );
1432    }
1433
1434    /// #6315 code review (I1 follow-up): same end-to-end coverage for `dump_focus_knowledge`.
1435    #[tokio::test]
1436    async fn dump_focus_knowledge_redacts_secret_before_writing() {
1437        let dir = tempdir().unwrap();
1438        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1439        dumper.dump_focus_knowledge("summary mentions key sk-abc123def456 from the scrape");
1440
1441        let content = read_dump_file(dir.path(), "0000-focus-knowledge.txt").await;
1442        assert!(
1443            content.contains("[REDACTED]"),
1444            "secret must be redacted: {content}"
1445        );
1446        assert!(
1447            !content.contains("sk-abc123def456"),
1448            "raw secret must not reach disk: {content}"
1449        );
1450    }
1451
1452    #[tokio::test]
1453    async fn memcot_state_written_to_dump_when_present() {
1454        for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1455            let dir = tempdir().unwrap();
1456            let dumper = DebugDumper::new(dir.path(), fmt).unwrap();
1457            let messages = sample_messages();
1458            let tools = sample_tools();
1459
1460            let _ = dumper.dump_request(&RequestDebugDump {
1461                model_name: "test-model",
1462                messages: &messages,
1463                tools: &tools,
1464                provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1465                memcot_state: Some("Rust uses LLVM; user is refactoring the parser"),
1466            });
1467
1468            let payload = read_request_dump(dir.path()).await;
1469            assert_eq!(
1470                payload["memcot_state"], "Rust uses LLVM; user is refactoring the parser",
1471                "memcot_state must appear in {fmt:?} dump"
1472            );
1473        }
1474    }
1475
1476    #[tokio::test]
1477    async fn memcot_state_null_when_absent() {
1478        let dir = tempdir().unwrap();
1479        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1480        let messages = sample_messages();
1481        let tools = sample_tools();
1482
1483        let _ = dumper.dump_request(&RequestDebugDump {
1484            model_name: "test-model",
1485            messages: &messages,
1486            tools: &tools,
1487            provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1488            memcot_state: None,
1489        });
1490
1491        let payload = read_request_dump(dir.path()).await;
1492        assert!(
1493            payload["memcot_state"].is_null(),
1494            "memcot_state must be null when None"
1495        );
1496    }
1497
1498    /// Smoke test for #6029: `dump_request` returns promptly and the write still lands
1499    /// afterward. NOT a strict regression guard — a revert to a synchronous single-file write
1500    /// would typically also complete in 1-3ms on a tmpfs-backed tempdir and would still pass
1501    /// the 50ms budget below. The write path isn't injectable/mockable here, so a tighter
1502    /// assertion (e.g. proving the write is dispatched to a *different* thread than the
1503    /// caller) isn't practical without adding test-only instrumentation to `DebugDumper`. Keep
1504    /// this as a coarse sanity check plus the file-existence check below, not proof of
1505    /// non-blocking behavior.
1506    #[tokio::test]
1507    async fn dump_request_smoke_returns_promptly_and_write_still_lands() {
1508        let dir = tempdir().unwrap();
1509        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1510        let messages = sample_messages();
1511        let tools = sample_tools();
1512
1513        let start = std::time::Instant::now();
1514        let _ = dumper.dump_request(&RequestDebugDump {
1515            model_name: "test-model",
1516            messages: &messages,
1517            tools: &tools,
1518            provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1519            memcot_state: None,
1520        });
1521        // Coarse budget only (see doc comment above) — not a strict non-blocking proof.
1522        assert!(
1523            start.elapsed() < std::time::Duration::from_millis(50),
1524            "dump_request must return without waiting on the blocking write"
1525        );
1526
1527        // The write still completes shortly afterward (fire-and-forget, not dropped).
1528        let _ = read_request_dump(dir.path()).await;
1529    }
1530
1531    // --- Image redaction (#6306) ---
1532
1533    #[tokio::test]
1534    async fn json_dump_redacts_image_bytes_by_default() {
1535        let dir = tempdir().unwrap();
1536        let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
1537        let messages = sample_image_messages();
1538        let tools = sample_tools();
1539
1540        let _ = dumper.dump_request(&RequestDebugDump {
1541            model_name: "test-model",
1542            messages: &messages,
1543            tools: &tools,
1544            provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
1545            memcot_state: None,
1546        });
1547
1548        let payload = read_request_dump(dir.path()).await;
1549        let dumped_json = payload.to_string();
1550        assert!(
1551            !dumped_json.contains(&sample_image_base64()),
1552            "json dump must not contain raw base64 image bytes by default"
1553        );
1554        let image_part = &payload["messages"][1]["parts"][1];
1555        assert_eq!(image_part["mime_type"], SAMPLE_IMAGE_MIME);
1556        let marker = image_part["data"].as_str().unwrap();
1557        assert!(
1558            marker.starts_with("<redacted image: image/png,"),
1559            "unexpected marker: {marker}"
1560        );
1561        // Sibling text part must be untouched.
1562        assert_eq!(payload["messages"][1]["parts"][0]["text"], "describe this");
1563    }
1564
1565    #[tokio::test]
1566    async fn raw_dump_redacts_image_bytes_via_fallback_content_blocks() {
1567        let dir = tempdir().unwrap();
1568        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1569        let messages = sample_image_messages();
1570        let tools = sample_tools();
1571
1572        // No "messages"/"system" key present, so raw_dump falls back to
1573        // messages_to_api_value/part_to_block, which mirrors Claude's content-block shape.
1574        let _ = dumper.dump_request(&RequestDebugDump {
1575            model_name: "claude-sonnet-test",
1576            messages: &messages,
1577            tools: &tools,
1578            provider_request: serde_json::json!({ "model": "claude-sonnet-test" }),
1579            memcot_state: None,
1580        });
1581
1582        let payload = read_request_dump(dir.path()).await;
1583        let dumped_json = payload.to_string();
1584        assert!(
1585            !dumped_json.contains(&sample_image_base64()),
1586            "raw dump fallback content blocks must not contain raw base64 image bytes by default"
1587        );
1588        let image_block = &payload["messages"][0]["content"][1];
1589        assert_eq!(image_block["type"], "image");
1590        assert_eq!(image_block["source"]["media_type"], SAMPLE_IMAGE_MIME);
1591        let marker = image_block["source"]["data"].as_str().unwrap();
1592        assert!(
1593            marker.starts_with("<redacted image: image/png,"),
1594            "unexpected marker: {marker}"
1595        );
1596    }
1597
1598    #[tokio::test]
1599    async fn raw_dump_redacts_openai_style_image_url_in_provider_request() {
1600        let dir = tempdir().unwrap();
1601        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1602        let messages = sample_messages();
1603        let tools = sample_tools();
1604        let data_url = format!("data:{SAMPLE_IMAGE_MIME};base64,{}", sample_image_base64());
1605
1606        // provider_request already carries a "messages" key (real OpenAI vision wire format),
1607        // so raw_dump clones it verbatim without hitting the part_to_block fallback.
1608        let _ = dumper.dump_request(&RequestDebugDump {
1609            model_name: "gpt-5-mini",
1610            messages: &messages,
1611            tools: &tools,
1612            provider_request: serde_json::json!({
1613                "model": "gpt-5-mini",
1614                "messages": [{
1615                    "role": "user",
1616                    "content": [
1617                        { "type": "text", "text": "describe this" },
1618                        { "type": "image_url", "image_url": { "url": data_url } },
1619                    ],
1620                }],
1621            }),
1622            memcot_state: None,
1623        });
1624
1625        let payload = read_request_dump(dir.path()).await;
1626        let dumped_json = payload.to_string();
1627        assert!(
1628            !dumped_json.contains(&sample_image_base64()),
1629            "raw dump of a real provider_request must not contain raw base64 image bytes by default"
1630        );
1631        let url = payload["messages"][0]["content"][1]["image_url"]["url"]
1632            .as_str()
1633            .unwrap();
1634        assert!(
1635            url.starts_with("<redacted image: image/png,"),
1636            "unexpected marker: {url}"
1637        );
1638    }
1639
1640    #[tokio::test]
1641    async fn raw_dump_redacts_gemini_and_ollama_style_provider_request_shapes() {
1642        let dir = tempdir().unwrap();
1643        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1644        let messages = sample_messages();
1645        let tools = sample_tools();
1646        let b64 = sample_image_base64();
1647
1648        let _ = dumper.dump_request(&RequestDebugDump {
1649            model_name: "test-model",
1650            messages: &messages,
1651            tools: &tools,
1652            provider_request: serde_json::json!({
1653                "model": "test-model",
1654                // Gemini shape.
1655                "contents": [{
1656                    "role": "user",
1657                    "parts": [{ "inlineData": { "mimeType": SAMPLE_IMAGE_MIME, "data": b64 } }],
1658                }],
1659                // Ollama shape (also satisfies the "messages" key so the fallback isn't hit).
1660                "messages": [{ "role": "user", "content": "hi", "images": [b64] }],
1661            }),
1662            memcot_state: None,
1663        });
1664
1665        let payload = read_request_dump(dir.path()).await;
1666        let dumped_json = payload.to_string();
1667        assert!(
1668            !dumped_json.contains(&sample_image_base64()),
1669            "raw dump must redact both Gemini inlineData and Ollama images-array shapes"
1670        );
1671        let gemini_data = payload["contents"][0]["parts"][0]["inlineData"]["data"]
1672            .as_str()
1673            .unwrap();
1674        assert!(gemini_data.starts_with("<redacted image:"));
1675        let ollama_image = payload["messages"][0]["images"][0].as_str().unwrap();
1676        assert!(ollama_image.starts_with("<redacted image:"));
1677    }
1678
1679    #[tokio::test]
1680    async fn include_raw_images_opt_in_preserves_full_bytes() {
1681        for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1682            let dir = tempdir().unwrap();
1683            let dumper = DebugDumper::new(dir.path(), fmt)
1684                .unwrap()
1685                .with_include_raw_images(true);
1686            let messages = sample_image_messages();
1687            let tools = sample_tools();
1688
1689            let _ = dumper.dump_request(&RequestDebugDump {
1690                model_name: "test-model",
1691                messages: &messages,
1692                tools: &tools,
1693                provider_request: serde_json::json!({ "model": "test-model" }),
1694                memcot_state: None,
1695            });
1696
1697            let payload = read_request_dump(dir.path()).await;
1698            assert!(
1699                payload.to_string().contains(&sample_image_base64()),
1700                "with include_raw_images=true, {fmt:?} dump must contain the full base64 image bytes"
1701            );
1702        }
1703    }
1704
1705    /// #6306 x #6315 rebase interaction: `redact_dump_tree`'s `redact_binary_blobs` component
1706    /// (200+ char base64-run heuristic, #6315) must not clobber image bytes that are recognized
1707    /// as image data and skipped via `include_raw_images = true` (#6306). Uses a payload large
1708    /// enough (`> 200` base64 chars) to actually trip the blob heuristic --
1709    /// `sample_image_base64()` alone is too short to exercise this interaction.
1710    #[tokio::test]
1711    async fn include_raw_images_survives_binary_blob_heuristic_for_large_images() {
1712        let large_bytes: Vec<u8> = std::iter::repeat(0..=255u8).flatten().take(300).collect();
1713        let large_b64 = base64::engine::general_purpose::STANDARD.encode(&large_bytes);
1714        assert!(
1715            large_b64.len() > 200,
1716            "test setup sanity check: encoded image must exceed redact_binary_blobs's 200-char threshold"
1717        );
1718        let messages = vec![
1719            Message::from_legacy(Role::System, "system prompt"),
1720            Message::from_parts(
1721                Role::User,
1722                vec![
1723                    MessagePart::Text {
1724                        text: "describe this".to_owned(),
1725                    },
1726                    MessagePart::Image(Box::new(zeph_llm::provider::ImageData {
1727                        data: large_bytes,
1728                        mime_type: "image/png".to_owned(),
1729                    })),
1730                ],
1731            ),
1732        ];
1733        let tools = sample_tools();
1734
1735        for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1736            let dir = tempdir().unwrap();
1737            let dumper = DebugDumper::new(dir.path(), fmt)
1738                .unwrap()
1739                .with_include_raw_images(true);
1740
1741            let _ = dumper.dump_request(&RequestDebugDump {
1742                model_name: "test-model",
1743                messages: &messages,
1744                tools: &tools,
1745                provider_request: serde_json::json!({ "model": "test-model" }),
1746                memcot_state: None,
1747            });
1748
1749            let payload = read_request_dump(dir.path()).await;
1750            assert!(
1751                payload.to_string().contains(&large_b64),
1752                "{fmt:?}: include_raw_images=true must preserve full image bytes even though \
1753                 they exceed redact_binary_blobs's 200-char threshold"
1754            );
1755        }
1756    }
1757
1758    /// #6315 x #6306 scope-limit fix (critic finding, rebase round 2): `include_raw_images` must
1759    /// stay scoped to fields recognized as image data -- it must NOT disable
1760    /// `redact_binary_blobs` for the rest of the dump. A non-image tool (e.g. the original
1761    /// #6315 scenario: a vision tool emitting base64 as plain text instead of a typed
1762    /// `MessagePart::Image`) must still get its binary blob redacted even while
1763    /// `include_raw_images = true` is active for genuine image debugging in the same session.
1764    #[tokio::test]
1765    async fn include_raw_images_does_not_widen_scope_to_non_image_blobs() {
1766        let blob = "A".repeat(300);
1767        let messages = vec![
1768            Message::from_legacy(Role::System, "system prompt"),
1769            Message::from_legacy(Role::User, format!("tool output: {blob}")),
1770        ];
1771        let tools = sample_tools();
1772
1773        for fmt in [DumpFormat::Json, DumpFormat::Raw] {
1774            let dir = tempdir().unwrap();
1775            let dumper = DebugDumper::new(dir.path(), fmt)
1776                .unwrap()
1777                .with_include_raw_images(true);
1778
1779            let _ = dumper.dump_request(&RequestDebugDump {
1780                model_name: "test-model",
1781                messages: &messages,
1782                tools: &tools,
1783                provider_request: serde_json::json!({ "model": "test-model" }),
1784                memcot_state: None,
1785            });
1786
1787            let payload = read_request_dump(dir.path()).await;
1788            let dumped_json = payload.to_string();
1789            assert!(
1790                dumped_json.contains("<redacted possible binary data:"),
1791                "{fmt:?}: include_raw_images=true must not suppress redact_binary_blobs for \
1792                 non-image freeform text: {dumped_json}"
1793            );
1794            assert!(
1795                !dumped_json.contains(&blob),
1796                "{fmt:?}: raw non-image blob must not survive even with include_raw_images=true: \
1797                 {dumped_json}"
1798            );
1799        }
1800    }
1801
1802    /// #6315 x #6306 critic finding (round 3, critical): `OpenAI`'s Vision API accepts
1803    /// `image_url.url` as either a `data:` URL or a plain external URL. A plain URL is never
1804    /// recognized as image data (it fails the `data:` prefix check), so it must still get full
1805    /// `scrub_content` treatment -- fires unconditionally, with no `include_raw_images` opt-in
1806    /// needed, since this is the secure default path.
1807    #[tokio::test]
1808    async fn image_url_with_external_url_still_gets_secret_scrubbed() {
1809        let dir = tempdir().unwrap();
1810        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1811        let messages = sample_messages();
1812        let tools = sample_tools();
1813
1814        let _ = dumper.dump_request(&RequestDebugDump {
1815            model_name: "gpt-5-mini",
1816            messages: &messages,
1817            tools: &tools,
1818            provider_request: serde_json::json!({
1819                "model": "gpt-5-mini",
1820                "messages": [{
1821                    "role": "user",
1822                    "content": [
1823                        { "type": "text", "text": "describe this" },
1824                        {
1825                            "type": "image_url",
1826                            "image_url": { "url": "https://user:s3cr3t-key@cdn.example.com/photo.png" },
1827                        },
1828                    ],
1829                }],
1830            }),
1831            memcot_state: None,
1832        });
1833
1834        let payload = read_request_dump(dir.path()).await;
1835        let url = payload["messages"][0]["content"][1]["image_url"]["url"]
1836            .as_str()
1837            .unwrap();
1838        assert!(
1839            url.contains("[REDACTED]"),
1840            "external image_url.url with embedded credentials must still be scrubbed: {url}"
1841        );
1842        assert!(
1843            !url.contains("s3cr3t-key"),
1844            "raw credential must not survive: {url}"
1845        );
1846    }
1847
1848    /// #6315 x #6306 critic finding (round 3, same class): a `source` object recognized via
1849    /// `type == "image"` but without a `data` key (e.g. a hypothetical URL-referenced source
1850    /// rather than a base64-embedded one) must still get its other fields fully redacted, not
1851    /// silently exempted as a whole alongside the (here, absent) recognized `data` leaf.
1852    #[tokio::test]
1853    async fn image_type_source_without_data_key_still_gets_secret_scrubbed() {
1854        let dir = tempdir().unwrap();
1855        let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1856        let messages = sample_messages();
1857        let tools = sample_tools();
1858
1859        let _ = dumper.dump_request(&RequestDebugDump {
1860            model_name: "claude-sonnet-test",
1861            messages: &messages,
1862            tools: &tools,
1863            provider_request: serde_json::json!({
1864                "model": "claude-sonnet-test",
1865                "messages": [{
1866                    "role": "user",
1867                    "content": [{
1868                        "type": "image",
1869                        "source": {
1870                            "type": "url",
1871                            "url": "https://user:s3cr3t-key@cdn.example.com/photo.png",
1872                        },
1873                    }],
1874                }],
1875            }),
1876            memcot_state: None,
1877        });
1878
1879        let payload = read_request_dump(dir.path()).await;
1880        let url = payload["messages"][0]["content"][0]["source"]["url"]
1881            .as_str()
1882            .unwrap();
1883        assert!(
1884            url.contains("[REDACTED]"),
1885            "source object without a data key must still get secret-scrubbed: {url}"
1886        );
1887        assert!(
1888            !url.contains("s3cr3t-key"),
1889            "raw credential must not survive: {url}"
1890        );
1891    }
1892
1893    /// Cross-crate regression guard (#6306 critic finding S1): the tests above all hand-author
1894    /// JSON matching `redact_dump_tree`'s own assumed image-data shapes, which only proves the
1895    /// redactor redacts shapes it already knows about — a real provider renaming/restructuring
1896    /// its image field (or a new provider) could silently reopen the leak with every other test
1897    /// still green. This drives the *actual* `LlmProvider::debug_request_json` implementation
1898    /// of each real provider — the exact code path `raw_dump`'s primary branch consumes in
1899    /// production via `RequestDebugDump::provider_request` — through the real `dump_request`
1900    /// write path, and asserts no raw base64 image data survives.
1901    #[tokio::test]
1902    async fn raw_dump_redacts_real_provider_debug_request_json_for_every_provider() {
1903        use zeph_llm::provider::LlmProvider;
1904
1905        let messages = sample_image_messages();
1906        let b64 = sample_image_base64();
1907
1908        let claude = zeph_llm::claude::ClaudeProvider::new(
1909            "test-key".to_owned(),
1910            "claude-sonnet-test".to_owned(),
1911            4096,
1912        );
1913        let openai = zeph_llm::openai::OpenAiProvider::new(zeph_llm::openai::OpenAiConfig {
1914            api_key: "test-key".to_owned(),
1915            base_url: "https://api.openai.com/v1".to_owned(),
1916            model: "gpt-5-mini".to_owned(),
1917            max_tokens: 4096,
1918            embedding_model: None,
1919            reasoning_effort: None,
1920            context_window: None,
1921            completion_tokens_param: None,
1922            vision: None,
1923        });
1924        let gemini = zeph_llm::gemini::GeminiProvider::new(
1925            "test-key".to_owned(),
1926            "gemini-2.5-flash".to_owned(),
1927            4096,
1928        );
1929        let ollama = zeph_llm::ollama::OllamaProvider::new(
1930            "http://localhost:11434",
1931            "llava".to_owned(),
1932            "nomic-embed-text".to_owned(),
1933        );
1934
1935        let providers: Vec<(&str, serde_json::Value)> = vec![
1936            ("claude", claude.debug_request_json(&messages, &[], false)),
1937            ("openai", openai.debug_request_json(&messages, &[], false)),
1938            ("gemini", gemini.debug_request_json(&messages, &[], false)),
1939            ("ollama", ollama.debug_request_json(&messages, &[], false)),
1940        ];
1941
1942        for (name, provider_request) in providers {
1943            assert!(
1944                provider_request.to_string().contains(&b64),
1945                "test setup sanity check: {name}'s debug_request_json must actually contain \
1946                 the raw base64 image bytes before redaction (otherwise this test proves \
1947                 nothing)"
1948            );
1949
1950            let dir = tempdir().unwrap();
1951            let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
1952            let _ = dumper.dump_request(&RequestDebugDump {
1953                model_name: name,
1954                messages: &messages,
1955                tools: &[],
1956                provider_request,
1957                memcot_state: None,
1958            });
1959
1960            let payload = read_request_dump(dir.path()).await;
1961            assert!(
1962                !payload.to_string().contains(&b64),
1963                "{name}'s real debug_request_json output must not contain raw base64 image \
1964                 bytes after passing through the real dump_request/raw_dump redaction path"
1965            );
1966        }
1967    }
1968
1969    #[test]
1970    fn redact_dump_tree_leaves_clean_non_image_values_untouched() {
1971        let mut value = serde_json::json!({
1972            "model": "test-model",
1973            "messages": [{
1974                "role": "user",
1975                "content": [
1976                    { "type": "text", "text": "hello world" },
1977                    { "type": "tool_use", "id": "t1", "name": "read_file", "input": { "path": "a.rs" } },
1978                    { "type": "tool_result", "tool_use_id": "t1", "content": "file contents", "is_error": false },
1979                ],
1980            }],
1981            "temperature": 0.5,
1982        });
1983        let before = value.clone();
1984        redact_dump_tree(&mut value, false);
1985        assert_eq!(
1986            value, before,
1987            "JSON with no secrets/blobs/image data must be unchanged by redaction"
1988        );
1989    }
1990}