1pub 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#[derive(Clone)]
26pub struct DebugDumper {
27 dir: PathBuf,
28 counter: Arc<AtomicU32>,
29 format: DumpFormat,
30 include_raw_images: bool,
31 #[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 pub memcot_state: Option<&'a str>,
48}
49
50impl DebugDumper {
51 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 #[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 #[must_use]
93 pub fn dir(&self) -> &Path {
94 &self.dir
95 }
96
97 #[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 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 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 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 #[must_use]
160 pub fn dump_request(&self, request: &RequestDebugDump<'_>) -> u32 {
161 let id = self.next_id();
162 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 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 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 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 #[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 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 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 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 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 #[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 ®istry.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 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
462impl 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 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
497pub struct PiiScrubbingDumpSink {
504 inner: DebugDumper,
505 pii_filter: zeph_sanitizer::pii::PiiFilter,
506}
507
508impl PiiScrubbingDumpSink {
509 #[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
619const MIN_IMAGE_BASE64_LEN: usize = 64;
623
624fn 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 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 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
725fn 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
737fn 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
757fn 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 if let Some(url) = image_url.get_mut("url") {
785 redact_dump_tree(url, include_raw_images);
789 }
790 redact_object_fields_except(image_url, "url", include_raw_images);
791 true
792}
793
794fn 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
813fn 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 {
836 redact_dump_tree(img, include_raw_images);
839 }
840 }
841 true
842}
843
844fn 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
902fn 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
947fn 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 "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
1079 },
1080 })),
1081 _ => None,
1082 }
1083}
1084
1085#[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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_tx.send(()).unwrap();
1597 let payload = read_request_dump(dir.path()).await;
1598 assert_eq!(payload["model"], "test-model");
1599 }
1600
1601 #[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 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 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 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 "contents": [{
1726 "role": "user",
1727 "parts": [{ "inlineData": { "mimeType": SAMPLE_IMAGE_MIME, "data": b64 } }],
1728 }],
1729 "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 #[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 #[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 #[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 #[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 #[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}