Skip to main content

rpi_cli/
modes.rs

1//! Output modes. Mirrors the v1-relevant slice of the TS
2//! `packages/coding-agent/src/modes/{print-mode,json-event,rpc-mode}.ts` — the
3//! three run shapes a harness-backed CLI needs:
4//!
5//! - [`print`] — single-shot: send the prompt(s), print the final assistant
6//!   text (or the error) to stdout, exit. Mirrors TS `runPrintMode` (text mode).
7//! - [`json`] — single-shot streaming: emit each harness event as a JSON line
8//!   on stdout, then the final outcome. Mirrors TS `runPrintMode`
9//!   (`mode === "json"`) + [`json_event::toJsonEvent`].
10//! - [`interactive`] — a minimal line-oriented REPL: read prompts from stdin,
11//!   run each, print the assistant text, loop until EOF / `/exit`. v1 does NOT
12//!   port the TS `InteractiveMode` TUI (`modes/interactive/*` — a full terminal
13//!   UI with Ink/React components); this is a deliberately minimal replacement,
14//!   documented in `docs/m6-cli-open-questions.md`.
15//!
16//! All three drive the same `AgentHarness` via `AgentLane::prompt_text`.
17
18use std::io::{BufRead, IsTerminal, Write};
19use std::sync::{Arc, Mutex};
20
21use rpi_ai::types::{AssistantMessage, Content, StopReason};
22use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
23use rpi_harness::events::{HarnessEvent, RunEndOutcome};
24
25use crate::args::Args;
26
27/// Extract the concatenated text content from an assistant message. Mirrors the
28/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
29pub fn assistant_text(msg: &AssistantMessage) -> String {
30    msg.content
31        .iter()
32        .filter_map(|c| match c {
33            Content::Text(t) => Some(t.text.clone()),
34            _ => None,
35        })
36        .collect()
37}
38
39/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
40/// ⇒ exit 1; everything else ⇒ 0.
41pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
42    match outcome {
43        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
44        _ => 0,
45    }
46}
47
48/// `print` mode: send the initial message (prompt text + inline `@file`
49/// expansions), then any follow-up messages, print the final assistant text,
50/// return the exit code. Mirrors TS `runPrintMode` (text).
51pub async fn print(
52    harness: &AgentHarness,
53    _args: &Args,
54    initial: Option<String>,
55    extra_messages: &[String],
56) -> i32 {
57    let lane: Arc<dyn AgentLane> = harness.lane("main");
58
59    let mut last_exit = 0;
60    let mut last_msg: Option<AssistantMessage> = None;
61
62    // The initial prompt (and its `@file` attachments) go in one user message;
63    // extra positionals are separate prompts (mirrors the TS loop).
64    let mut prompts: Vec<String> = Vec::new();
65    if let Some(init) = initial {
66        prompts.push(init);
67    }
68    for m in extra_messages {
69        prompts.push(m.clone());
70    }
71
72    if prompts.is_empty() {
73        // Nothing to do — print mode with no prompt is a no-op success.
74        return 0;
75    }
76
77    for prompt in prompts {
78        match lane.prompt_text(&prompt, Vec::new()).await {
79            Ok(result) => {
80                last_exit = outcome_exit_code(&result.outcome);
81                match &result.outcome {
82                    HarnessRunOutcome::Completed { final_message, .. }
83                    | HarnessRunOutcome::Aborted { final_message, .. } => {
84                        last_msg = Some(final_message.clone());
85                    }
86                    HarnessRunOutcome::Failed {
87                        error,
88                        final_message,
89                        ..
90                    } => {
91                        if let Some(m) = final_message {
92                            if m.stop_reason == StopReason::Error {
93                                if let Some(em) = &m.error_message {
94                                    eprintln!("{em}");
95                                }
96                            }
97                        }
98                        eprintln!("run failed: {error:?}");
99                    }
100                    HarnessRunOutcome::Suspended { .. } => {
101                        eprintln!("run suspended (deferred) — resume is not supported in v1");
102                        last_exit = 1;
103                    }
104                }
105            }
106            Err(e) => {
107                eprintln!("prompt rejected: {e}");
108                return 1;
109            }
110        }
111    }
112
113    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
114    if let Some(m) = &last_msg {
115        match m.stop_reason {
116            StopReason::Error => {
117                if let Some(em) = &m.error_message {
118                    eprintln!("{em}");
119                }
120                last_exit = 1;
121            }
122            StopReason::Aborted => {
123                eprintln!("request aborted");
124                last_exit = 1;
125            }
126            _ => {
127                let text = assistant_text(m);
128                let mut out = std::io::stdout();
129                let _ = out.write_all(text.as_bytes());
130                if !text.ends_with('\n') {
131                    let _ = out.write_all(b"\n");
132                }
133                let _ = out.flush();
134            }
135        }
136    }
137
138    last_exit
139}
140
141/// `json` mode: emit each harness event as a JSON line on stdout, run the
142/// prompts, then emit a terminal `result` line carrying the outcome + final
143/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
144pub async fn json(
145    harness: &AgentHarness,
146    _args: &Args,
147    initial: Option<String>,
148    extra_messages: &[String],
149) -> i32 {
150    let lane: Arc<dyn AgentLane> = harness.lane("main");
151    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
152    let collected_for_watch = collected.clone();
153
154    // A watch captures every event (RunStart fires inline during prompt_text,
155    // before a post-call listener could attach — same reason as the M5g test).
156    let mut watch = harness.events().watch(|| ());
157    watch.start(Arc::new(move |event: &HarnessEvent| {
158        // Emit each event live as JSON, and also buffer for the final summary.
159        emit_json_event(event);
160        collected_for_watch.lock().unwrap().push(event.clone());
161    }));
162    // Keep the watch alive for the whole run. Leaking is acceptable for a
163    // single-shot CLI process (the bus outlives this scope anyway).
164    std::mem::forget(watch);
165
166    let mut prompts: Vec<String> = Vec::new();
167    if let Some(init) = initial {
168        prompts.push(init);
169    }
170    for m in extra_messages {
171        prompts.push(m.clone());
172    }
173
174    let mut last_exit = 0;
175    let mut final_outcome: Option<HarnessRunOutcome> = None;
176
177    for prompt in prompts {
178        match lane.prompt_text(&prompt, Vec::new()).await {
179            Ok(result) => {
180                last_exit = outcome_exit_code(&result.outcome);
181                final_outcome = Some(result.outcome);
182            }
183            Err(e) => {
184                // Emit a structured error line + exit.
185                let line = serde_json::json!({
186                    "type": "error",
187                    "error": e.to_string(),
188                });
189                println!("{line}");
190                return 1;
191            }
192        }
193    }
194
195    // Terminal result summary.
196    let (outcome_str, final_text) = match final_outcome {
197        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
198            ("completed", Some(assistant_text(&final_message)))
199        }
200        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
201            ("aborted", Some(assistant_text(&final_message)))
202        }
203        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
204            let t = final_message.as_ref().map(assistant_text);
205            ("failed", t)
206        }
207        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
208        None => ("idle", None),
209    };
210    let result_line = serde_json::json!({
211        "type": "result",
212        "outcome": outcome_str,
213        "finalText": final_text,
214    });
215    println!("{result_line}");
216    last_exit
217}
218
219/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
220/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
221/// payload's key fields).
222fn emit_json_event(event: &HarnessEvent) {
223    let line = match event {
224        HarnessEvent::RunStart(e) => serde_json::json!({
225            "type": "run_start",
226            "lane": e.lane,
227            "runId": e.run_id,
228        }),
229        HarnessEvent::RunEnd(e) => serde_json::json!({
230            "type": "run_end",
231            "lane": e.lane,
232            "runId": e.run_id,
233            "outcome": run_end_outcome_str(e.outcome),
234            "leafId": e.leaf_id,
235        }),
236    };
237    println!("{line}");
238}
239
240fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
241    match o {
242        RunEndOutcome::Completed => "completed",
243        RunEndOutcome::Aborted => "aborted",
244        RunEndOutcome::Failed => "failed",
245    }
246}
247
248/// `interactive` mode: a minimal line-oriented REPL. NOT a port of the TS TUI.
249pub async fn interactive(
250    harness: &AgentHarness,
251    #[allow(unused_variables)] args: &Args,
252    initial: Option<String>,
253    extra_messages: &[String],
254) -> i32 {
255    let lane: Arc<dyn AgentLane> = harness.lane("main");
256    let stdin = std::io::stdin();
257    let is_tty = stdin.is_terminal();
258
259    if is_tty {
260        println!("rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n");
261    }
262
263    // Run the initial prompt + extra messages first (same as print mode).
264    let mut prompts: Vec<String> = Vec::new();
265    if let Some(init) = initial {
266        prompts.push(init);
267    }
268    for m in extra_messages {
269        prompts.push(m.clone());
270    }
271    for prompt in prompts {
272        if let Err(code) = run_one(&lane, &prompt).await {
273            return code;
274        }
275    }
276
277    // Then read lines from stdin until EOF / `/exit`.
278    let mut line = String::new();
279    loop {
280        if is_tty {
281            print!("> ");
282            let _ = std::io::stdout().flush();
283        }
284        line.clear();
285        match stdin.lock().read_line(&mut line) {
286            Ok(0) => break, // EOF
287            Ok(_) => {}
288            Err(_) => break,
289        }
290        let trimmed = line.trim();
291        if trimmed.is_empty() {
292            continue;
293        }
294        if trimmed == "/exit" || trimmed == "/quit" {
295            break;
296        }
297        if trimmed == "/abort" {
298            let _ = lane.abort().await;
299            eprintln!("(aborted)");
300            continue;
301        }
302        if let Err(code) = run_one(&lane, trimmed).await {
303            return code;
304        }
305    }
306    0
307}
308
309/// Run a single prompt in interactive mode, printing the assistant reply (or
310/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
311/// hard rejection.
312async fn run_one(lane: &Arc<dyn AgentLane>, prompt: &str) -> Result<(), i32> {
313    match lane.prompt_text(prompt, Vec::new()).await {
314        Ok(result) => {
315            match &result.outcome {
316                HarnessRunOutcome::Completed { final_message, .. }
317                | HarnessRunOutcome::Aborted { final_message, .. } => {
318                    let text = assistant_text(final_message);
319                    if !text.is_empty() {
320                        println!("{text}");
321                    }
322                }
323                HarnessRunOutcome::Failed { error, final_message, .. } => {
324                    if let Some(m) = final_message {
325                        if let Some(em) = &m.error_message {
326                            eprintln!("error: {em}");
327                        }
328                    }
329                    eprintln!("run failed: {error:?}");
330                }
331                HarnessRunOutcome::Suspended { .. } => {
332                    eprintln!("run suspended (deferred) — resume not supported in v1");
333                }
334            }
335            Ok(())
336        }
337        Err(e) => {
338            eprintln!("prompt rejected: {e}");
339            Err(1)
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use rpi_ai::types::{AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage};
348    use rpi_harness::session::types::OperationError;
349
350    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
351        AssistantMessage {
352            role: rpi_ai::types::AssistantRole,
353            content: vec![Content::Text(TextContent {
354                kind: TextContentType,
355                text: text.into(),
356                text_signature: None,
357            })],
358            api: rpi_ai::Api::AnthropicMessages,
359            provider: "anthropic".into(),
360            model: "claude-sonnet-5".into(),
361            response_model: None,
362            response_id: None,
363            usage: Usage::zero(),
364            stop_reason: stop,
365            deferred: None,
366            error_message: None,
367            raw_stop_reason: None,
368            end_turn: None,
369            timestamp: 0,
370        }
371    }
372
373    #[test]
374    fn assistant_text_concatenates_text_blocks() {
375        let m = assistant("hello", StopReason::Stop);
376        assert_eq!(assistant_text(&m), "hello");
377    }
378
379    #[test]
380    fn outcome_exit_code_maps_failed_aborted_to_1() {
381        let failed = HarnessRunOutcome::Failed {
382            leaf_id: "l".into(),
383            error: OperationError { code: "boom".into(), message: "boom".into() },
384            final_entry_id: None,
385            final_message: None,
386        };
387        assert_eq!(outcome_exit_code(&failed), 1);
388        let completed = HarnessRunOutcome::Completed {
389            leaf_id: "l".into(),
390            final_entry_id: "e".into(),
391            final_message: assistant("ok", StopReason::Stop),
392        };
393        assert_eq!(outcome_exit_code(&completed), 0);
394    }
395
396    #[test]
397    fn run_end_outcome_str_roundtrip() {
398        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
399        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
400        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
401    }
402}