Skip to main content

monoloop_testkit/
live_agy.rs

1//! End-to-end live Antigravity ACP driver (via `agy-acp` bridge).
2//!
3//! **Test kit only.** Requires `agy` authenticated and an ACP bridge
4//! (`agy-acp` on PATH or `npx agy-acp`).
5
6use crate::console::{ConsoleRenderer, ConsoleRendererConfig, SyncMemorySink};
7use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
8use monoloop_connector_agy::{AgyAgentConfig, AgyAgentHandle, AgySessionConfig};
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 agy prompt run.
18#[derive(Clone, Debug)]
19pub struct LiveAgyRunOptions {
20    /// Prompt text.
21    pub prompt: String,
22    /// Working directory.
23    pub cwd: PathBuf,
24    /// ACP process config.
25    pub agent: AgyAgentConfig,
26    /// Session create options.
27    pub session: AgySessionConfig,
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 LiveAgyRunOptions {
39    /// Defaults under `target/live_agy_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_agy_run");
43        let mut agent = AgyAgentConfig::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: AgySessionConfig::new(project),
53            title: "Live Antigravity 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 LiveAgyArtifactPaths {
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 agy run.
75#[derive(Clone, Debug)]
76pub struct LiveAgyRunReport {
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: LiveAgyArtifactPaths,
91}
92
93/// Run one prompt against live Antigravity ACP and write review artifacts.
94pub async fn run_live_agy_prompt(opts: LiveAgyRunOptions) -> Result<LiveAgyRunReport, String> {
95    if let Some(parent) = opts.artifact_stem.parent() {
96        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
97    }
98
99    let mut agent = AgyAgentHandle::connect(opts.agent.clone())
100        .await
101        .map_err(|e| e.to_string())?;
102    let mut updates = agent.take_updates();
103    let mut session_cfg = opts.session.clone();
104    session_cfg.cwd = opts.cwd.clone();
105    let session = agent
106        .session_new(session_cfg)
107        .await
108        .map_err(|e| e.to_string())?;
109    let session_id = session.session_id.clone();
110
111    let dialect = DialectBinding::negotiated(DialectDescriptor::agy_acp("1"));
112    let factory = DefaultInterpreterFactory::new();
113    let interp = factory
114        .start(StartInterpretation {
115            interpretation_id: InterpretationId::generate(),
116            connection_id: monoloop_contracts::ConnectionId::new("agy-live"),
117            external_session_id: Some(session.external_session_id()),
118            dialect,
119            limits: InterpretationLimits::default(),
120        })
121        .map_err(|e| e.to_string())?;
122
123    let input = interp.input.clone();
124    let pump = tokio::spawn(async move {
125        while let Some(bytes) = updates.recv().await {
126            if input.push_bytes(bytes).await.is_err() {
127                break;
128            }
129        }
130    });
131
132    let prompt_result = session
133        .prompt_text(&opts.prompt)
134        .await
135        .map_err(|e| e.to_string())?;
136    let prompt_result_s = prompt_result.to_string();
137
138    tokio::time::sleep(opts.drain_after_prompt).await;
139    let dump_text = agent.raw_dump_text();
140    let _ = interp.input.finish_clean().await;
141    agent.shutdown().await;
142    let _ = pump.await;
143
144    let mut events = Vec::new();
145    let sink = std::sync::Arc::new(SyncMemorySink::new());
146    let console = ConsoleRenderer::new(ConsoleRendererConfig::default(), sink.clone());
147    loop {
148        match interp.events.recv().await {
149            Some(ev) => {
150                if opts.render_console {
151                    console.render(&ev);
152                }
153                let done = matches!(ev, InterpreterOutputEvent::Ended(_));
154                events.push(ev);
155                if done {
156                    break;
157                }
158            }
159            None => break,
160        }
161    }
162
163    let html = build_html_report(
164        &events,
165        &HtmlReportParams {
166            title: opts.title.clone(),
167            ..HtmlReportParams::default()
168        },
169    );
170    let html_path = PathBuf::from(format!("{}.html", opts.artifact_stem.display()));
171    write_html_report(&html_path, &html).map_err(|e| e.to_string())?;
172
173    let raw_path = PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display()));
174    if !dump_text.is_empty() {
175        let _ = std::fs::write(&raw_path, &dump_text);
176    } else if !raw_path.is_file() {
177        let _ = std::fs::write(&raw_path, "");
178    }
179
180    let mut sequence_text = String::from("=== LIVE AGY — CANONICAL TEXT ===\n");
181    for (i, e) in events.iter().enumerate() {
182        if let InterpreterOutputEvent::Unit(u) = e {
183            if let CanonicalUnit::Text(t) = &u.snapshot().unit {
184                sequence_text.push_str(&format!("{i:04} | {}\n", t.content));
185            }
186        }
187    }
188    sequence_text.push_str(&format!(
189        "\nsessionId={session_id}\nprompt_result={prompt_result_s}\n"
190    ));
191    let seq_path = PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display()));
192    std::fs::write(&seq_path, &sequence_text).map_err(|e| e.to_string())?;
193
194    let chat_path = PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display()));
195    std::fs::write(&chat_path, &html.chat_projection.plain_text).map_err(|e| e.to_string())?;
196
197    Ok(LiveAgyRunReport {
198        session_id,
199        prompt_result: prompt_result_s,
200        events,
201        html,
202        console_text: sink.join(),
203        sequence_text,
204        paths: LiveAgyArtifactPaths {
205            html: html_path,
206            raw: raw_path,
207            sequence: seq_path,
208            chat: chat_path,
209        },
210    })
211}