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::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/// Cloneable debug dump writer; clones share the same atomic counter.
24#[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    /// Current `MemCoT` semantic state buffer at the time of this request, if any.
37    ///
38    /// `Some` when `memory.memcot.enabled = true` and at least one distillation has run.
39    /// Written to the dump so offline analysis can correlate state with LLM payloads.
40    pub memcot_state: Option<&'a str>,
41}
42
43impl DebugDumper {
44    /// Create a new dumper, creating a timestamped subdirectory under `base_dir`.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if the directory cannot be created.
49    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    /// Return the session dump directory.
64    #[must_use]
65    pub fn dir(&self) -> &Path {
66        &self.dir
67    }
68
69    /// Returns `true` when the dump format is [`DumpFormat::Trace`].
70    ///
71    /// In Trace mode `dump_request` returns early without using `provider_request`, so callers
72    /// can skip the expensive `debug_request_json` serialization.
73    #[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    /// Offload the write to the blocking thread pool. Fire-and-forget: the returned
83    /// `JoinHandle` is intentionally dropped — callers never wait on debug dump I/O, and a
84    /// failed write is only logged, never propagated (this is an opt-in debugging aid, not a
85    /// correctness-critical path). Dropping the handle does not cancel the task; it still runs
86    /// to completion on the blocking pool.
87    ///
88    /// Requires an active Tokio runtime. Used only by dump methods reachable from the async
89    /// per-turn hot path (#6029); call sites reachable from synchronous contexts (e.g. a plain
90    /// `Fn` callback) must use [`Self::write_sync`] instead.
91    fn write(&self, filename: &str, content: &[u8]) {
92        let path = self.dir.join(filename);
93        let content = content.to_vec();
94        // Ask-First exception to rust-code.md's Await Discipline rule #2 ("fire-and-forget
95        // tasks MUST be tracked, never drop a JoinHandle"): this handle is deliberately
96        // dropped, untracked. Rationale: this is a debug-only diagnostic path (opt-in,
97        // disabled by default), write failures are already logged via `tracing::warn!` below
98        // and never need to surface to a caller, and routing it through `BackgroundSupervisor`
99        // would require threading a `&mut BackgroundSupervisor` through 5 otherwise-unrelated
100        // hot-path call sites (llm_dispatch.rs, tool_result.rs, tier_loop.rs, focus.rs,
101        // state/mod.rs) for a debug feature most users never enable — judged disproportionate
102        // for this fix. Recorded here per the exception clause in
103        // .claude/rules/continuous-improvement.md rather than left as a silent deviation.
104        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    /// Synchronous write, used by dump methods invoked from non-async call sites (a plain
112    /// `Fn` callback, or test-only code paths not reachable from the agent turn loop). These are
113    /// out of scope for the #6029 hot-path fix — see the issue for the tracked follow-up.
114    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    /// Dump the messages about to be sent to the LLM.
122    ///
123    /// Returns an ID that must be passed to `dump_response` to correlate request and response.
124    /// When `format = Trace`, no file is written (spans are collected by `trace::TracingCollector`).
125    #[must_use]
126    pub fn dump_request(&self, request: &RequestDebugDump<'_>) -> u32 {
127        let id = self.next_id();
128        // In Trace format, skip legacy numbered files — span data lives in TracingCollector.
129        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    /// Dump the LLM response corresponding to a prior `dump_request` call.
142    /// When `format = Trace`, this is a no-op.
143    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    /// Dump raw tool output before any truncation or summarization.
151    /// When `format = Trace`, this is a no-op (tool output is recorded via `TracingCollector`).
152    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    /// Dump pruning scores computed by task-aware or MIG scoring.
162    /// When `format = Trace`, this is a no-op.
163    #[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    /// Dump an `AnchoredSummary` produced during structured compaction.
187    ///
188    /// Includes completeness metrics and a fallback flag.
189    /// When `format = Trace`, this is a no-op.
190    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    /// Dump the compaction probe result for a hard compaction event (#1609).
227    /// When `format = Trace`, this is a no-op.
228    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    /// Dump the accumulated Focus Agent knowledge blocks.
288    /// When `format = Trace`, this is a no-op.
289    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    /// Dump `SideQuest` eviction state: cursor list with eviction flags and freed token count.
301    /// When `format = Trace`, this is a no-op.
302    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    /// Dump the subgoal registry state alongside a compaction event (#2022).
340    ///
341    /// Writes a human-readable text file listing each subgoal with its state and message span.
342    /// When `format = Trace`, this is a no-op.
343    #[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 &registry.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    /// Dump a tool error with error classification for debugging transient/permanent failures.
372    /// When `format = Trace`, this is a no-op.
373    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
499/// Render messages as the API payload format (mirrors `split_messages_structured` in the
500/// Claude provider): system extracted, `agent_visible = false` messages filtered out,
501/// parts converted to typed content blocks (`text`, `tool_use`, `tool_result`, etc.).
502fn 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    /// Polls for the dump file rather than reading it immediately: `write()` now offloads to
675    /// `spawn_blocking` and is fire-and-forget (#6029), so the file may not exist yet the
676    /// instant `dump_request` returns.
677    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    /// Smoke test for #6029: `dump_request` returns promptly and the write still lands
804    /// afterward. NOT a strict regression guard — a revert to a synchronous single-file write
805    /// would typically also complete in 1-3ms on a tmpfs-backed tempdir and would still pass
806    /// the 50ms budget below. The write path isn't injectable/mockable here, so a tighter
807    /// assertion (e.g. proving the write is dispatched to a *different* thread than the
808    /// caller) isn't practical without adding test-only instrumentation to `DebugDumper`. Keep
809    /// this as a coarse sanity check plus the file-existence check below, not proof of
810    /// non-blocking behavior.
811    #[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        // Coarse budget only (see doc comment above) — not a strict non-blocking proof.
827        assert!(
828            start.elapsed() < std::time::Duration::from_millis(50),
829            "dump_request must return without waiting on the blocking write"
830        );
831
832        // The write still completes shortly afterward (fire-and-forget, not dropped).
833        let _ = read_request_dump(dir.path()).await;
834    }
835}