1pub mod trace;
11
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU32, Ordering};
15
16use base64::Engine as _;
17use zeph_llm::provider::{Message, MessagePart, Role, ToolDefinition};
18
19use crate::redact::scrub_content;
20
21pub use zeph_config::DumpFormat;
22
23#[derive(Clone)]
25pub struct DebugDumper {
26 dir: PathBuf,
27 counter: Arc<AtomicU32>,
28 format: DumpFormat,
29}
30
31pub struct RequestDebugDump<'a> {
32 pub model_name: &'a str,
33 pub messages: &'a [Message],
34 pub tools: &'a [ToolDefinition],
35 pub provider_request: serde_json::Value,
36 pub memcot_state: Option<&'a str>,
41}
42
43impl DebugDumper {
44 pub fn new(base_dir: &Path, format: DumpFormat) -> std::io::Result<Self> {
50 let ts = std::time::SystemTime::now()
51 .duration_since(std::time::UNIX_EPOCH)
52 .map_or(0, |d| d.as_secs());
53 let dir = base_dir.join(ts.to_string());
54 std::fs::create_dir_all(&dir)?;
55 tracing::info!(path = %dir.display(), format = ?format, "debug dump directory created");
56 Ok(Self {
57 dir,
58 counter: Arc::new(AtomicU32::new(0)),
59 format,
60 })
61 }
62
63 #[must_use]
65 pub fn dir(&self) -> &Path {
66 &self.dir
67 }
68
69 #[must_use]
74 pub fn is_trace_format(&self) -> bool {
75 self.format == DumpFormat::Trace
76 }
77
78 fn next_id(&self) -> u32 {
79 self.counter.fetch_add(1, Ordering::Relaxed)
80 }
81
82 fn write(&self, filename: &str, content: &[u8]) {
92 let path = self.dir.join(filename);
93 let content = content.to_vec();
94 tokio::task::spawn_blocking(move || {
105 if let Err(e) = zeph_common::fs_secure::write_private(&path, &content) {
106 tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
107 }
108 });
109 }
110
111 fn write_sync(&self, filename: &str, content: &[u8]) {
115 let path = self.dir.join(filename);
116 if let Err(e) = zeph_common::fs_secure::write_private(&path, content) {
117 tracing::warn!(path = %path.display(), error = %e, "debug dump write failed");
118 }
119 }
120
121 #[must_use]
126 pub fn dump_request(&self, request: &RequestDebugDump<'_>) -> u32 {
127 let id = self.next_id();
128 if self.format == DumpFormat::Trace {
130 return id;
131 }
132 let json = match self.format {
133 DumpFormat::Raw => raw_dump(request),
134 DumpFormat::Trace => unreachable!("handled above"),
135 _ => json_dump(request),
136 };
137 self.write(&format!("{id:04}-request.json"), json.as_bytes());
138 id
139 }
140
141 pub fn dump_response(&self, id: u32, response: &str) {
144 if self.format == DumpFormat::Trace {
145 return;
146 }
147 self.write(&format!("{id:04}-response.txt"), response.as_bytes());
148 }
149
150 pub fn dump_tool_output(&self, tool_name: &str, output: &str) {
153 if self.format == DumpFormat::Trace {
154 return;
155 }
156 let id = self.next_id();
157 let safe_name = sanitize_dump_name(tool_name);
158 self.write(&format!("{id:04}-tool-{safe_name}.txt"), output.as_bytes());
159 }
160
161 #[cfg(test)]
164 pub(crate) fn dump_pruning_scores(&self, scores: &[zeph_agent_context::BlockScore]) {
165 if self.format == DumpFormat::Trace {
166 return;
167 }
168 let id = self.next_id();
169 let payload: Vec<serde_json::Value> = scores
170 .iter()
171 .map(|s| {
172 serde_json::json!({
173 "msg_index": s.msg_index,
174 "relevance": s.relevance,
175 "redundancy": s.redundancy,
176 "mig": s.mig,
177 })
178 })
179 .collect();
180 match serde_json::to_string_pretty(&serde_json::json!({ "scores": payload })) {
181 Ok(json) => self.write_sync(&format!("{id:04}-pruning-scores.json"), json.as_bytes()),
182 Err(e) => tracing::warn!("dump_pruning_scores: serialize failed: {e}"),
183 }
184 }
185
186 pub(crate) fn dump_anchored_summary(
191 &self,
192 summary: &zeph_memory::AnchoredSummary,
193 fallback: bool,
194 token_counter: &zeph_memory::TokenCounter,
195 ) {
196 if self.format == DumpFormat::Trace {
197 return;
198 }
199 let id = self.next_id();
200 let section_completeness = serde_json::json!({
201 "session_intent": !summary.session_intent.trim().is_empty(),
202 "files_modified": !summary.files_modified.is_empty(),
203 "decisions_made": !summary.decisions_made.is_empty(),
204 "open_questions": !summary.open_questions.is_empty(),
205 "next_steps": !summary.next_steps.is_empty(),
206 });
207 let total_items = summary.files_modified.len()
208 + summary.decisions_made.len()
209 + summary.open_questions.len()
210 + summary.next_steps.len();
211 let markdown = summary.to_markdown();
212 let token_estimate = token_counter.count_tokens(&markdown);
213 let payload = serde_json::json!({
214 "summary": summary,
215 "section_completeness": section_completeness,
216 "total_items": total_items,
217 "token_estimate": token_estimate,
218 "fallback": fallback,
219 });
220 match serde_json::to_string_pretty(&payload) {
221 Ok(json) => self.write_sync(&format!("{id:04}-anchored-summary.json"), json.as_bytes()),
222 Err(e) => tracing::warn!("dump_anchored_summary: serialize failed: {e}"),
223 }
224 }
225
226 pub(crate) fn dump_compaction_probe(&self, result: &zeph_memory::CompactionProbeResult) {
229 if self.format == DumpFormat::Trace {
230 return;
231 }
232 let id = self.next_id();
233 let questions: Vec<serde_json::Value> = result
234 .questions
235 .iter()
236 .zip(
237 result
238 .answers
239 .iter()
240 .chain(std::iter::repeat(&String::new())),
241 )
242 .zip(
243 result
244 .per_question_scores
245 .iter()
246 .chain(std::iter::repeat(&0.0_f32)),
247 )
248 .map(|((q, a), &s)| {
249 serde_json::json!({
250 "question": scrub_content(&q.question),
251 "expected": scrub_content(&q.expected_answer),
252 "actual": scrub_content(a),
253 "score": s,
254 "category": format!("{:?}", q.category),
255 })
256 })
257 .collect();
258 let category_scores: Vec<serde_json::Value> = result
259 .category_scores
260 .iter()
261 .map(|cs| {
262 serde_json::json!({
263 "category": format!("{:?}", cs.category),
264 "score": cs.score,
265 "probes_run": cs.probes_run,
266 })
267 })
268 .collect();
269 let payload = serde_json::json!({
270 "score": result.score,
271 "category_scores": category_scores,
272 "threshold": result.threshold,
273 "hard_fail_threshold": result.hard_fail_threshold,
274 "verdict": format!("{:?}", result.verdict),
275 "model": result.model,
276 "duration_ms": result.duration_ms,
277 "questions": questions,
278 });
279 match serde_json::to_string_pretty(&payload) {
280 Ok(json) => {
281 self.write_sync(&format!("{id:04}-compaction-probe.json"), json.as_bytes());
282 }
283 Err(e) => tracing::warn!("dump_compaction_probe: serialize failed: {e}"),
284 }
285 }
286
287 pub fn dump_focus_knowledge(&self, knowledge: &str) {
290 if self.format == DumpFormat::Trace {
291 return;
292 }
293 let id = self.next_id();
294 self.write(
295 &format!("{id:04}-focus-knowledge.txt"),
296 knowledge.as_bytes(),
297 );
298 }
299
300 pub(crate) fn dump_sidequest_eviction(
303 &self,
304 cursors: &[crate::agent::sidequest::ToolOutputCursor],
305 evicted_indices: &[usize],
306 freed_tokens: usize,
307 ) {
308 if self.format == DumpFormat::Trace {
309 return;
310 }
311 let id = self.next_id();
312 let cursor_info: Vec<serde_json::Value> = cursors
313 .iter()
314 .enumerate()
315 .map(|(i, c)| {
316 serde_json::json!({
317 "cursor_id": i,
318 "msg_index": c.msg_index,
319 "part_index": c.part_index,
320 "tool_name": c.tool_name,
321 "token_count": c.token_count,
322 "evicted": evicted_indices.contains(&i),
323 })
324 })
325 .collect();
326 let payload = serde_json::json!({
327 "cursors": cursor_info,
328 "evicted_indices": evicted_indices,
329 "freed_tokens": freed_tokens,
330 });
331 match serde_json::to_string_pretty(&payload) {
332 Ok(json) => {
333 self.write_sync(&format!("{id:04}-sidequest-eviction.json"), json.as_bytes());
334 }
335 Err(e) => tracing::warn!("dump_sidequest_eviction: serialize failed: {e}"),
336 }
337 }
338
339 #[cfg(test)]
344 pub(crate) fn dump_subgoal_registry(&self, registry: &zeph_agent_context::SubgoalRegistry) {
345 if self.format == DumpFormat::Trace {
346 return;
347 }
348 let id = self.next_id();
349 let mut output = String::from("=== Subgoal Registry ===\n");
350 if registry.subgoals.is_empty() {
351 output.push_str("(no subgoals tracked yet)\n");
352 } else {
353 for sg in ®istry.subgoals {
354 let state_str = match sg.state {
355 zeph_agent_context::SubgoalState::Active => "Active ",
356 zeph_agent_context::SubgoalState::Completed => "Completed",
357 _ => "Unknown ",
358 };
359 let _ = std::fmt::write(
360 &mut output,
361 format_args!(
362 "[{}] {state_str}: \"{}\" (msgs {}-{})\n",
363 sg.id.0, sg.description, sg.start_msg_index, sg.end_msg_index,
364 ),
365 );
366 }
367 }
368 self.write_sync(&format!("{id:04}-subgoal-registry.txt"), output.as_bytes());
369 }
370
371 pub fn dump_tool_error(&self, tool_name: &str, error: &zeph_tools::ToolError) {
374 if self.format == DumpFormat::Trace {
375 return;
376 }
377 let id = self.next_id();
378 let safe_name = sanitize_dump_name(tool_name);
379 let payload = serde_json::json!({
380 "tool": tool_name,
381 "error": error.to_string(),
382 "kind": error.kind().to_string(),
383 });
384 match serde_json::to_string_pretty(&payload) {
385 Ok(json) => {
386 self.write(
387 &format!("{id:04}-tool-error-{safe_name}.json"),
388 json.as_bytes(),
389 );
390 }
391 Err(e) => {
392 tracing::warn!("dump_tool_error: failed to serialize error payload: {e}");
393 }
394 }
395 }
396}
397
398fn json_dump(request: &RequestDebugDump<'_>) -> String {
399 let payload = serde_json::json!({
400 "model": extract_model(&request.provider_request, request.model_name),
401 "max_tokens": extract_max_tokens(&request.provider_request),
402 "messages": serde_json::to_value(request.messages)
403 .unwrap_or(serde_json::Value::Array(vec![])),
404 "tools": extract_tools(&request.provider_request, request.tools),
405 "temperature": request
406 .provider_request
407 .get("temperature")
408 .cloned()
409 .unwrap_or(serde_json::Value::Null),
410 "cache_control": request
411 .provider_request
412 .get("cache_control")
413 .cloned()
414 .unwrap_or(serde_json::Value::Null),
415 "memcot_state": request.memcot_state,
416 });
417 serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
418}
419
420fn raw_dump(request: &RequestDebugDump<'_>) -> String {
421 let mut payload = if request.provider_request.is_object() {
422 request.provider_request.clone()
423 } else {
424 serde_json::json!({})
425 };
426 if let Some(obj) = payload.as_object_mut() {
427 obj.entry("model")
428 .or_insert_with(|| extract_model(&request.provider_request, request.model_name));
429 obj.entry("max_tokens")
430 .or_insert_with(|| extract_max_tokens(&request.provider_request));
431 obj.entry("tools")
432 .or_insert_with(|| extract_tools(&request.provider_request, request.tools));
433 obj.entry("temperature").or_insert_with(|| {
434 request
435 .provider_request
436 .get("temperature")
437 .cloned()
438 .unwrap_or(serde_json::Value::Null)
439 });
440 obj.entry("cache_control").or_insert_with(|| {
441 request
442 .provider_request
443 .get("cache_control")
444 .cloned()
445 .unwrap_or(serde_json::Value::Null)
446 });
447 obj.insert(
448 "memcot_state".to_owned(),
449 match request.memcot_state {
450 Some(s) => serde_json::Value::String(s.to_owned()),
451 None => serde_json::Value::Null,
452 },
453 );
454 if !obj.contains_key("messages") && !obj.contains_key("system") {
455 let generic = messages_to_api_value(request.messages);
456 if let Some(generic_obj) = generic.as_object() {
457 for (key, value) in generic_obj {
458 obj.insert(key.clone(), value.clone());
459 }
460 }
461 }
462 }
463 serde_json::to_string_pretty(&payload).unwrap_or_else(|e| format!("serialization error: {e}"))
464}
465
466fn extract_model(payload: &serde_json::Value, fallback: &str) -> serde_json::Value {
467 payload
468 .get("model")
469 .cloned()
470 .unwrap_or_else(|| serde_json::json!(fallback))
471}
472
473fn extract_max_tokens(payload: &serde_json::Value) -> serde_json::Value {
474 payload
475 .get("max_tokens")
476 .cloned()
477 .or_else(|| payload.get("max_completion_tokens").cloned())
478 .unwrap_or(serde_json::Value::Null)
479}
480
481fn extract_tools(payload: &serde_json::Value, fallback: &[ToolDefinition]) -> serde_json::Value {
482 payload.get("tools").cloned().unwrap_or_else(|| {
483 serde_json::to_value(fallback).unwrap_or(serde_json::Value::Array(vec![]))
484 })
485}
486
487fn sanitize_dump_name(name: &str) -> String {
488 name.chars()
489 .map(|c| {
490 if c.is_alphanumeric() || c == '-' {
491 c
492 } else {
493 '_'
494 }
495 })
496 .collect()
497}
498
499fn messages_to_api_value(messages: &[Message]) -> serde_json::Value {
503 let system: String = messages
504 .iter()
505 .filter(|m| m.metadata.visibility.is_agent_visible() && m.role == Role::System)
506 .map(zeph_llm::provider::Message::to_llm_content)
507 .collect::<Vec<_>>()
508 .join("\n\n");
509
510 let chat: Vec<serde_json::Value> = messages
511 .iter()
512 .filter(|m| m.metadata.visibility.is_agent_visible() && m.role != Role::System)
513 .filter_map(|m| {
514 let role = match m.role {
515 Role::User => "user",
516 Role::Assistant => "assistant",
517 Role::System | _ => return None,
518 };
519 let is_assistant = m.role == Role::Assistant;
520 let has_structured = m.parts.iter().any(|p| {
521 matches!(
522 p,
523 MessagePart::ToolUse { .. }
524 | MessagePart::ToolResult { .. }
525 | MessagePart::Image(_)
526 | MessagePart::ThinkingBlock { .. }
527 | MessagePart::RedactedThinkingBlock { .. }
528 )
529 });
530 let content: serde_json::Value = if !has_structured || m.parts.is_empty() {
531 let text = m.to_llm_content();
532 if text.trim().is_empty() {
533 return None;
534 }
535 serde_json::json!(text)
536 } else {
537 let blocks: Vec<serde_json::Value> = m
538 .parts
539 .iter()
540 .filter_map(|p| part_to_block(p, is_assistant))
541 .collect();
542 if blocks.is_empty() {
543 return None;
544 }
545 serde_json::Value::Array(blocks)
546 };
547 Some(serde_json::json!({ "role": role, "content": content }))
548 })
549 .collect();
550
551 serde_json::json!({ "system": system, "messages": chat })
552}
553
554fn part_to_block(part: &MessagePart, is_assistant: bool) -> Option<serde_json::Value> {
555 match part {
556 MessagePart::Text { text }
557 | MessagePart::Recall { text }
558 | MessagePart::CodeContext { text }
559 | MessagePart::Summary { text }
560 | MessagePart::CrossSession { text } => {
561 if text.trim().is_empty() {
562 None
563 } else {
564 Some(serde_json::json!({ "type": "text", "text": text }))
565 }
566 }
567 MessagePart::ToolOutput {
568 tool_name,
569 body,
570 compacted_at,
571 } => {
572 let text = if compacted_at.is_some() {
573 if body.is_empty() {
574 format!("[tool output: {tool_name}] (pruned)")
575 } else {
576 format!("[tool output: {tool_name}] {body}")
577 }
578 } else {
579 format!("[tool output: {tool_name}]\n{body}")
580 };
581 Some(serde_json::json!({ "type": "text", "text": text }))
582 }
583 MessagePart::ToolUse { id, name, input } if is_assistant => {
584 Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input }))
585 }
586 MessagePart::ToolUse { name, input, .. } => Some(
587 serde_json::json!({ "type": "text", "text": format!("[tool_use: {name}] {input}") }),
588 ),
589 MessagePart::ToolResult {
590 tool_use_id,
591 content,
592 is_error,
593 } if !is_assistant => Some(
594 serde_json::json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error }),
595 ),
596 MessagePart::ToolResult { content, .. } => {
597 if content.trim().is_empty() {
598 None
599 } else {
600 Some(serde_json::json!({ "type": "text", "text": content }))
601 }
602 }
603 MessagePart::ThinkingBlock {
604 thinking,
605 signature,
606 } if is_assistant => Some(
607 serde_json::json!({ "type": "thinking", "thinking": thinking, "signature": signature }),
608 ),
609 MessagePart::RedactedThinkingBlock { data } if is_assistant => {
610 Some(serde_json::json!({ "type": "redacted_thinking", "data": data }))
611 }
612 MessagePart::ThinkingBlock { .. }
613 | MessagePart::RedactedThinkingBlock { .. }
614 | MessagePart::Compaction { .. }
615 if !is_assistant =>
616 {
617 None
618 }
619 MessagePart::Compaction { summary } => {
620 Some(serde_json::json!({ "type": "compaction", "summary": summary }))
621 }
622 MessagePart::Image(img) => Some(serde_json::json!({
623 "type": "image",
624 "source": {
625 "type": "base64",
626 "media_type": img.mime_type,
627 "data": base64::engine::general_purpose::STANDARD.encode(&img.data),
628 },
629 })),
630 _ => None,
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use tempfile::tempdir;
638
639 #[test]
640 fn dump_format_from_str_valid() {
641 assert_eq!("json".parse::<DumpFormat>().unwrap(), DumpFormat::Json);
642 assert_eq!("raw".parse::<DumpFormat>().unwrap(), DumpFormat::Raw);
643 assert_eq!("trace".parse::<DumpFormat>().unwrap(), DumpFormat::Trace);
644 }
645
646 #[test]
647 fn dump_format_from_str_invalid_returns_error() {
648 let err = "binary".parse::<DumpFormat>().unwrap_err();
649 assert!(
650 err.contains("unknown dump format"),
651 "error must mention unknown dump format: {err}"
652 );
653 }
654
655 fn sample_messages() -> Vec<Message> {
656 vec![
657 Message::from_legacy(Role::System, "system prompt"),
658 Message::from_legacy(Role::User, "hello"),
659 ]
660 }
661
662 fn sample_tools() -> Vec<ToolDefinition> {
663 vec![ToolDefinition {
664 name: "read_file".into(),
665 description: "Read a file".into(),
666 parameters: serde_json::json!({
667 "type": "object",
668 "properties": { "path": { "type": "string" } },
669 }),
670 output_schema: None,
671 }]
672 }
673
674 async fn read_request_dump(dir: &Path) -> serde_json::Value {
678 let session = std::fs::read_dir(dir)
679 .unwrap()
680 .next()
681 .unwrap()
682 .unwrap()
683 .path();
684 let path = session.join("0000-request.json");
685 for _ in 0..200 {
686 if let Ok(content) = std::fs::read_to_string(&path) {
687 return serde_json::from_str(&content).unwrap();
688 }
689 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
690 }
691 panic!(
692 "debug dump file not written within timeout: {}",
693 path.display()
694 );
695 }
696
697 #[tokio::test]
698 async fn json_dump_request_includes_request_metadata() {
699 let dir = tempdir().unwrap();
700 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
701 let messages = sample_messages();
702 let tools = sample_tools();
703
704 let _ = dumper.dump_request(&RequestDebugDump {
705 model_name: "claude-sonnet-test",
706 messages: &messages,
707 tools: &tools,
708 provider_request: serde_json::json!({
709 "model": "claude-sonnet-test",
710 "max_tokens": 4096,
711 "tools": [{ "name": "read_file" }],
712 "temperature": 0.7,
713 "cache_control": { "type": "ephemeral" }
714 }),
715 memcot_state: None,
716 });
717
718 let payload = read_request_dump(dir.path()).await;
719 assert_eq!(payload["model"], "claude-sonnet-test");
720 assert_eq!(payload["max_tokens"], 4096);
721 assert_eq!(payload["tools"][0]["name"], "read_file");
722 assert_eq!(payload["temperature"], 0.7);
723 assert_eq!(payload["cache_control"]["type"], "ephemeral");
724 assert_eq!(payload["messages"][1]["content"], "hello");
725 }
726
727 #[tokio::test]
728 async fn raw_dump_request_includes_request_metadata() {
729 let dir = tempdir().unwrap();
730 let dumper = DebugDumper::new(dir.path(), DumpFormat::Raw).unwrap();
731 let messages = sample_messages();
732 let tools = sample_tools();
733
734 let _ = dumper.dump_request(&RequestDebugDump {
735 model_name: "gpt-5-mini",
736 messages: &messages,
737 tools: &tools,
738 provider_request: serde_json::json!({
739 "model": "gpt-5-mini",
740 "max_completion_tokens": 2048,
741 "messages": [{ "role": "user", "content": "hello" }],
742 "tools": [{ "type": "function", "function": { "name": "read_file" } }],
743 "temperature": 0.3,
744 "cache_control": null
745 }),
746 memcot_state: None,
747 });
748
749 let payload = read_request_dump(dir.path()).await;
750 assert_eq!(payload["model"], "gpt-5-mini");
751 assert_eq!(payload["max_tokens"], 2048);
752 assert_eq!(payload["tools"][0]["function"]["name"], "read_file");
753 assert_eq!(payload["temperature"], 0.3);
754 assert_eq!(payload["messages"][0]["content"], "hello");
755 }
756
757 #[tokio::test]
758 async fn memcot_state_written_to_dump_when_present() {
759 for fmt in [DumpFormat::Json, DumpFormat::Raw] {
760 let dir = tempdir().unwrap();
761 let dumper = DebugDumper::new(dir.path(), fmt).unwrap();
762 let messages = sample_messages();
763 let tools = sample_tools();
764
765 let _ = dumper.dump_request(&RequestDebugDump {
766 model_name: "test-model",
767 messages: &messages,
768 tools: &tools,
769 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
770 memcot_state: Some("Rust uses LLVM; user is refactoring the parser"),
771 });
772
773 let payload = read_request_dump(dir.path()).await;
774 assert_eq!(
775 payload["memcot_state"], "Rust uses LLVM; user is refactoring the parser",
776 "memcot_state must appear in {fmt:?} dump"
777 );
778 }
779 }
780
781 #[tokio::test]
782 async fn memcot_state_null_when_absent() {
783 let dir = tempdir().unwrap();
784 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
785 let messages = sample_messages();
786 let tools = sample_tools();
787
788 let _ = dumper.dump_request(&RequestDebugDump {
789 model_name: "test-model",
790 messages: &messages,
791 tools: &tools,
792 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
793 memcot_state: None,
794 });
795
796 let payload = read_request_dump(dir.path()).await;
797 assert!(
798 payload["memcot_state"].is_null(),
799 "memcot_state must be null when None"
800 );
801 }
802
803 #[tokio::test]
812 async fn dump_request_smoke_returns_promptly_and_write_still_lands() {
813 let dir = tempdir().unwrap();
814 let dumper = DebugDumper::new(dir.path(), DumpFormat::Json).unwrap();
815 let messages = sample_messages();
816 let tools = sample_tools();
817
818 let start = std::time::Instant::now();
819 let _ = dumper.dump_request(&RequestDebugDump {
820 model_name: "test-model",
821 messages: &messages,
822 tools: &tools,
823 provider_request: serde_json::json!({ "model": "test-model", "max_tokens": 1024 }),
824 memcot_state: None,
825 });
826 assert!(
828 start.elapsed() < std::time::Duration::from_millis(50),
829 "dump_request must return without waiting on the blocking write"
830 );
831
832 let _ = read_request_dump(dir.path()).await;
834 }
835}