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.wait())
172        .await
173        .map_err(|_| "connect timed out".to_string())?
174        .map_err(|e| format!("connect failed: {e}"))?;
175    println!("live-grok: connected + initialized");
176
177    let pending_sess = server
178        .sessions
179        .begin_new(GrokSessionConfig {
180            cwd: Some(opts.cwd.display().to_string()),
181            mcp_servers: vec![],
182            permission_mode: Some("always-approve".into()),
183            agent_profile: None,
184            extension_metadata: Some(serde_json::json!({ "yoloMode": true })),
185        })
186        .map_err(|e| format!("session/new begin: {e}"))?;
187    let session = tokio::time::timeout(opts.connect_timeout, pending_sess.wait())
188        .await
189        .map_err(|_| "session/new timed out".to_string())?
190        .map_err(|e| format!("session/new failed: {e}"))?;
191    let session_id = session.session_id.as_str().to_string();
192    println!("live-grok: sessionId={session_id}");
193
194    let factory = DefaultInterpreterFactory::new();
195    let interp = factory
196        .start(StartInterpretation {
197            interpretation_id: InterpretationId::generate(),
198            connection_id: session.connection_id.clone(),
199            external_session_id: Some(session.session_id.clone().into()),
200            dialect: DialectBinding::negotiated(DialectDescriptor::acp_json_rpc("1")),
201            limits: InterpretationLimits::default(),
202        })
203        .map_err(|e| format!("start interpretation: {e}"))?;
204
205    let sink = Arc::new(SyncMemorySink::new());
206    let renderer = ConsoleRenderer::new(
207        ConsoleRendererConfig {
208            show_tool_payloads: true,
209            max_content_chars: 2000,
210            ..Default::default()
211        },
212        sink.clone() as Arc<dyn ConsoleSink>,
213    );
214
215    let output = Arc::clone(&session.output);
216    let input = interp.input.clone();
217    let drain = tokio::spawn(async move {
218        loop {
219            match output.receive().await {
220                Ok(Some(bytes)) => {
221                    if input.push_bytes(bytes).await.is_err() {
222                        break;
223                    }
224                }
225                Ok(None) => break,
226                Err(_) => break,
227            }
228        }
229    });
230
231    println!(
232        "live-grok: session/prompt (request_deadline={:?}, outer_ceiling={:?})…",
233        opts.request_deadline, opts.prompt_wait_ceiling
234    );
235    let exchange = session
236        .input
237        .begin_send(EncodedAcpSessionMessage {
238            method: "session/prompt".into(),
239            params: serde_json::json!({
240                "prompt": [
241                    { "type": "text", "text": opts.prompt }
242                ]
243            }),
244        })
245        .map_err(|e| format!("begin_send: {e}"))?;
246
247    let (prompt_result, timed_out) = match opts.prompt_wait_ceiling {
248        Some(ceiling) => match tokio::time::timeout(ceiling, exchange.wait()).await {
249            Ok(Ok(v)) => {
250                println!("live-grok: prompt complete");
251                (format!("{v}"), false)
252            }
253            Ok(Err(e)) => (format!("error:{e}"), false),
254            Err(_) => {
255                eprintln!(
256                    "live-grok: outer ceiling {:?} hit — salvaging streamed events",
257                    ceiling
258                );
259                ("timeout".into(), true)
260            }
261        },
262        None => match exchange.wait().await {
263            Ok(v) => {
264                println!("live-grok: prompt complete");
265                (format!("{v}"), false)
266            }
267            Err(e) => (format!("error:{e}"), false),
268        },
269    };
270
271    // Brief settle for trailing session/update frames.
272    tokio::time::sleep(Duration::from_millis(400)).await;
273    session
274        .control
275        .cancel(monoloop_connector_grok::CancellationReason::CallerRequested);
276    let _ = tokio::time::timeout(Duration::from_secs(3), drain).await;
277    let _ = interp.input.finish_clean().await;
278
279    let mut events = Vec::new();
280    loop {
281        match tokio::time::timeout(Duration::from_secs(2), interp.events.recv()).await {
282            Ok(Some(ev)) => {
283                if opts.render_console {
284                    renderer.render(&ev);
285                }
286                let done = matches!(ev, InterpreterOutputEvent::Ended(_));
287                events.push(ev);
288                if done {
289                    break;
290                }
291            }
292            Ok(None) => break,
293            Err(_) => break,
294        }
295    }
296
297    let raw = dump.snapshot();
298    let html = build_html_report(
299        &events,
300        &HtmlReportParams {
301            title: opts.title.clone(),
302            show_tool_payloads: true,
303            ..Default::default()
304        },
305    );
306
307    let sequence_text = format_sequence(&session_id, &prompt_result, &events);
308    let paths = LiveGrokArtifactPaths {
309        html: PathBuf::from(format!("{}.html", opts.artifact_stem.display())),
310        raw: PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display())),
311        sequence: PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display())),
312        chat: PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display())),
313    };
314
315    std::fs::write(&paths.raw, raw.format_text()).map_err(|e| format!("write raw: {e}"))?;
316    std::fs::write(&paths.sequence, &sequence_text).map_err(|e| format!("write sequence: {e}"))?;
317    write_html_report(&paths.html, &html).map_err(|e| format!("write html: {e}"))?;
318    std::fs::write(&paths.chat, &html.chat_projection.plain_text)
319        .map_err(|e| format!("write chat: {e}"))?;
320
321    Ok(LiveGrokRunReport {
322        session_id,
323        prompt_result,
324        timed_out,
325        events,
326        html,
327        raw,
328        console_text: sink.join(),
329        sequence_text,
330        paths,
331        port,
332    })
333}
334
335fn format_sequence(
336    session_id: &str,
337    prompt_result: &str,
338    events: &[InterpreterOutputEvent],
339) -> String {
340    let mut sequence = String::new();
341    sequence.push_str("=== LIVE GROK MANAGED RUN — CANONICAL EVENT SEQUENCE ===\n");
342    sequence.push_str(&format!("sessionId={session_id}\n"));
343    sequence.push_str(&format!("prompt_result={prompt_result}\n\n"));
344    for (i, ev) in events.iter().enumerate() {
345        sequence.push_str(&format!("{:04} {}\n", i, describe_event(ev)));
346    }
347    sequence.push_str(&format!("\n=== total events: {} ===\n", events.len()));
348    sequence.push_str("\n=== TOOL ACTIONS (compressed) ===\n");
349    for line in tool_summary(events) {
350        sequence.push_str(&line);
351        sequence.push('\n');
352    }
353    sequence
354}
355
356fn describe_event(ev: &InterpreterOutputEvent) -> String {
357    match ev {
358        InterpreterOutputEvent::Unit(u) => {
359            let s = u.snapshot();
360            match &s.unit {
361                CanonicalUnit::Text(t) => format!(
362                    "TEXT ch={:?} g={} | {}",
363                    t.channel,
364                    s.unit_generation,
365                    truncate(&t.content, 120)
366                ),
367                CanonicalUnit::Tool(t) => format!(
368                    "TOOL action={} name={:?} req={:?} exec={:?} g={} wait={:?} args={}",
369                    t.tool_action_id.as_str(),
370                    t.tool_name,
371                    t.request_state,
372                    t.execution_state,
373                    s.unit_generation,
374                    t.waiting_for,
375                    t.request_payload
376                        .as_deref()
377                        .map(|p| truncate(p, 80))
378                        .unwrap_or_else(|| "-".into())
379                ),
380                CanonicalUnit::Boundary(b) => format!("BOUNDARY {:?}", b.kind),
381                CanonicalUnit::Structure(st) => {
382                    format!("STRUCTURE {:?} | {}", st.kind, truncate(&st.content, 80))
383                }
384                CanonicalUnit::Diagnostic(d) => {
385                    format!("DIAG {:?} | {}", d.kind, truncate(&d.message, 100))
386                }
387                CanonicalUnit::Paragraph(p) => format!("PARAGRAPH {:?}", p.kind),
388                CanonicalUnit::Usage(u) => format!("USAGE {u:?}"),
389            }
390        }
391        InterpreterOutputEvent::Ended(e) => format!(
392            "END {:?} events={} sentences={} unresolved={}",
393            e.kind, e.canonical_event_count, e.completed_sentence_count, e.unresolved_text_bytes
394        ),
395    }
396}
397
398fn tool_summary(events: &[InterpreterOutputEvent]) -> Vec<String> {
399    use std::collections::BTreeMap;
400    let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
401    for ev in events {
402        if let InterpreterOutputEvent::Unit(u) = ev {
403            if let CanonicalUnit::Tool(t) = &u.snapshot().unit {
404                let id = t.tool_action_id.as_str().to_string();
405                map.entry(id).or_default().push(format!(
406                    "g{} {:?} name={:?} terminal={:?}",
407                    u.snapshot().unit_generation,
408                    t.request_state,
409                    t.tool_name,
410                    t.terminal_outcome
411                ));
412            }
413        }
414    }
415    map.into_iter()
416        .map(|(id, gens)| format!("{id}: {}", gens.join(" → ")))
417        .collect()
418}
419
420fn truncate(s: &str, max: usize) -> String {
421    let t: String = s.chars().take(max).collect();
422    if s.chars().count() > max {
423        format!("{t}…")
424    } else {
425        t
426    }
427}
428
429/// Options for a **live** multi-session qualification on one long-lived Grok serve.
430///
431/// Proves concurrent `session/new` isolation (distinct `sessionId`s) and optional
432/// explicit `session/load` of one id — not a TransactionRuntime Golden claim.
433#[derive(Clone, Debug)]
434pub struct LiveGrokMultiSessionOptions {
435    /// Shared project / cwd for both sessions.
436    pub project: PathBuf,
437    /// Prompt for session A (should ask for a unique marker).
438    pub prompt_a: String,
439    /// Prompt for session B (should ask for a different unique marker).
440    pub prompt_b: String,
441    /// Marker substring expected in session A's chat/events (isolation check).
442    pub marker_a: String,
443    /// Marker substring expected in session B's chat/events.
444    pub marker_b: String,
445    /// Serve / connector knobs reused from single-session defaults.
446    pub base: LiveGrokRunOptions,
447    /// When true, after both news complete, `session/load` session A's id and
448    /// assert the loaded id matches (no most-recent heuristic).
449    pub verify_explicit_load: bool,
450}
451
452impl LiveGrokMultiSessionOptions {
453    /// Defaults: short marker prompts, 3-minute outer ceiling, default secret.
454    pub fn for_project(project: impl Into<PathBuf>) -> Self {
455        let project = project.into();
456        let mut base = LiveGrokRunOptions::for_project(&project, "unused");
457        base.artifact_stem = project.join("target/live_grok_multi_session");
458        base.title = "Live Grok multi-session qualification".into();
459        // Fail closed for CI/agent runs — do not sit on the 2h RPC ceiling.
460        base.prompt_wait_ceiling = Some(Duration::from_secs(180));
461        base.request_deadline = Duration::from_secs(180);
462        Self {
463            project,
464            prompt_a: "Reply with exactly this token and nothing else: MONOLOOP_MS_A".into(),
465            prompt_b: "Reply with exactly this token and nothing else: MONOLOOP_MS_B".into(),
466            marker_a: "MONOLOOP_MS_A".into(),
467            marker_b: "MONOLOOP_MS_B".into(),
468            base,
469            verify_explicit_load: true,
470        }
471    }
472}
473
474/// Per-session outcome from a multi-session live run.
475#[derive(Clone, Debug)]
476pub struct LiveGrokSessionOutcome {
477    /// Grok `sessionId`.
478    pub session_id: String,
479    /// Prompt JSON-RPC result (or timeout/error note).
480    pub prompt_result: String,
481    /// Whether the outer ceiling fired.
482    pub timed_out: bool,
483    /// Collected interpreter events.
484    pub events: Vec<InterpreterOutputEvent>,
485    /// Chat projection plain text (for marker isolation asserts).
486    pub chat_text: String,
487}
488
489/// Report from [`run_live_grok_multi_session`].
490#[derive(Clone, Debug)]
491pub struct LiveGrokMultiSessionReport {
492    /// Serve port.
493    pub port: u16,
494    /// Session A outcome.
495    pub session_a: LiveGrokSessionOutcome,
496    /// Session B outcome.
497    pub session_b: LiveGrokSessionOutcome,
498    /// Explicit load of A's id succeeded with matching id (when requested).
499    pub load_a_ok: Option<bool>,
500    /// Written summary path.
501    pub summary_path: PathBuf,
502}
503
504/// One serve → two concurrent `session/new` + prompts → optional explicit load.
505///
506/// Uses the default `GROK_AGENT_SECRET` (`monoloop-live-test`) when unset — agents
507/// on this host are expected to be preauthorized; no separate secret bootstrap.
508pub async fn run_live_grok_multi_session(
509    opts: LiveGrokMultiSessionOptions,
510) -> Result<LiveGrokMultiSessionReport, String> {
511    if let Some(parent) = opts.base.artifact_stem.parent() {
512        if !parent.as_os_str().is_empty() {
513            std::fs::create_dir_all(parent)
514                .map_err(|e| format!("create artifact dir {}: {e}", parent.display()))?;
515        }
516    }
517
518    println!(
519        "live-grok-multi: starting managed serve (ready ≤ {:?})…",
520        opts.base.serve.ready_timeout
521    );
522    let serve = ManagedGrokServe::start(opts.base.serve.clone()).await?;
523    let port = serve.port();
524    let secret = serve.secret().to_string();
525    println!(
526        "live-grok-multi: serve up pid={:?} port={port}",
527        serve.pid()
528    );
529
530    let result = run_multi_with_serve(&opts, &secret, port).await;
531
532    println!("live-grok-multi: stopping serve…");
533    if let Err(e) = serve.stop().await {
534        eprintln!("live-grok-multi: serve stop warning: {e}");
535    } else {
536        println!("live-grok-multi: serve stopped");
537    }
538
539    result
540}
541
542async fn run_multi_with_serve(
543    opts: &LiveGrokMultiSessionOptions,
544    secret: &str,
545    port: u16,
546) -> Result<LiveGrokMultiSessionReport, String> {
547    use monoloop_connector_grok::{GrokSessionId, GrokSessionLoadConfig};
548
549    let secrets = Arc::new(InMemorySecretResolver::new());
550    secrets.insert("GROK_WS_SECRET", secret);
551    let dump = Arc::new(RawDumpCollector::enabled());
552
553    let mut limits = GrokConnectorLimits::default();
554    limits.request_deadline = opts.base.request_deadline;
555    limits.connect_deadline = opts.base.connect_timeout;
556
557    let mut config = GrokServerConfig::loopback(port, SecretRef::new("GROK_WS_SECRET"))
558        .map_err(|e| format!("server config: {e}"))?;
559    config.limits = limits;
560    let config = config.with_raw_dump(Arc::clone(&dump));
561
562    let connector = GrokConnector::new(secrets);
563    println!("live-grok-multi: connecting ws://127.0.0.1:{port}/ws …");
564    let pending = connector
565        .connect(config)
566        .map_err(|e| format!("connect begin: {e}"))?;
567    let server = Arc::new(
568        tokio::time::timeout(opts.base.connect_timeout, pending.wait())
569            .await
570            .map_err(|_| "connect timed out".to_string())?
571            .map_err(|e| format!("connect failed: {e}"))?,
572    );
573    println!("live-grok-multi: connected + initialized");
574
575    let session_cfg = GrokSessionConfig {
576        cwd: Some(opts.project.display().to_string()),
577        mcp_servers: vec![],
578        permission_mode: Some("always-approve".into()),
579        agent_profile: None,
580        extension_metadata: Some(serde_json::json!({ "yoloMode": true })),
581    };
582
583    // Concurrent session/new (barrier-style join of two begins).
584    let cfg_a = session_cfg.clone();
585    let cfg_b = session_cfg.clone();
586    let server_a = Arc::clone(&server);
587    let server_b = Arc::clone(&server);
588    let connect_timeout = opts.base.connect_timeout;
589    let (sess_a, sess_b) = tokio::try_join!(
590        async move {
591            let pending = server_a
592                .sessions
593                .begin_new(cfg_a)
594                .map_err(|e| format!("session A begin_new: {e}"))?;
595            tokio::time::timeout(connect_timeout, pending.wait())
596                .await
597                .map_err(|_| "session A new timed out".to_string())?
598                .map_err(|e| format!("session A new failed: {e}"))
599        },
600        async move {
601            let pending = server_b
602                .sessions
603                .begin_new(cfg_b)
604                .map_err(|e| format!("session B begin_new: {e}"))?;
605            tokio::time::timeout(connect_timeout, pending.wait())
606                .await
607                .map_err(|_| "session B new timed out".to_string())?
608                .map_err(|e| format!("session B new failed: {e}"))
609        }
610    )?;
611
612    let id_a = sess_a.session_id.as_str().to_string();
613    let id_b = sess_b.session_id.as_str().to_string();
614    if id_a == id_b {
615        return Err(format!(
616            "concurrent session/new must yield distinct sessionIds; both={id_a}"
617        ));
618    }
619    println!("live-grok-multi: sessionA={id_a}");
620    println!("live-grok-multi: sessionB={id_b}");
621
622    let prompt_a = opts.prompt_a.clone();
623    let prompt_b = opts.prompt_b.clone();
624    let ceiling = opts.base.prompt_wait_ceiling;
625    let render = opts.base.render_console;
626
627    let (out_a, out_b) = tokio::try_join!(
628        prompt_one_session(sess_a, prompt_a, ceiling, render),
629        prompt_one_session(sess_b, prompt_b, ceiling, render),
630    )?;
631
632    // Isolation: each chat must contain its own marker and must not contain the other's.
633    if !out_a.timed_out {
634        let has_a = out_a.chat_text.contains(&opts.marker_a)
635            || out_a
636                .events
637                .iter()
638                .any(|e| event_text(e).contains(&opts.marker_a));
639        if !has_a {
640            return Err(format!(
641                "session A missing marker {}; chat={}",
642                opts.marker_a,
643                truncate(&out_a.chat_text, 200)
644            ));
645        }
646        if out_a.chat_text.contains(&opts.marker_b) {
647            return Err(format!(
648                "session A chat must not contain B marker {}; chat={}",
649                opts.marker_b,
650                truncate(&out_a.chat_text, 200)
651            ));
652        }
653    }
654    if !out_b.timed_out {
655        let has_b = out_b.chat_text.contains(&opts.marker_b)
656            || out_b
657                .events
658                .iter()
659                .any(|e| event_text(e).contains(&opts.marker_b));
660        if !has_b {
661            return Err(format!(
662                "session B missing marker {}; chat={}",
663                opts.marker_b,
664                truncate(&out_b.chat_text, 200)
665            ));
666        }
667        if out_b.chat_text.contains(&opts.marker_a) {
668            return Err(format!(
669                "session B chat must not contain A marker {}; chat={}",
670                opts.marker_a,
671                truncate(&out_b.chat_text, 200)
672            ));
673        }
674    }
675
676    let load_a_ok = if opts.verify_explicit_load {
677        // Brief settle after cancel so the agent releases the live attachment.
678        tokio::time::sleep(Duration::from_millis(500)).await;
679        let known = GrokSessionId::new(id_a.clone());
680        let load_cfg = GrokSessionLoadConfig {
681            cwd: Some(opts.project.display().to_string()),
682        };
683        match server.sessions.begin_load(known, load_cfg) {
684            Err(e) => {
685                eprintln!("live-grok-multi: session/load begin failed: {e}");
686                Some(false)
687            }
688            Ok(pending) => {
689                match tokio::time::timeout(opts.base.connect_timeout, pending.wait()).await {
690                    Err(_) => {
691                        eprintln!("live-grok-multi: session/load timed out");
692                        Some(false)
693                    }
694                    Ok(Err(e)) => {
695                        // Live agent may reject load of a just-completed short session
696                        // (Invalid params). Concurrent new + isolation remains the
697                        // primary multi-session proof; record load honestly.
698                        eprintln!("live-grok-multi: session/load failed: {e}");
699                        Some(false)
700                    }
701                    Ok(Ok(loaded)) => {
702                        let loaded_id = loaded.session_id.as_str().to_string();
703                        loaded
704                            .control
705                            .cancel(monoloop_connector_grok::CancellationReason::CallerRequested);
706                        let ok = loaded_id == id_a;
707                        println!("live-grok-multi: explicit load of A → {loaded_id} (ok={ok})");
708                        Some(ok)
709                    }
710                }
711            }
712        }
713    } else {
714        None
715    };
716
717    let summary_path = PathBuf::from(format!("{}.summary.txt", opts.base.artifact_stem.display()));
718    let mut summary = String::new();
719    summary.push_str("=== LIVE GROK MULTI-SESSION QUALIFICATION ===\n");
720    summary.push_str(&format!("port={port}\n"));
721    summary.push_str(&format!(
722        "session_a id={} timed_out={} prompt_result={}\n",
723        out_a.session_id, out_a.timed_out, out_a.prompt_result
724    ));
725    summary.push_str(&format!(
726        "session_b id={} timed_out={} prompt_result={}\n",
727        out_b.session_id, out_b.timed_out, out_b.prompt_result
728    ));
729    summary.push_str(&format!("load_a_ok={load_a_ok:?}\n"));
730    summary.push_str(&format!(
731        "distinct_ids={}\n",
732        out_a.session_id != out_b.session_id
733    ));
734    std::fs::write(&summary_path, &summary).map_err(|e| format!("write summary: {e}"))?;
735    let _ = dump.snapshot(); // keep raw collector live through prompts
736
737    Ok(LiveGrokMultiSessionReport {
738        port,
739        session_a: out_a,
740        session_b: out_b,
741        load_a_ok,
742        summary_path,
743    })
744}
745
746async fn prompt_one_session(
747    session: monoloop_connector_grok::GrokSessionHandle,
748    prompt: String,
749    ceiling: Option<Duration>,
750    render_console: bool,
751) -> Result<LiveGrokSessionOutcome, String> {
752    let session_id = session.session_id.as_str().to_string();
753    let factory = DefaultInterpreterFactory::new();
754    let interp = factory
755        .start(StartInterpretation {
756            interpretation_id: InterpretationId::generate(),
757            connection_id: session.connection_id.clone(),
758            external_session_id: Some(session.session_id.clone().into()),
759            dialect: DialectBinding::negotiated(DialectDescriptor::acp_json_rpc("1")),
760            limits: InterpretationLimits::default(),
761        })
762        .map_err(|e| format!("start interpretation {session_id}: {e}"))?;
763
764    let sink = Arc::new(SyncMemorySink::new());
765    let renderer = ConsoleRenderer::new(
766        ConsoleRendererConfig {
767            show_tool_payloads: false,
768            max_content_chars: 500,
769            ..Default::default()
770        },
771        sink.clone() as Arc<dyn ConsoleSink>,
772    );
773
774    let output = Arc::clone(&session.output);
775    let input = interp.input.clone();
776    let drain = tokio::spawn(async move {
777        loop {
778            match output.receive().await {
779                Ok(Some(bytes)) => {
780                    if input.push_bytes(bytes).await.is_err() {
781                        break;
782                    }
783                }
784                Ok(None) => break,
785                Err(_) => break,
786            }
787        }
788    });
789
790    println!("live-grok-multi: prompt sessionId={session_id}…");
791    let exchange = session
792        .input
793        .begin_send(EncodedAcpSessionMessage {
794            method: "session/prompt".into(),
795            params: serde_json::json!({
796                "prompt": [ { "type": "text", "text": prompt } ]
797            }),
798        })
799        .map_err(|e| format!("begin_send {session_id}: {e}"))?;
800
801    let (prompt_result, timed_out) = match ceiling {
802        Some(ceiling) => match tokio::time::timeout(ceiling, exchange.wait()).await {
803            Ok(Ok(v)) => (format!("{v}"), false),
804            Ok(Err(e)) => (format!("error:{e}"), false),
805            Err(_) => ("timeout".into(), true),
806        },
807        None => match exchange.wait().await {
808            Ok(v) => (format!("{v}"), false),
809            Err(e) => (format!("error:{e}"), false),
810        },
811    };
812
813    tokio::time::sleep(Duration::from_millis(400)).await;
814    session
815        .control
816        .cancel(monoloop_connector_grok::CancellationReason::CallerRequested);
817    let _ = tokio::time::timeout(Duration::from_secs(3), drain).await;
818    let _ = interp.input.finish_clean().await;
819
820    let mut events = Vec::new();
821    loop {
822        match tokio::time::timeout(Duration::from_secs(2), interp.events.recv()).await {
823            Ok(Some(ev)) => {
824                if render_console {
825                    renderer.render(&ev);
826                }
827                let done = matches!(ev, InterpreterOutputEvent::Ended(_));
828                events.push(ev);
829                if done {
830                    break;
831                }
832            }
833            Ok(None) => break,
834            Err(_) => break,
835        }
836    }
837
838    let html = build_html_report(
839        &events,
840        &HtmlReportParams {
841            title: format!("multi-session {session_id}"),
842            show_tool_payloads: false,
843            ..Default::default()
844        },
845    );
846
847    Ok(LiveGrokSessionOutcome {
848        session_id,
849        prompt_result,
850        timed_out,
851        events,
852        chat_text: html.chat_projection.plain_text,
853    })
854}
855
856fn event_text(ev: &InterpreterOutputEvent) -> String {
857    match ev {
858        InterpreterOutputEvent::Unit(u) => match &u.snapshot().unit {
859            CanonicalUnit::Text(t) => t.content.clone(),
860            _ => String::new(),
861        },
862        _ => String::new(),
863    }
864}