monoloop_testkit/
live_codex.rs1use 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#[derive(Clone, Debug)]
19pub struct LiveCodexRunOptions {
20 pub prompt: String,
22 pub cwd: PathBuf,
24 pub agent: CodexAgentConfig,
26 pub session: CodexSessionConfig,
28 pub title: String,
30 pub artifact_stem: PathBuf,
32 pub render_console: bool,
34 pub drain_after_prompt: Duration,
36}
37
38impl LiveCodexRunOptions {
39 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#[derive(Clone, Debug)]
63pub struct LiveCodexArtifactPaths {
64 pub html: PathBuf,
66 pub raw: PathBuf,
68 pub sequence: PathBuf,
70 pub chat: PathBuf,
72}
73
74#[derive(Clone, Debug)]
76pub struct LiveCodexRunReport {
77 pub session_id: String,
79 pub prompt_result: String,
81 pub events: Vec<InterpreterOutputEvent>,
83 pub html: HtmlReport,
85 pub console_text: String,
87 pub sequence_text: String,
89 pub paths: LiveCodexArtifactPaths,
91}
92
93pub 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}