Skip to main content

monoloop_testkit/
live_zai.rs

1//! End-to-end live Z.ai CLI driver (`zai -p` headless NDJSON).
2//!
3//! **Test kit only.** Requires `zai` installed and configured (`zai config` / `ZAI_API_KEY`).
4
5use crate::console::{ConsoleRenderer, ConsoleRendererConfig, SyncMemorySink};
6use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
7use monoloop_connector_zai::{run_headless_prompt, ZaiAgentConfig};
8use monoloop_contracts::{
9    CanonicalUnit, DialectBinding, DialectDescriptor, InterpretationId, InterpretationLimits,
10    InterpreterOutputEvent,
11};
12use monoloop_interpreter::{DefaultInterpreterFactory, InterpreterFactory, StartInterpretation};
13use std::path::PathBuf;
14use std::time::Duration;
15use tokio::sync::mpsc;
16
17/// Configuration for a single live zai headless run.
18#[derive(Clone, Debug)]
19pub struct LiveZaiRunOptions {
20    /// Prompt text.
21    pub prompt: String,
22    /// Working directory.
23    pub cwd: PathBuf,
24    /// Process config.
25    pub agent: ZaiAgentConfig,
26    /// HTML title.
27    pub title: String,
28    /// Artifact stem.
29    pub artifact_stem: PathBuf,
30    /// Render console lines while collecting.
31    pub render_console: bool,
32}
33
34impl LiveZaiRunOptions {
35    /// Defaults under `target/live_zai_run`.
36    pub fn for_project(project: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
37        let project = project.into();
38        let stem = project.join("target/live_zai_run");
39        let mut agent = ZaiAgentConfig::for_project(project.clone());
40        agent.raw_dump_path = Some(PathBuf::from(format!("{}.raw.txt", stem.display())));
41        agent.run_deadline = Duration::from_secs(10 * 60);
42        Self {
43            prompt: prompt.into(),
44            cwd: project,
45            agent,
46            title: "Live Z.ai CLI — interpretation review".into(),
47            artifact_stem: stem,
48            render_console: true,
49        }
50    }
51}
52
53/// Artifact paths written by a live run.
54#[derive(Clone, Debug)]
55pub struct LiveZaiArtifactPaths {
56    /// HTML review.
57    pub html: PathBuf,
58    /// Raw NDJSON dump.
59    pub raw: PathBuf,
60    /// Sequence summary.
61    pub sequence: PathBuf,
62    /// Chat projection plain text.
63    pub chat: PathBuf,
64}
65
66/// Report from a managed live zai run.
67#[derive(Clone, Debug)]
68pub struct LiveZaiRunReport {
69    /// Synthetic session id for the headless run.
70    pub session_id: String,
71    /// Process exit code.
72    pub exit_code: Option<i32>,
73    /// Interpreter events.
74    pub events: Vec<InterpreterOutputEvent>,
75    /// HTML review.
76    pub html: HtmlReport,
77    /// Console text.
78    pub console_text: String,
79    /// Sequence summary.
80    pub sequence_text: String,
81    /// Paths written.
82    pub paths: LiveZaiArtifactPaths,
83}
84
85/// Run one prompt against live Z.ai CLI headless and write review artifacts.
86pub async fn run_live_zai_prompt(opts: LiveZaiRunOptions) -> Result<LiveZaiRunReport, String> {
87    if let Some(parent) = opts.artifact_stem.parent() {
88        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
89    }
90
91    let mut agent = opts.agent.clone();
92    agent.cwd = opts.cwd.clone();
93    agent.raw_dump_path = Some(PathBuf::from(format!(
94        "{}.raw.txt",
95        opts.artifact_stem.display()
96    )));
97
98    let (tx, mut updates) = mpsc::channel(256);
99    let run = tokio::spawn({
100        let agent = agent.clone();
101        let prompt = opts.prompt.clone();
102        async move { run_headless_prompt(&agent, &prompt, tx).await }
103    });
104
105    let dialect = DialectBinding::negotiated(DialectDescriptor::zai_cli("1"));
106    let factory = DefaultInterpreterFactory::new();
107    let interp = factory
108        .start(StartInterpretation {
109            interpretation_id: InterpretationId::generate(),
110            connection_id: monoloop_contracts::ConnectionId::new("zai-live"),
111            external_session_id: None,
112            dialect,
113            limits: InterpretationLimits::default(),
114        })
115        .map_err(|e| e.to_string())?;
116
117    let input = interp.input.clone();
118    let pump = tokio::spawn(async move {
119        while let Some(bytes) = updates.recv().await {
120            if input.push_bytes(bytes).await.is_err() {
121                break;
122            }
123        }
124    });
125
126    let outcome = run
127        .await
128        .map_err(|e| e.to_string())?
129        .map_err(|e| e.to_string())?;
130    let _ = pump.await;
131    let _ = interp.input.finish_clean().await;
132
133    let mut events = Vec::new();
134    let sink = std::sync::Arc::new(SyncMemorySink::new());
135    let console = ConsoleRenderer::new(ConsoleRendererConfig::default(), sink.clone());
136    loop {
137        match interp.events.recv().await {
138            Some(ev) => {
139                if opts.render_console {
140                    console.render(&ev);
141                }
142                let done = matches!(ev, InterpreterOutputEvent::Ended(_));
143                events.push(ev);
144                if done {
145                    break;
146                }
147            }
148            None => break,
149        }
150    }
151
152    let html = build_html_report(
153        &events,
154        &HtmlReportParams {
155            title: opts.title.clone(),
156            ..HtmlReportParams::default()
157        },
158    );
159    let html_path = PathBuf::from(format!("{}.html", opts.artifact_stem.display()));
160    write_html_report(&html_path, &html).map_err(|e| e.to_string())?;
161
162    let raw_path = PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display()));
163    if !outcome.raw_dump_text.is_empty() {
164        let _ = std::fs::write(&raw_path, &outcome.raw_dump_text);
165    }
166
167    let mut sequence_text = String::from("=== LIVE ZAI — CANONICAL TEXT ===\n");
168    for (i, ev) in events.iter().enumerate() {
169        if let InterpreterOutputEvent::Unit(u) = ev {
170            if let CanonicalUnit::Text(t) = &u.snapshot().unit {
171                sequence_text.push_str(&format!("{i:04} | {}\n", t.content));
172            }
173        }
174    }
175    sequence_text.push_str(&format!(
176        "\nsession={} exit={:?} sentences={} strategy={:?} confidence={:?}\n",
177        outcome.session_id,
178        outcome.exit_code,
179        html.sentence_count,
180        html.chat_projection.strategy,
181        html.chat_projection.confidence
182    ));
183    let seq_path = PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display()));
184    let _ = std::fs::write(&seq_path, &sequence_text);
185
186    let chat_path = PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display()));
187    let _ = std::fs::write(&chat_path, &html.chat_projection.plain_text);
188
189    Ok(LiveZaiRunReport {
190        session_id: outcome.session_id,
191        exit_code: outcome.exit_code,
192        events,
193        html,
194        console_text: sink.join(),
195        sequence_text,
196        paths: LiveZaiArtifactPaths {
197            html: html_path,
198            raw: raw_path,
199            sequence: seq_path,
200            chat: chat_path,
201        },
202    })
203}