Skip to main content

monoloop_testkit/
live_grok.rs

1//! End-to-end live Grok driver: **start serve → session → prompt → collect → stop**.
2//!
3//! **Test kit only.** Owns the Grok child process for the duration of the run.
4//! The client waits for Grok's natural `session/prompt` completion (no short
5//! artificial hang for the operator); an optional safety ceiling may still be set.
6
7use crate::console::{ConsoleRenderer, ConsoleRendererConfig, ConsoleSink, SyncMemorySink};
8use crate::grok_serve::{GrokServeOptions, ManagedGrokServe};
9use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
10use monoloop_connector_grok::{
11    EncodedAcpSessionMessage, GrokConnector, GrokConnectorLimits, GrokServerConfig,
12    GrokSessionConfig, InMemorySecretResolver, RawDumpCollector, RawDumpSnapshot, SecretRef,
13};
14use monoloop_contracts::{
15    CanonicalUnit, DialectBinding, DialectDescriptor, InterpretationId, InterpretationLimits,
16    InterpreterOutputEvent,
17};
18use monoloop_interpreter::{DefaultInterpreterFactory, InterpreterFactory, StartInterpretation};
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::time::Duration;
22
23/// Configuration for a single live Grok prompt run.
24#[derive(Clone, Debug)]
25pub struct LiveGrokRunOptions {
26    /// Prompt text sent via `session/prompt`.
27    pub prompt: String,
28    /// Working directory for the Grok session (`cwd`).
29    pub cwd: PathBuf,
30    /// Serve options (port, secret, log path, ready timeout).
31    pub serve: GrokServeOptions,
32    /// Title used in the HTML review page.
33    pub title: String,
34    /// Artifact stem directory + base name (without extension).
35    /// Writes `{stem}.html`, `{stem}.raw.txt`, `{stem}.sequence.txt`, `{stem}.chat.txt`.
36    pub artifact_stem: PathBuf,
37    /// How long the Connector may wait for a single JSON-RPC (including the prompt).
38    /// Default: 2 hours — long enough for real agent work; still fail-closed.
39    pub request_deadline: Duration,
40    /// Optional outer ceiling on waiting for prompt completion.
41    /// `None` = wait until the RPC finishes or `request_deadline` fires.
42    pub prompt_wait_ceiling: Option<Duration>,
43    /// Connect / session-open timeout.
44    pub connect_timeout: Duration,
45    /// When true, render console lines while collecting.
46    pub render_console: bool,
47}
48
49impl LiveGrokRunOptions {
50    /// Sensible defaults for a repo-root live capture under `target/`.
51    pub fn for_project(project: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
52        let project = project.into();
53        let log = project.join("target/grok-serve.managed.log");
54        Self {
55            prompt: prompt.into(),
56            cwd: project.clone(),
57            serve: GrokServeOptions {
58                port: None, // ephemeral — avoids clashing with a leftover serve
59                secret: std::env::var("GROK_AGENT_SECRET")
60                    .unwrap_or_else(|_| "monoloop-live-test".into()),
61                grok_bin: PathBuf::from(
62                    std::env::var("GROK_BIN").unwrap_or_else(|_| "grok".into()),
63                ),
64                ready_timeout: Duration::from_secs(15),
65                log_path: Some(log),
66            },
67            title: "Live Grok Build — interpretation review".into(),
68            artifact_stem: project.join("target/live_grok_run"),
69            request_deadline: Duration::from_secs(2 * 60 * 60),
70            prompt_wait_ceiling: None,
71            connect_timeout: Duration::from_secs(30),
72            render_console: true,
73        }
74    }
75}
76
77/// Full report from a managed live run.
78#[derive(Clone, Debug)]
79pub struct LiveGrokRunReport {
80    /// Grok `sessionId` string.
81    pub session_id: String,
82    /// Terminal JSON-RPC result body (or timeout/error note).
83    pub prompt_result: String,
84    /// Whether the prompt wait hit the optional outer ceiling.
85    pub timed_out: bool,
86    /// Canonical interpreter events (including `Ended` when available).
87    pub events: Vec<InterpreterOutputEvent>,
88    /// HTML review (always built).
89    pub html: HtmlReport,
90    /// Raw dump snapshot (may be empty if capture failed early).
91    pub raw: RawDumpSnapshot,
92    /// Append-only console text.
93    pub console_text: String,
94    /// Human sequence summary.
95    pub sequence_text: String,
96    /// Written artifact paths.
97    pub paths: LiveGrokArtifactPaths,
98    /// Port the managed serve used.
99    pub port: u16,
100}
101
102/// Paths written by the live driver.
103#[derive(Clone, Debug)]
104pub struct LiveGrokArtifactPaths {
105    /// HTML review.
106    pub html: PathBuf,
107    /// Raw wire dump.
108    pub raw: PathBuf,
109    /// Event sequence summary.
110    pub sequence: PathBuf,
111    /// Chat projection plain text.
112    pub chat: PathBuf,
113}
114
115/// Start Grok serve, run one prompt to completion, tear everything down.
116///
117/// Cleanup is guaranteed: serve is stopped even if the prompt fails.
118pub async fn run_live_grok_prompt(opts: LiveGrokRunOptions) -> Result<LiveGrokRunReport, String> {
119    if let Some(parent) = opts.artifact_stem.parent() {
120        if !parent.as_os_str().is_empty() {
121            std::fs::create_dir_all(parent)
122                .map_err(|e| format!("create artifact dir {}: {e}", parent.display()))?;
123        }
124    }
125
126    println!(
127        "live-grok: starting managed serve (ready ≤ {:?})…",
128        opts.serve.ready_timeout
129    );
130    let serve = ManagedGrokServe::start(opts.serve.clone()).await?;
131    let port = serve.port();
132    let secret = serve.secret().to_string();
133    println!("live-grok: serve up pid={:?} port={port}", serve.pid());
134
135    let result = run_session_with_serve(&opts, &serve, &secret, port).await;
136
137    println!("live-grok: stopping serve…");
138    if let Err(e) = serve.stop().await {
139        eprintln!("live-grok: serve stop warning: {e}");
140    } else {
141        println!("live-grok: serve stopped");
142    }
143
144    result
145}
146
147async fn run_session_with_serve(
148    opts: &LiveGrokRunOptions,
149    _serve: &ManagedGrokServe,
150    secret: &str,
151    port: u16,
152) -> Result<LiveGrokRunReport, String> {
153    let secrets = Arc::new(InMemorySecretResolver::new());
154    secrets.insert("GROK_WS_SECRET", secret);
155    let dump = Arc::new(RawDumpCollector::enabled());
156
157    let mut limits = GrokConnectorLimits::default();
158    limits.request_deadline = opts.request_deadline;
159    limits.connect_deadline = opts.connect_timeout;
160
161    let mut config = GrokServerConfig::loopback(port, SecretRef::new("GROK_WS_SECRET"))
162        .map_err(|e| format!("server config: {e}"))?;
163    config.limits = limits;
164    let config = config.with_raw_dump(Arc::clone(&dump));
165
166    let connector = GrokConnector::new(secrets);
167    println!("live-grok: connecting ws://127.0.0.1:{port}/ws …");
168    let pending = connector
169        .connect(config)
170        .map_err(|e| format!("connect begin: {e}"))?;
171    let server = tokio::time::timeout(opts.connect_timeout, pending.opened)
172        .await
173        .map_err(|_| "connect timed out".to_string())?
174        .map_err(|e| format!("connect channel: {e}"))?
175        .map_err(|e| format!("connect failed: {e}"))?;
176    println!("live-grok: connected + initialized");
177
178    let pending_sess = server
179        .sessions
180        .begin_new(GrokSessionConfig {
181            cwd: Some(opts.cwd.display().to_string()),
182            mcp_servers: vec![],
183            permission_mode: Some("always-approve".into()),
184            agent_profile: None,
185            extension_metadata: Some(serde_json::json!({ "yoloMode": true })),
186        })
187        .map_err(|e| format!("session/new begin: {e}"))?;
188    let session = tokio::time::timeout(opts.connect_timeout, pending_sess.opened)
189        .await
190        .map_err(|_| "session/new timed out".to_string())?
191        .map_err(|e| format!("session channel: {e}"))?
192        .map_err(|e| format!("session/new failed: {e}"))?;
193    let session_id = session.session_id.as_str().to_string();
194    println!("live-grok: sessionId={session_id}");
195
196    let factory = DefaultInterpreterFactory::new();
197    let interp = factory
198        .start(StartInterpretation {
199            interpretation_id: InterpretationId::generate(),
200            connection_id: session.connection_id.clone(),
201            external_session_id: Some(session.session_id.clone().into()),
202            dialect: DialectBinding::negotiated(DialectDescriptor::acp_json_rpc("1")),
203            limits: InterpretationLimits::default(),
204        })
205        .map_err(|e| format!("start interpretation: {e}"))?;
206
207    let sink = Arc::new(SyncMemorySink::new());
208    let renderer = ConsoleRenderer::new(
209        ConsoleRendererConfig {
210            show_tool_payloads: true,
211            max_content_chars: 2000,
212            ..Default::default()
213        },
214        sink.clone() as Arc<dyn ConsoleSink>,
215    );
216
217    let output = Arc::clone(&session.output);
218    let input = interp.input.clone();
219    let drain = tokio::spawn(async move {
220        loop {
221            match output.receive().await {
222                Ok(Some(bytes)) => {
223                    if input.push_bytes(bytes).await.is_err() {
224                        break;
225                    }
226                }
227                Ok(None) => break,
228                Err(_) => break,
229            }
230        }
231    });
232
233    println!(
234        "live-grok: session/prompt (request_deadline={:?}, outer_ceiling={:?})…",
235        opts.request_deadline, opts.prompt_wait_ceiling
236    );
237    let exchange = session
238        .input
239        .begin_send(EncodedAcpSessionMessage {
240            method: "session/prompt".into(),
241            params: serde_json::json!({
242                "prompt": [
243                    { "type": "text", "text": opts.prompt }
244                ]
245            }),
246        })
247        .map_err(|e| format!("begin_send: {e}"))?;
248
249    let (prompt_result, timed_out) = match opts.prompt_wait_ceiling {
250        Some(ceiling) => match tokio::time::timeout(ceiling, exchange.response).await {
251            Ok(Ok(Ok(v))) => {
252                println!("live-grok: prompt complete");
253                (format!("{v}"), false)
254            }
255            Ok(Ok(Err(e))) => (format!("error:{e}"), false),
256            Ok(Err(_)) => ("channel_dropped".into(), false),
257            Err(_) => {
258                eprintln!(
259                    "live-grok: outer ceiling {:?} hit — salvaging streamed events",
260                    ceiling
261                );
262                ("timeout".into(), true)
263            }
264        },
265        None => match exchange.response.await {
266            Ok(Ok(v)) => {
267                println!("live-grok: prompt complete");
268                (format!("{v}"), false)
269            }
270            Ok(Err(e)) => (format!("error:{e}"), false),
271            Err(_) => ("channel_dropped".into(), false),
272        },
273    };
274
275    // Brief settle for trailing session/update frames.
276    tokio::time::sleep(Duration::from_millis(400)).await;
277    session
278        .control
279        .cancel(monoloop_connector_grok::CancellationReason::CallerRequested);
280    let _ = tokio::time::timeout(Duration::from_secs(3), drain).await;
281    let _ = interp.input.finish_clean().await;
282
283    let mut events = Vec::new();
284    loop {
285        match tokio::time::timeout(Duration::from_secs(2), interp.events.recv()).await {
286            Ok(Some(ev)) => {
287                if opts.render_console {
288                    renderer.render(&ev);
289                }
290                let done = matches!(ev, InterpreterOutputEvent::Ended(_));
291                events.push(ev);
292                if done {
293                    break;
294                }
295            }
296            Ok(None) => break,
297            Err(_) => break,
298        }
299    }
300
301    let raw = dump.snapshot();
302    let html = build_html_report(
303        &events,
304        &HtmlReportParams {
305            title: opts.title.clone(),
306            show_tool_payloads: true,
307            ..Default::default()
308        },
309    );
310
311    let sequence_text = format_sequence(&session_id, &prompt_result, &events);
312    let paths = LiveGrokArtifactPaths {
313        html: PathBuf::from(format!("{}.html", opts.artifact_stem.display())),
314        raw: PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display())),
315        sequence: PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display())),
316        chat: PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display())),
317    };
318
319    std::fs::write(&paths.raw, raw.format_text()).map_err(|e| format!("write raw: {e}"))?;
320    std::fs::write(&paths.sequence, &sequence_text).map_err(|e| format!("write sequence: {e}"))?;
321    write_html_report(&paths.html, &html).map_err(|e| format!("write html: {e}"))?;
322    std::fs::write(&paths.chat, &html.chat_projection.plain_text)
323        .map_err(|e| format!("write chat: {e}"))?;
324
325    Ok(LiveGrokRunReport {
326        session_id,
327        prompt_result,
328        timed_out,
329        events,
330        html,
331        raw,
332        console_text: sink.join(),
333        sequence_text,
334        paths,
335        port,
336    })
337}
338
339fn format_sequence(
340    session_id: &str,
341    prompt_result: &str,
342    events: &[InterpreterOutputEvent],
343) -> String {
344    let mut sequence = String::new();
345    sequence.push_str("=== LIVE GROK MANAGED RUN — CANONICAL EVENT SEQUENCE ===\n");
346    sequence.push_str(&format!("sessionId={session_id}\n"));
347    sequence.push_str(&format!("prompt_result={prompt_result}\n\n"));
348    for (i, ev) in events.iter().enumerate() {
349        sequence.push_str(&format!("{:04} {}\n", i, describe_event(ev)));
350    }
351    sequence.push_str(&format!("\n=== total events: {} ===\n", events.len()));
352    sequence.push_str("\n=== TOOL ACTIONS (compressed) ===\n");
353    for line in tool_summary(events) {
354        sequence.push_str(&line);
355        sequence.push('\n');
356    }
357    sequence
358}
359
360fn describe_event(ev: &InterpreterOutputEvent) -> String {
361    match ev {
362        InterpreterOutputEvent::Unit(u) => {
363            let s = u.snapshot();
364            match &s.unit {
365                CanonicalUnit::Text(t) => format!(
366                    "TEXT ch={:?} g={} | {}",
367                    t.channel,
368                    s.unit_generation,
369                    truncate(&t.content, 120)
370                ),
371                CanonicalUnit::Tool(t) => format!(
372                    "TOOL action={} name={:?} req={:?} exec={:?} g={} wait={:?} args={}",
373                    t.tool_action_id.as_str(),
374                    t.tool_name,
375                    t.request_state,
376                    t.execution_state,
377                    s.unit_generation,
378                    t.waiting_for,
379                    t.request_payload
380                        .as_deref()
381                        .map(|p| truncate(p, 80))
382                        .unwrap_or_else(|| "-".into())
383                ),
384                CanonicalUnit::Boundary(b) => format!("BOUNDARY {:?}", b.kind),
385                CanonicalUnit::Structure(st) => {
386                    format!("STRUCTURE {:?} | {}", st.kind, truncate(&st.content, 80))
387                }
388                CanonicalUnit::Diagnostic(d) => {
389                    format!("DIAG {:?} | {}", d.kind, truncate(&d.message, 100))
390                }
391                CanonicalUnit::Paragraph(p) => format!("PARAGRAPH {:?}", p.kind),
392                CanonicalUnit::Usage(u) => format!("USAGE {u:?}"),
393            }
394        }
395        InterpreterOutputEvent::Ended(e) => format!(
396            "END {:?} events={} sentences={} unresolved={}",
397            e.kind, e.canonical_event_count, e.completed_sentence_count, e.unresolved_text_bytes
398        ),
399    }
400}
401
402fn tool_summary(events: &[InterpreterOutputEvent]) -> Vec<String> {
403    use std::collections::BTreeMap;
404    let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
405    for ev in events {
406        if let InterpreterOutputEvent::Unit(u) = ev {
407            if let CanonicalUnit::Tool(t) = &u.snapshot().unit {
408                let id = t.tool_action_id.as_str().to_string();
409                map.entry(id).or_default().push(format!(
410                    "g{} {:?} name={:?} terminal={:?}",
411                    u.snapshot().unit_generation,
412                    t.request_state,
413                    t.tool_name,
414                    t.terminal_outcome
415                ));
416            }
417        }
418    }
419    map.into_iter()
420        .map(|(id, gens)| format!("{id}: {}", gens.join(" → ")))
421        .collect()
422}
423
424fn truncate(s: &str, max: usize) -> String {
425    let t: String = s.chars().take(max).collect();
426    if s.chars().count() > max {
427        format!("{t}…")
428    } else {
429        t
430    }
431}