Skip to main content

monoloop_testkit/
driver.rs

1//! Minimal test Driver composition (not a product component).
2//!
3//! Wires Interpreter → event distributor → independent Console + Loop
4//! subscriptions with EmptyToolRegistry.
5
6use crate::console::{ConsoleRenderer, ConsoleRendererConfig, ConsoleSink, SyncMemorySink};
7use crate::distribute::{pump_interpreter_to_distributor, EventDistributor, SubscriberPolicy};
8use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
9use monoloop_contracts::{
10    DialectBinding, InterpretationId, InterpretationLimits, InterpreterOutputEvent, LoopEnd,
11    LoopId, LoopLimits, LoopOutputEvent, LoopScope, MonoloopRunId, OutboundToolOutcome,
12};
13use monoloop_interpreter::{
14    ConnectionId, DefaultInterpreterFactory, InterpreterFactory, StartInterpretation,
15};
16use monoloop_loop::{DefaultLoopRuntime, LoopHandle};
17use std::path::PathBuf;
18use std::sync::Arc;
19use tokio::sync::Mutex;
20
21/// Optional parameters for a pipeline run (mirrors a `--params` style switch).
22#[derive(Clone, Debug)]
23pub struct PipelineParams {
24    /// Render append-only console output.
25    pub render_console: bool,
26    /// Capture exact inbound raw chunks fed to the Interpreter (as a wire dump).
27    pub dump_raw: bool,
28    /// When set, write a self-contained HTML review of canonical events to this path.
29    pub html_dump_path: Option<PathBuf>,
30    /// HTML report options (used when `html_dump_path` is set or HTML is requested).
31    pub html_params: HtmlReportParams,
32    /// When true, always build an in-memory HTML report (even without a path).
33    pub build_html: bool,
34}
35
36impl Default for PipelineParams {
37    fn default() -> Self {
38        Self {
39            render_console: true,
40            dump_raw: false,
41            html_dump_path: None,
42            html_params: HtmlReportParams::default(),
43            build_html: false,
44        }
45    }
46}
47
48impl PipelineParams {
49    /// Console only (legacy default behaviour).
50    pub fn console_only() -> Self {
51        Self {
52            render_console: true,
53            dump_raw: false,
54            html_dump_path: None,
55            html_params: HtmlReportParams::default(),
56            build_html: false,
57        }
58    }
59
60    /// Console + raw dump of exact dialect bytes.
61    pub fn with_raw_dump() -> Self {
62        Self {
63            render_console: true,
64            dump_raw: true,
65            html_dump_path: None,
66            html_params: HtmlReportParams::default(),
67            build_html: false,
68        }
69    }
70
71    /// Console + HTML file dump for visual interpretation review.
72    pub fn with_html_dump(path: impl Into<PathBuf>) -> Self {
73        Self {
74            render_console: true,
75            dump_raw: false,
76            html_dump_path: Some(path.into()),
77            html_params: HtmlReportParams::default(),
78            build_html: true,
79        }
80    }
81
82    /// Console + raw dump + HTML file.
83    pub fn with_raw_and_html(path: impl Into<PathBuf>) -> Self {
84        Self {
85            render_console: true,
86            dump_raw: true,
87            html_dump_path: Some(path.into()),
88            html_params: HtmlReportParams::default(),
89            build_html: true,
90        }
91    }
92
93    /// Quiet: no console, no dump.
94    pub fn quiet() -> Self {
95        Self {
96            render_console: false,
97            dump_raw: false,
98            html_dump_path: None,
99            html_params: HtmlReportParams::default(),
100            build_html: false,
101        }
102    }
103}
104
105/// One raw frame as presented to the Interpreter (exact chunk bytes).
106#[derive(Clone, Debug)]
107pub struct RawInputFrame {
108    /// Index in feed order.
109    pub index: u64,
110    /// Exact bytes of this chunk.
111    pub bytes: bytes::Bytes,
112}
113
114/// Snapshot of raw input for a pipeline run.
115#[derive(Clone, Debug, Default)]
116pub struct PipelineRawDump {
117    /// Chunks in the order they were pushed.
118    pub frames: Vec<RawInputFrame>,
119}
120
121impl PipelineRawDump {
122    /// Human-readable dump of exact chunks.
123    pub fn format_text(&self) -> String {
124        let mut s = String::new();
125        s.push_str(&format!(
126            "=== PIPELINE RAW DUMP (frames={}) ===\n",
127            self.frames.len()
128        ));
129        for f in &self.frames {
130            s.push_str(&format!(
131                "--- chunk #{} len={} ---\n",
132                f.index,
133                f.bytes.len()
134            ));
135            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&f.bytes) {
136                if let Ok(pretty) = serde_json::to_string_pretty(&v) {
137                    s.push_str(&pretty);
138                    s.push('\n');
139                    continue;
140                }
141            }
142            // May be a partial JSON chunk — print lossy exact bytes.
143            s.push_str(&String::from_utf8_lossy(&f.bytes));
144            if !s.ends_with('\n') {
145                s.push('\n');
146            }
147        }
148        s.push_str("=== END PIPELINE RAW DUMP ===\n");
149        s
150    }
151
152    /// Concatenate all chunk bytes in order (exact stream).
153    pub fn concat(&self) -> bytes::Bytes {
154        let mut out = Vec::new();
155        for f in &self.frames {
156            out.extend_from_slice(&f.bytes);
157        }
158        bytes::Bytes::from(out)
159    }
160
161    /// True if any chunk contains the needle.
162    pub fn contains_str(&self, needle: &str) -> bool {
163        self.frames
164            .iter()
165            .any(|f| String::from_utf8_lossy(&f.bytes).contains(needle))
166    }
167}
168
169/// Result of a test driver run over raw dialect bytes.
170#[derive(Debug)]
171pub struct DriverRunReport {
172    /// Owning run id.
173    pub run_id: MonoloopRunId,
174    /// Interpreter events collected (from a third lossless tap).
175    pub interpreter_events: Vec<InterpreterOutputEvent>,
176    /// Loop output events.
177    pub loop_events: Vec<LoopOutputEvent>,
178    /// Loop terminal.
179    pub loop_end: LoopEnd,
180    /// Console text (if rendered).
181    pub console_text: String,
182    /// Count of tool_unavailable outcomes.
183    pub tools_unavailable: u64,
184    /// Exact input chunks when `PipelineParams.dump_raw` is set.
185    pub raw_dump: Option<PipelineRawDump>,
186    /// HTML review built from canonical events (when requested).
187    pub html_report: Option<HtmlReport>,
188    /// Path written when `html_dump_path` was set.
189    pub html_dump_path: Option<PathBuf>,
190}
191
192/// Run Interpreter + Console + Empty Loop over pre-chunked raw bytes.
193pub async fn run_bytes_pipeline(
194    dialect: DialectBinding,
195    chunks: &[bytes::Bytes],
196    render_console: bool,
197) -> DriverRunReport {
198    run_bytes_pipeline_with_params(
199        dialect,
200        chunks,
201        PipelineParams {
202            render_console,
203            dump_raw: false,
204            html_dump_path: None,
205            html_params: HtmlReportParams::default(),
206            build_html: false,
207        },
208    )
209    .await
210}
211
212/// Same as [`run_bytes_pipeline`] with full params (console + optional raw dump).
213pub async fn run_bytes_pipeline_with_params(
214    dialect: DialectBinding,
215    chunks: &[bytes::Bytes],
216    params: PipelineParams,
217) -> DriverRunReport {
218    let run_id = MonoloopRunId::generate();
219    let interpretation_id = InterpretationId::generate();
220    let connection_id = ConnectionId::new("driver-conn");
221    let loop_id = LoopId::generate();
222
223    let raw_dump = if params.dump_raw {
224        Some(PipelineRawDump {
225            frames: chunks
226                .iter()
227                .enumerate()
228                .map(|(i, b)| RawInputFrame {
229                    index: i as u64,
230                    bytes: b.clone(),
231                })
232                .collect(),
233        })
234    } else {
235        None
236    };
237
238    let factory = DefaultInterpreterFactory::new();
239    let interp = factory
240        .start(StartInterpretation {
241            interpretation_id: interpretation_id.clone(),
242            connection_id: connection_id.clone(),
243            external_session_id: None,
244            dialect,
245            limits: InterpretationLimits::default(),
246        })
247        .expect("start interpretation");
248
249    let mut dist = EventDistributor::new();
250    // Independent subscriptions — never one shared receiver.
251    let loop_sub = dist.subscribe("loop", SubscriberPolicy::Lossless, 1024);
252    let console_sub = dist.subscribe("console", SubscriberPolicy::BestEffort, 1024);
253    let tap_sub = dist.subscribe("tap", SubscriberPolicy::Lossless, 4096);
254
255    let loop_rt = DefaultLoopRuntime::new();
256    let scope = LoopScope::single(
257        run_id.clone(),
258        loop_id.clone(),
259        interpretation_id,
260        connection_id,
261        None,
262    );
263    // Testkit owns the JoinHandle (explicit spawn — not product ambient start).
264    let (loop_handle, loop_fut) = loop_rt
265        .prepare_empty(
266            run_id.clone(),
267            loop_id,
268            scope,
269            loop_sub,
270            LoopLimits::default(),
271        )
272        .expect("prepare loop");
273    tokio::spawn(loop_fut);
274
275    let sink = Arc::new(SyncMemorySink::new());
276    let console_task = if params.render_console {
277        let renderer = Arc::new(ConsoleRenderer::new(
278            ConsoleRendererConfig::default(),
279            sink.clone() as Arc<dyn ConsoleSink>,
280        ));
281        Some(tokio::spawn(async move {
282            let mut sub = console_sub;
283            while let Some(msg) = sub.recv().await {
284                if let Ok(delivered) = msg {
285                    renderer.render(&delivered.event);
286                    if matches!(delivered.event, InterpreterOutputEvent::Ended(_)) {
287                        break;
288                    }
289                }
290            }
291        }))
292    } else {
293        drop(console_sub);
294        None
295    };
296
297    let tap_events = Arc::new(Mutex::new(Vec::new()));
298    let tap_events2 = Arc::clone(&tap_events);
299    let tap_task = tokio::spawn(async move {
300        let mut sub = tap_sub;
301        let mut out = Vec::new();
302        while let Some(msg) = sub.recv().await {
303            if let Ok(delivered) = msg {
304                let done = matches!(delivered.event, InterpreterOutputEvent::Ended(_));
305                out.push(delivered.event);
306                if done {
307                    break;
308                }
309            }
310        }
311        *tap_events2.lock().await = out;
312    });
313
314    let loop_collect = tokio::spawn(collect_loop_output(loop_handle));
315
316    let pump = {
317        let events = Arc::clone(&interp.events);
318        tokio::spawn(async move {
319            pump_interpreter_to_distributor(events, dist).await;
320        })
321    };
322
323    for chunk in chunks {
324        interp.input.push_bytes(chunk.clone()).await.expect("push");
325    }
326    interp.input.finish_clean().await.expect("finish");
327
328    let _ = pump.await;
329    let (loop_events, loop_end) = loop_collect.await.expect("loop join");
330    let _ = tap_task.await;
331    if let Some(t) = console_task {
332        let _ = t.await;
333    }
334
335    let interpreter_events = tap_events.lock().await.clone();
336    let tools_unavailable = loop_events
337        .iter()
338        .filter(|e| {
339            matches!(
340                e,
341                LoopOutputEvent::OutboundToolResult(r)
342                    if r.outcome == OutboundToolOutcome::ToolUnavailable
343            )
344        })
345        .count() as u64;
346
347    let want_html = params.build_html || params.html_dump_path.is_some();
348    let (html_report, html_dump_path) = if want_html {
349        let report = build_html_report(&interpreter_events, &params.html_params);
350        let written = if let Some(ref path) = params.html_dump_path {
351            write_html_report(path, &report).expect("write html dump");
352            Some(path.clone())
353        } else {
354            None
355        };
356        (Some(report), written)
357    } else {
358        (None, None)
359    };
360
361    DriverRunReport {
362        run_id,
363        interpreter_events,
364        loop_events,
365        loop_end,
366        console_text: sink.join(),
367        tools_unavailable,
368        raw_dump,
369        html_report,
370        html_dump_path,
371    }
372}
373
374async fn collect_loop_output(handle: LoopHandle) -> (Vec<LoopOutputEvent>, LoopEnd) {
375    let mut out = Vec::new();
376    {
377        let mut rx = handle.take_output().await;
378        while let Some(ev) = rx.recv().await {
379            let done = matches!(ev, LoopOutputEvent::LoopEnded(_));
380            out.push(ev);
381            if done {
382                break;
383            }
384        }
385    }
386    let from_stream = out.iter().rev().find_map(|e| match e {
387        LoopOutputEvent::LoopEnded(le) => Some(le.clone()),
388        _ => None,
389    });
390    let loop_end = match from_stream {
391        Some(e) => e,
392        None => handle.completion.wait().await,
393    };
394    (out, loop_end)
395}