Skip to main content

monoloop_testkit/
live_cursor.rs

1//! End-to-end live Cursor ACP driver: **spawn agent → session → prompt → collect → stop**.
2//!
3//! **Test kit only.** Requires Cursor CLI (`agent`) authenticated (`agent login`
4//! or `CURSOR_API_KEY`).
5
6use 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/// Configuration for a single live Cursor prompt run.
18#[derive(Clone, Debug)]
19pub struct LiveCursorRunOptions {
20    /// Prompt text.
21    pub prompt: String,
22    /// Working directory for the session.
23    pub cwd: PathBuf,
24    /// Agent process config.
25    pub agent: CursorAgentConfig,
26    /// Session create options (mode / model).
27    pub session: CursorSessionConfig,
28    /// HTML title.
29    pub title: String,
30    /// Artifact stem (`{stem}.html`, `.raw.txt`, `.sequence.txt`, `.chat.txt`).
31    pub artifact_stem: PathBuf,
32    /// When true, print console lines while collecting.
33    pub render_console: bool,
34    /// Outer ceiling on collecting after prompt returns (drain late updates).
35    pub drain_after_prompt: Duration,
36}
37
38impl LiveCursorRunOptions {
39    /// Defaults under `target/live_cursor_run` for a project root.
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_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    /// Ask mode (no tools/edits) convenience.
60    pub fn with_ask_mode(mut self) -> Self {
61        self.session = self.session.with_ask_mode();
62        self
63    }
64
65    /// Agent mode (tools) convenience.
66    pub fn with_agent_mode(mut self) -> Self {
67        self.session = self.session.with_agent_mode();
68        self
69    }
70}
71
72/// Artifact paths written by a live Cursor run.
73#[derive(Clone, Debug)]
74pub struct LiveCursorArtifactPaths {
75    /// HTML review page.
76    pub html: PathBuf,
77    /// Raw NDJSON dump.
78    pub raw: PathBuf,
79    /// Sequence summary.
80    pub sequence: PathBuf,
81    /// Chat projection plain text.
82    pub chat: PathBuf,
83}
84
85/// Report from a managed live Cursor run.
86#[derive(Clone, Debug)]
87pub struct LiveCursorRunReport {
88    /// Cursor sessionId.
89    pub session_id: String,
90    /// Prompt RPC result JSON.
91    pub prompt_result: String,
92    /// Interpreter events.
93    pub events: Vec<InterpreterOutputEvent>,
94    /// HTML review.
95    pub html: HtmlReport,
96    /// Console text.
97    pub console_text: String,
98    /// Sequence summary.
99    pub sequence_text: String,
100    /// Paths written.
101    pub paths: LiveCursorArtifactPaths,
102}
103
104/// Run one prompt against a live Cursor ACP agent and write review artifacts.
105pub 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    // Feed session/update NDJSON into the Interpreter while the prompt runs.
137    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    // Brief drain for trailing updates after stopReason.
153    tokio::time::sleep(opts.drain_after_prompt).await;
154    // Snapshot raw dump before process teardown.
155    let dump_text = agent.raw_dump_text();
156    // Finish interpretation cleanly, then shut down agent (closes update stream).
157    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}