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