Skip to main content

monoloop_testkit/
live_codex.rs

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