Skip to main content

monoloop_testkit/
live_claude.rs

1//! End-to-end live Claude Code driver (`claude -p --output-format stream-json`).
2//!
3//! **Test kit only.** Requires `claude` installed and authenticated.
4
5use crate::console::{ConsoleRenderer, ConsoleRendererConfig, SyncMemorySink};
6use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
7use monoloop_connector_claude::{run_claude_print, ClaudeAgentConfig};
8use monoloop_contracts::{
9    CanonicalUnit, DialectBinding, DialectDescriptor, ExternalSessionId, InterpretationId,
10    InterpretationLimits, InterpreterOutputEvent,
11};
12use monoloop_interpreter::{DefaultInterpreterFactory, InterpreterFactory, StartInterpretation};
13use std::path::PathBuf;
14use std::time::Duration;
15use tokio::sync::mpsc;
16
17/// Configuration for a single live Claude print run.
18#[derive(Clone, Debug)]
19pub struct LiveClaudeRunOptions {
20    /// Prompt text.
21    pub prompt: String,
22    /// Working directory.
23    pub cwd: PathBuf,
24    /// Process config.
25    pub agent: ClaudeAgentConfig,
26    /// HTML title.
27    pub title: String,
28    /// Artifact stem.
29    pub artifact_stem: PathBuf,
30    /// Render console lines while collecting.
31    pub render_console: bool,
32}
33
34impl LiveClaudeRunOptions {
35    /// Defaults under `target/live_claude_run`.
36    pub fn for_project(project: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
37        let project = project.into();
38        let stem = project.join("target/live_claude_run");
39        let mut agent = ClaudeAgentConfig::for_project(project.clone());
40        agent.raw_dump_path = Some(PathBuf::from(format!("{}.raw.txt", stem.display())));
41        agent.run_deadline = Duration::from_secs(15 * 60);
42        Self {
43            prompt: prompt.into(),
44            cwd: project,
45            agent,
46            title: "Live Claude Code — interpretation review".into(),
47            artifact_stem: stem,
48            render_console: true,
49        }
50    }
51}
52
53/// Artifact paths written by a live run.
54#[derive(Clone, Debug)]
55pub struct LiveClaudeArtifactPaths {
56    /// HTML review.
57    pub html: PathBuf,
58    /// Raw stream-json dump.
59    pub raw: PathBuf,
60    /// Sequence summary.
61    pub sequence: PathBuf,
62    /// Chat projection plain text.
63    pub chat: PathBuf,
64}
65
66/// Report from a managed live Claude run.
67#[derive(Clone, Debug)]
68pub struct LiveClaudeRunReport {
69    /// Claude session id from stream init.
70    pub session_id: String,
71    /// Process exit code.
72    pub exit_code: Option<i32>,
73    /// Interpreter events.
74    pub events: Vec<InterpreterOutputEvent>,
75    /// HTML review.
76    pub html: HtmlReport,
77    /// Console text.
78    pub console_text: String,
79    /// Sequence summary.
80    pub sequence_text: String,
81    /// Paths written.
82    pub paths: LiveClaudeArtifactPaths,
83}
84
85/// Run one prompt against live Claude Code print mode and write review artifacts.
86pub async fn run_live_claude_prompt(
87    opts: LiveClaudeRunOptions,
88) -> Result<LiveClaudeRunReport, String> {
89    if let Some(parent) = opts.artifact_stem.parent() {
90        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
91    }
92
93    let mut agent = opts.agent.clone();
94    agent.cwd = opts.cwd.clone();
95    agent.raw_dump_path = Some(PathBuf::from(format!(
96        "{}.raw.txt",
97        opts.artifact_stem.display()
98    )));
99
100    let (tx, mut updates) = mpsc::channel(256);
101    let run = tokio::spawn({
102        let agent = agent.clone();
103        let prompt = opts.prompt.clone();
104        async move { run_claude_print(&agent, &prompt, tx).await }
105    });
106
107    let dialect = DialectBinding::negotiated(DialectDescriptor::claude_code("1"));
108    let factory = DefaultInterpreterFactory::new();
109    let interp = factory
110        .start(StartInterpretation {
111            interpretation_id: InterpretationId::generate(),
112            connection_id: monoloop_contracts::ConnectionId::new("claude-live"),
113            external_session_id: None,
114            dialect,
115            limits: InterpretationLimits::default(),
116        })
117        .map_err(|e| e.to_string())?;
118
119    let input = interp.input.clone();
120    let pump = tokio::spawn(async move {
121        while let Some(bytes) = updates.recv().await {
122            if input.push_bytes(bytes).await.is_err() {
123                break;
124            }
125        }
126    });
127
128    let outcome = run
129        .await
130        .map_err(|e| e.to_string())?
131        .map_err(|e| e.to_string())?;
132    let _ = pump.await;
133
134    // Attach external session id post-hoc is not needed for interpretation already started;
135    // session id is reported on the outcome for artifacts.
136    let _ = ExternalSessionId::new(outcome.session_id.clone());
137
138    let _ = interp.input.finish_clean().await;
139
140    let mut events = Vec::new();
141    let sink = std::sync::Arc::new(SyncMemorySink::new());
142    let console = ConsoleRenderer::new(ConsoleRendererConfig::default(), sink.clone());
143    loop {
144        match interp.events.recv().await {
145            Some(ev) => {
146                if opts.render_console {
147                    console.render(&ev);
148                }
149                let done = matches!(ev, InterpreterOutputEvent::Ended(_));
150                events.push(ev);
151                if done {
152                    break;
153                }
154            }
155            None => break,
156        }
157    }
158
159    let html = build_html_report(
160        &events,
161        &HtmlReportParams {
162            title: opts.title.clone(),
163            ..HtmlReportParams::default()
164        },
165    );
166    let html_path = PathBuf::from(format!("{}.html", opts.artifact_stem.display()));
167    write_html_report(&html_path, &html).map_err(|e| e.to_string())?;
168
169    let raw_path = PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display()));
170    if !outcome.raw_dump_text.is_empty() {
171        let _ = std::fs::write(&raw_path, &outcome.raw_dump_text);
172    }
173
174    let mut sequence_text = String::from("=== LIVE CLAUDE — CANONICAL TEXT ===\n");
175    for (i, ev) in events.iter().enumerate() {
176        if let InterpreterOutputEvent::Unit(u) = ev {
177            if let CanonicalUnit::Text(t) = &u.snapshot().unit {
178                sequence_text.push_str(&format!("{i:04} | {}\n", t.content));
179            }
180        }
181    }
182    sequence_text.push_str(&format!(
183        "\nsession={} exit={:?} sentences={} strategy={:?} confidence={:?}\n",
184        outcome.session_id,
185        outcome.exit_code,
186        html.sentence_count,
187        html.chat_projection.strategy,
188        html.chat_projection.confidence
189    ));
190    let seq_path = PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display()));
191    let _ = std::fs::write(&seq_path, &sequence_text);
192
193    let chat_path = PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display()));
194    let _ = std::fs::write(&chat_path, &html.chat_projection.plain_text);
195
196    Ok(LiveClaudeRunReport {
197        session_id: outcome.session_id,
198        exit_code: outcome.exit_code,
199        events,
200        html,
201        console_text: sink.join(),
202        sequence_text,
203        paths: LiveClaudeArtifactPaths {
204            html: html_path,
205            raw: raw_path,
206            sequence: seq_path,
207            chat: chat_path,
208        },
209    })
210}