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_agent::events::AgentEvent;
22use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason};
23use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
24use rpi_harness::events::{HarnessEvent, RunEndOutcome};
25
26use crate::args::Args;
27
28/// Extract the concatenated text content from an assistant message. Mirrors the
29/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
30pub fn assistant_text(msg: &AssistantMessage) -> String {
31    msg.content
32        .iter()
33        .filter_map(|c| match c {
34            Content::Text(t) => Some(t.text.clone()),
35            _ => None,
36        })
37        .collect()
38}
39
40/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
41/// ⇒ exit 1; everything else ⇒ 0.
42pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
43    match outcome {
44        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
45        _ => 0,
46    }
47}
48
49/// `print` mode: send the initial message (prompt text + inline `@file`
50/// expansions), then any follow-up messages, print the final assistant text,
51/// return the exit code. Mirrors TS `runPrintMode` (text).
52pub async fn print(
53    harness: &AgentHarness,
54    _args: &Args,
55    initial: Option<String>,
56    extra_messages: &[String],
57    initial_images: Vec<ImageContent>,
58) -> i32 {
59    let lane: Arc<dyn AgentLane> = harness.lane("main");
60
61    let mut last_exit = 0;
62    let mut last_msg: Option<AssistantMessage> = None;
63
64    // The initial prompt (and its `@file` attachments) go in one user message;
65    // extra positionals are separate prompts (mirrors the TS loop).
66    let mut prompts: Vec<String> = Vec::new();
67    if let Some(init) = initial {
68        prompts.push(init);
69    }
70    for m in extra_messages {
71        prompts.push(m.clone());
72    }
73
74    if prompts.is_empty() {
75        // Nothing to do — print mode with no prompt is a no-op success.
76        return 0;
77    }
78
79    let mut images = initial_images;
80    for prompt in prompts {
81        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
82            Ok(result) => {
83                last_exit = outcome_exit_code(&result.outcome);
84                match &result.outcome {
85                    HarnessRunOutcome::Completed { final_message, .. }
86                    | HarnessRunOutcome::Aborted { final_message, .. } => {
87                        last_msg = Some(final_message.clone());
88                    }
89                    HarnessRunOutcome::Failed {
90                        error,
91                        final_message,
92                        ..
93                    } => {
94                        if let Some(m) = final_message {
95                            if m.stop_reason == StopReason::Error {
96                                if let Some(em) = &m.error_message {
97                                    eprintln!("{em}");
98                                }
99                            }
100                        }
101                        eprintln!("run failed: {error:?}");
102                    }
103                    HarnessRunOutcome::Suspended { .. } => {
104                        eprintln!("run suspended (deferred) — resume is not supported in v1");
105                        last_exit = 1;
106                    }
107                }
108            }
109            Err(e) => {
110                eprintln!("prompt rejected: {e}");
111                return 1;
112            }
113        }
114    }
115
116    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
117    if let Some(m) = &last_msg {
118        match m.stop_reason {
119            StopReason::Error => {
120                if let Some(em) = &m.error_message {
121                    eprintln!("{em}");
122                }
123                last_exit = 1;
124            }
125            StopReason::Aborted => {
126                eprintln!("request aborted");
127                last_exit = 1;
128            }
129            _ => {
130                let text = assistant_text(m);
131                let mut out = std::io::stdout();
132                let _ = out.write_all(text.as_bytes());
133                if !text.ends_with('\n') {
134                    let _ = out.write_all(b"\n");
135                }
136                let _ = out.flush();
137            }
138        }
139    }
140
141    last_exit
142}
143
144/// `json` mode: emit each harness event as a JSON line on stdout, run the
145/// prompts, then emit a terminal `result` line carrying the outcome + final
146/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
147pub async fn json(
148    harness: &AgentHarness,
149    _args: &Args,
150    initial: Option<String>,
151    extra_messages: &[String],
152    initial_images: Vec<ImageContent>,
153    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
154) -> i32 {
155    let lane: Arc<dyn AgentLane> = harness.lane("main");
156    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
157    let collected_for_watch = collected.clone();
158
159    // A watch captures every event (RunStart fires inline during prompt_text,
160    // before a post-call listener could attach — same reason as the M5g test).
161    let mut watch = harness.events().watch(|| ());
162    watch.start(Arc::new(move |event: &HarnessEvent| {
163        // Emit each event live as JSON, and also buffer for the final summary.
164        emit_json_event(event);
165        collected_for_watch.lock().unwrap().push(event.clone());
166    }));
167    // Keep the watch alive for the whole run. Leaking is acceptable for a
168    // single-shot CLI process (the bus outlives this scope anyway).
169    std::mem::forget(watch);
170
171    // The harness bus carries run lifecycle events; the agent receiver carries
172    // the native fine-grained stream (turns, message deltas, and tools).
173    let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
174    let agent_event_task = agent_events.take().map(|mut rx| {
175        tokio::spawn(async move {
176            let done_tx = agent_done_tx;
177            loop {
178                match rx.recv().await {
179                    Ok(event) => {
180                        let terminal = event.is_terminal();
181                        emit_agent_event(&event);
182                        if terminal {
183                            let _ = done_tx.send(());
184                        }
185                    }
186                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
187                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
188                }
189            }
190        })
191    });
192
193    let mut prompts: Vec<String> = Vec::new();
194    if let Some(init) = initial {
195        prompts.push(init);
196    }
197    for m in extra_messages {
198        prompts.push(m.clone());
199    }
200
201    let mut last_exit = 0;
202    let mut final_outcome: Option<HarnessRunOutcome> = None;
203
204    let mut images = initial_images;
205    for prompt in prompts {
206        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
207            Ok(result) => {
208                // The harness resolves after its run outcome, while the
209                // broadcast listener may still be scheduling the terminal
210                // AgentEnd line. Wait briefly so JSON consumers see the full
211                // lifecycle before the final result summary. The timeout is
212                // deliberately bounded for custom/older harness emitters.
213                let _ = tokio::time::timeout(
214                    std::time::Duration::from_millis(250),
215                    agent_done_rx.recv(),
216                )
217                .await;
218                last_exit = outcome_exit_code(&result.outcome);
219                final_outcome = Some(result.outcome);
220            }
221            Err(e) => {
222                // Emit a structured error line + exit.
223                let line = serde_json::json!({
224                    "type": "error",
225                    "error": e.to_string(),
226                });
227                println!("{line}");
228                if let Some(task) = agent_event_task {
229                    task.abort();
230                }
231                return 1;
232            }
233        }
234    }
235
236    // Terminal result summary.
237    let (outcome_str, final_text) = match final_outcome {
238        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
239            ("completed", Some(assistant_text(&final_message)))
240        }
241        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
242            ("aborted", Some(assistant_text(&final_message)))
243        }
244        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
245            let t = final_message.as_ref().map(assistant_text);
246            ("failed", t)
247        }
248        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
249        None => ("idle", None),
250    };
251    let result_line = serde_json::json!({
252        "type": "result",
253        "outcome": outcome_str,
254        "finalText": final_text,
255    });
256    println!("{result_line}");
257    if let Some(task) = agent_event_task {
258        task.abort();
259    }
260    last_exit
261}
262
263fn emit_agent_event(event: &AgentEvent) {
264    println!("{}", agent_event_json(event));
265}
266
267/// Stable JSON projection for the fine-grained agent lifecycle stream.
268/// Complex payloads use their serde representation instead of being dropped,
269/// while convenience fields keep the stream easy to consume incrementally.
270fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
271    use rpi_ai::types::AssistantMessageEvent;
272    match event {
273        AgentEvent::AgentStart => serde_json::json!({"type":"agent_start"}),
274        AgentEvent::AgentEnd { messages } => serde_json::json!({
275            "type":"agent_end", "messageCount": messages.len(),
276            "messages": serde_json::to_value(messages).unwrap_or(serde_json::Value::Null)
277        }),
278        AgentEvent::RetryScheduled {
279            attempt,
280            max_retries,
281            delay_ms,
282            error,
283        } => serde_json::json!({
284            "type":"retry_scheduled", "attempt":attempt,
285            "maxRetries":max_retries, "delayMs":delay_ms, "error":error
286        }),
287        AgentEvent::TurnStart => serde_json::json!({"type":"turn_start"}),
288        AgentEvent::TurnEnd {
289            message,
290            tool_results,
291        } => serde_json::json!({
292            "type":"turn_end", "message": serde_json::to_value(message).ok(),
293            "toolResultCount": tool_results.len(),
294            "toolResults": serde_json::to_value(tool_results).unwrap_or(serde_json::Value::Null)
295        }),
296        AgentEvent::MessageStart { message } => serde_json::json!({
297            "type":"message_start", "message": serde_json::to_value(message).ok()
298        }),
299        AgentEvent::MessageEnd { message } => serde_json::json!({
300            "type":"message_end", "message": serde_json::to_value(message).ok()
301        }),
302        AgentEvent::MessageUpdate {
303            message,
304            assistant_message_event,
305        } => {
306            let mut value = serde_json::json!({
307                "type":"message_update",
308                "message": serde_json::to_value(message).unwrap_or(serde_json::Value::Null),
309                "assistantMessageEvent": serde_json::to_value(assistant_message_event)
310                    .unwrap_or(serde_json::Value::Null),
311                "eventType": assistant_message_event.type_tag(),
312            });
313            let object = value.as_object_mut().expect("json object");
314            match assistant_message_event {
315                AssistantMessageEvent::TextDelta {
316                    content_index,
317                    delta,
318                    ..
319                }
320                | AssistantMessageEvent::ThinkingDelta {
321                    content_index,
322                    delta,
323                    ..
324                }
325                | AssistantMessageEvent::ToolCallDelta {
326                    content_index,
327                    delta,
328                    ..
329                } => {
330                    object.insert("contentIndex".into(), (*content_index).into());
331                    object.insert("delta".into(), delta.clone().into());
332                }
333                _ => {}
334            }
335            value
336        }
337        AgentEvent::ToolExecutionStart {
338            tool_call_id,
339            tool_name,
340            args,
341        } => serde_json::json!({
342            "type":"tool_execution_start", "toolCallId":tool_call_id,
343            "toolName":tool_name, "args":args
344        }),
345        AgentEvent::ToolExecutionUpdate {
346            tool_call_id,
347            tool_name,
348            args,
349            partial_result,
350        } => serde_json::json!({
351            "type":"tool_execution_update", "toolCallId":tool_call_id, "toolName":tool_name,
352            "args": args,
353            "partialResult": tool_result_json(partial_result)
354        }),
355        AgentEvent::ToolExecutionEnd {
356            tool_call_id,
357            tool_name,
358            result,
359            is_error,
360        } => serde_json::json!({
361            "type":"tool_execution_end", "toolCallId":tool_call_id,
362            "toolName":tool_name, "isError":is_error,
363            "result": tool_result_json(result)
364        }),
365    }
366}
367
368fn tool_result_json(result: &rpi_agent::types::AgentToolResult) -> serde_json::Value {
369    let content: Vec<serde_json::Value> = result
370        .content
371        .iter()
372        .map(|item| match item {
373            rpi_agent::types::TextContentOrImage::Text(text) => serde_json::json!({
374                "type": "text",
375                "text": text.text,
376            }),
377            rpi_agent::types::TextContentOrImage::Image(image) => {
378                serde_json::to_value(image).unwrap_or(serde_json::Value::Null)
379            }
380        })
381        .collect();
382    serde_json::json!({
383        "content": content,
384        "details": result.details,
385        "usage": result.usage.as_ref().and_then(|usage| serde_json::to_value(usage).ok()),
386        "addedToolNames": result.added_tool_names,
387        "terminate": result.terminate,
388    })
389}
390
391/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
392/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
393/// payload's key fields).
394fn emit_json_event(event: &HarnessEvent) {
395    let line = match event {
396        HarnessEvent::RunStart(e) => serde_json::json!({
397            "type": "run_start",
398            "lane": e.lane,
399            "runId": e.run_id,
400        }),
401        HarnessEvent::RunEnd(e) => serde_json::json!({
402            "type": "run_end",
403            "lane": e.lane,
404            "runId": e.run_id,
405            "outcome": run_end_outcome_str(e.outcome),
406            "leafId": e.leaf_id,
407        }),
408    };
409    println!("{line}");
410}
411
412fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
413    match o {
414        RunEndOutcome::Completed => "completed",
415        RunEndOutcome::Aborted => "aborted",
416        RunEndOutcome::Failed => "failed",
417    }
418}
419
420/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
421///
422/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
423/// render streaming responses). The REPL fallback ignores it.
424///
425/// `model_catalog` is the resolved provider's full model list, passed through
426/// so the TUI's `/model` selector can display available models (read-only —
427/// v1 does not switch models mid-session; see `docs/m6-cli-open-questions.md`).
428pub async fn interactive(
429    harness: &AgentHarness,
430    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
431    args: &Args,
432    model_catalog: Vec<rpi_ai::Model>,
433    initial: Option<String>,
434    extra_messages: &[String],
435    initial_images: Vec<ImageContent>,
436    theme: Option<&str>,
437    no_themes: bool,
438    reload_context: &crate::session::ReloadContext,
439) -> i32 {
440    // Check if TUI is supported
441    let force_tui = std::env::var("RPI_FORCE_TUI")
442        .map(|v| v == "1")
443        .unwrap_or(false);
444    if force_tui || crate::interactive_tui::is_tui_supported() {
445        // Use TUI-based interactive mode
446        crate::interactive_tui::interactive_tui(
447            harness,
448            event_rx,
449            args,
450            model_catalog,
451            initial,
452            extra_messages,
453            initial_images,
454            theme,
455            no_themes,
456            reload_context,
457        )
458        .await
459    } else {
460        // Fall back to simple REPL
461        interactive_repl(harness, args, initial, extra_messages, initial_images).await
462    }
463}
464
465/// Simple REPL-based interactive mode (fallback for non-TTY environments).
466pub async fn interactive_repl(
467    harness: &AgentHarness,
468    #[allow(unused_variables)] args: &Args,
469    initial: Option<String>,
470    extra_messages: &[String],
471    initial_images: Vec<ImageContent>,
472) -> i32 {
473    // Debug: confirm we entered REPL mode
474    let lane: Arc<dyn AgentLane> = harness.lane("main");
475    let stdin = std::io::stdin();
476    let is_tty = stdin.is_terminal();
477
478    if is_tty {
479        println!(
480            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
481        );
482    }
483
484    // Run the initial prompt + extra messages first (same as print mode).
485    let mut prompts: Vec<String> = Vec::new();
486    if let Some(init) = initial {
487        prompts.push(init);
488    }
489    for m in extra_messages {
490        prompts.push(m.clone());
491    }
492    let mut images = initial_images;
493    for prompt in prompts {
494        if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
495            return code;
496        }
497    }
498
499    // Then read lines from stdin until EOF / `/exit`.
500    let mut line = String::new();
501    loop {
502        if is_tty {
503            print!("> ");
504            let _ = std::io::stdout().flush();
505        }
506        line.clear();
507        match stdin.lock().read_line(&mut line) {
508            Ok(0) => break, // EOF
509            Ok(_) => {}
510            Err(_) => break,
511        }
512        let trimmed = line.trim();
513        if trimmed.is_empty() {
514            continue;
515        }
516        if trimmed == "/exit" || trimmed == "/quit" {
517            break;
518        }
519        if trimmed == "/abort" {
520            let _ = lane.abort().await;
521            eprintln!("(aborted)");
522            continue;
523        }
524        if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
525            return code;
526        }
527    }
528    0
529}
530
531/// Run a single prompt in interactive mode, printing the assistant reply (or
532/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
533/// hard rejection.
534async fn run_one(
535    lane: &Arc<dyn AgentLane>,
536    prompt: &str,
537    images: Vec<ImageContent>,
538) -> Result<(), i32> {
539    match lane.prompt_text(prompt, images).await {
540        Ok(result) => {
541            match &result.outcome {
542                HarnessRunOutcome::Completed { final_message, .. }
543                | HarnessRunOutcome::Aborted { final_message, .. } => {
544                    let text = assistant_text(final_message);
545                    if !text.is_empty() {
546                        println!("{text}");
547                    }
548                }
549                HarnessRunOutcome::Failed {
550                    error,
551                    final_message,
552                    ..
553                } => {
554                    if let Some(m) = final_message {
555                        if let Some(em) = &m.error_message {
556                            eprintln!("error: {em}");
557                        }
558                    }
559                    eprintln!("run failed: {error:?}");
560                }
561                HarnessRunOutcome::Suspended { .. } => {
562                    eprintln!("run suspended (deferred) — resume not supported in v1");
563                }
564            }
565            Ok(())
566        }
567        Err(e) => {
568            eprintln!("prompt rejected: {e}");
569            Err(1)
570        }
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use rpi_agent::events::AgentEvent;
578    use rpi_agent::types::AgentToolResult;
579    use rpi_ai::types::{
580        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
581    };
582    use rpi_harness::session::types::OperationError;
583
584    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
585        AssistantMessage {
586            role: rpi_ai::types::AssistantRole,
587            content: vec![Content::Text(TextContent {
588                kind: TextContentType,
589                text: text.into(),
590                text_signature: None,
591            })],
592            api: rpi_ai::Api::AnthropicMessages,
593            provider: "anthropic".into(),
594            model: "claude-sonnet-5".into(),
595            response_model: None,
596            response_id: None,
597            usage: Usage::zero(),
598            stop_reason: stop,
599            deferred: None,
600            error_message: None,
601            raw_stop_reason: None,
602            end_turn: None,
603            timestamp: 0,
604        }
605    }
606
607    #[test]
608    fn assistant_text_concatenates_text_blocks() {
609        let m = assistant("hello", StopReason::Stop);
610        assert_eq!(assistant_text(&m), "hello");
611    }
612
613    #[test]
614    fn outcome_exit_code_maps_failed_aborted_to_1() {
615        let failed = HarnessRunOutcome::Failed {
616            leaf_id: "l".into(),
617            error: OperationError {
618                code: "boom".into(),
619                message: "boom".into(),
620            },
621            final_entry_id: None,
622            final_message: None,
623        };
624        assert_eq!(outcome_exit_code(&failed), 1);
625        let completed = HarnessRunOutcome::Completed {
626            leaf_id: "l".into(),
627            final_entry_id: "e".into(),
628            final_message: assistant("ok", StopReason::Stop),
629        };
630        assert_eq!(outcome_exit_code(&completed), 0);
631    }
632
633    #[test]
634    fn run_end_outcome_str_roundtrip() {
635        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
636        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
637        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
638    }
639
640    #[test]
641    fn agent_event_projection_keeps_terminal_and_tool_payloads() {
642        let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
643        assert_eq!(end["type"], "agent_end");
644        assert_eq!(end["messages"], serde_json::json!([]));
645
646        let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
647            tool_call_id: "call-1".into(),
648            tool_name: "read".into(),
649            result: AgentToolResult::text("hello"),
650            is_error: false,
651        });
652        assert_eq!(tool["type"], "tool_execution_end");
653        assert_eq!(tool["result"]["content"][0]["text"], "hello");
654        assert_eq!(tool["result"]["terminate"], false);
655
656        let retry = agent_event_json(&AgentEvent::RetryScheduled {
657            attempt: 3,
658            max_retries: 10,
659            delay_ms: 8_000,
660            error: "503 service unavailable".into(),
661        });
662        assert_eq!(retry["type"], "retry_scheduled");
663        assert_eq!(retry["attempt"], 3);
664        assert_eq!(retry["maxRetries"], 10);
665        assert_eq!(retry["delayMs"], 8_000);
666        assert_eq!(retry["error"], "503 service unavailable");
667    }
668}