monoloop_testkit/
live_cursor.rs1use crate::console::{ConsoleRenderer, ConsoleRendererConfig, SyncMemorySink};
7use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
8use monoloop_connector_cursor::{CursorAgentConfig, CursorAgentHandle, CursorSessionConfig};
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 LiveCursorRunOptions {
20 pub prompt: String,
22 pub cwd: PathBuf,
24 pub agent: CursorAgentConfig,
26 pub session: CursorSessionConfig,
28 pub title: String,
30 pub artifact_stem: PathBuf,
32 pub render_console: bool,
34 pub drain_after_prompt: Duration,
36}
37
38impl LiveCursorRunOptions {
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_cursor_run");
43 let mut agent = CursorAgentConfig::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 Self {
48 prompt: prompt.into(),
49 cwd: project.clone(),
50 agent,
51 session: CursorSessionConfig::new(project).with_agent_mode(),
52 title: "Live Cursor ACP — interpretation review".into(),
53 artifact_stem: stem,
54 render_console: true,
55 drain_after_prompt: Duration::from_millis(200),
56 }
57 }
58
59 pub fn with_ask_mode(mut self) -> Self {
61 self.session = self.session.with_ask_mode();
62 self
63 }
64
65 pub fn with_agent_mode(mut self) -> Self {
67 self.session = self.session.with_agent_mode();
68 self
69 }
70}
71
72#[derive(Clone, Debug)]
74pub struct LiveCursorArtifactPaths {
75 pub html: PathBuf,
77 pub raw: PathBuf,
79 pub sequence: PathBuf,
81 pub chat: PathBuf,
83}
84
85#[derive(Clone, Debug)]
87pub struct LiveCursorRunReport {
88 pub session_id: String,
90 pub prompt_result: String,
92 pub events: Vec<InterpreterOutputEvent>,
94 pub html: HtmlReport,
96 pub console_text: String,
98 pub sequence_text: String,
100 pub paths: LiveCursorArtifactPaths,
102}
103
104pub async fn run_live_cursor_prompt(
106 opts: LiveCursorRunOptions,
107) -> Result<LiveCursorRunReport, String> {
108 if let Some(parent) = opts.artifact_stem.parent() {
109 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
110 }
111
112 let mut agent = CursorAgentHandle::connect(opts.agent.clone())
113 .await
114 .map_err(|e| e.to_string())?;
115 let mut updates = agent.take_updates();
116 let mut session_cfg = opts.session.clone();
117 session_cfg.cwd = opts.cwd.clone();
118 let session = agent
119 .session_new(session_cfg)
120 .await
121 .map_err(|e| e.to_string())?;
122 let session_id = session.session_id.clone();
123
124 let dialect = DialectBinding::negotiated(DialectDescriptor::cursor_acp("1"));
125 let factory = DefaultInterpreterFactory::new();
126 let interp = factory
127 .start(StartInterpretation {
128 interpretation_id: InterpretationId::generate(),
129 connection_id: monoloop_contracts::ConnectionId::new("cursor-live"),
130 external_session_id: Some(session.external_session_id()),
131 dialect,
132 limits: InterpretationLimits::default(),
133 })
134 .map_err(|e| e.to_string())?;
135
136 let input = interp.input.clone();
138 let pump = tokio::spawn(async move {
139 while let Some(bytes) = updates.recv().await {
140 if input.push_bytes(bytes).await.is_err() {
141 break;
142 }
143 }
144 });
145
146 let prompt_result = session
147 .prompt_text(&opts.prompt)
148 .await
149 .map_err(|e| e.to_string())?;
150 let prompt_result_s = prompt_result.to_string();
151
152 tokio::time::sleep(opts.drain_after_prompt).await;
154 let dump_text = agent.raw_dump_text();
156 let _ = interp.input.finish_clean().await;
158 agent.shutdown().await;
159 let _ = pump.await;
160
161 let mut events = Vec::new();
162 let sink = std::sync::Arc::new(SyncMemorySink::new());
163 let console = ConsoleRenderer::new(ConsoleRendererConfig::default(), sink.clone());
164 loop {
165 match interp.events.recv().await {
166 Some(ev) => {
167 if opts.render_console {
168 console.render(&ev);
169 }
170 let done = matches!(ev, InterpreterOutputEvent::Ended(_));
171 events.push(ev);
172 if done {
173 break;
174 }
175 }
176 None => break,
177 }
178 }
179
180 let html = build_html_report(
181 &events,
182 &HtmlReportParams {
183 title: opts.title.clone(),
184 ..HtmlReportParams::default()
185 },
186 );
187 let html_path = PathBuf::from(format!("{}.html", opts.artifact_stem.display()));
188 write_html_report(&html_path, &html).map_err(|e| e.to_string())?;
189
190 let raw_path = PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display()));
191 if !dump_text.is_empty() {
192 let _ = std::fs::write(&raw_path, &dump_text);
193 } else if !raw_path.is_file() {
194 let _ = std::fs::write(&raw_path, "");
195 }
196
197 let mut sequence_text = String::from("=== LIVE CURSOR — CANONICAL TEXT ===\n");
198 for (i, e) in events.iter().enumerate() {
199 if let InterpreterOutputEvent::Unit(u) = e {
200 if let CanonicalUnit::Text(t) = &u.snapshot().unit {
201 sequence_text.push_str(&format!("{i:04} | {}\n", t.content));
202 }
203 }
204 }
205 sequence_text.push_str(&format!(
206 "\nsessionId={session_id}\nprompt_result={prompt_result_s}\n"
207 ));
208 let seq_path = PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display()));
209 std::fs::write(&seq_path, &sequence_text).map_err(|e| e.to_string())?;
210
211 let chat_path = PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display()));
212 std::fs::write(&chat_path, &html.chat_projection.plain_text).map_err(|e| e.to_string())?;
213
214 let console_text = sink.join();
215
216 Ok(LiveCursorRunReport {
217 session_id,
218 prompt_result: prompt_result_s,
219 events,
220 html,
221 console_text,
222 sequence_text,
223 paths: LiveCursorArtifactPaths {
224 html: html_path,
225 raw: raw_path,
226 sequence: seq_path,
227 chat: chat_path,
228 },
229 })
230}