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;
27use crate::remote::protocol::RemoteResponse;
28
29/// Extract the concatenated text content from an assistant message. Mirrors the
30/// TS print-mode loop (`for content of assistantMsg.content if type===text`).
31pub fn assistant_text(msg: &AssistantMessage) -> String {
32    msg.content
33        .iter()
34        .filter_map(|c| match c {
35            Content::Text(t) => Some(t.text.clone()),
36            _ => None,
37        })
38        .collect()
39}
40
41/// The exit code a run's outcome maps to. Mirrors TS print mode: error/aborted
42/// ⇒ exit 1; everything else ⇒ 0.
43pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
44    match outcome {
45        HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
46        _ => 0,
47    }
48}
49
50/// `print` mode: send the initial message (prompt text + inline `@file`
51/// expansions), then any follow-up messages, print the final assistant text,
52/// return the exit code. Mirrors TS `runPrintMode` (text).
53pub async fn print(
54    harness: &AgentHarness,
55    _args: &Args,
56    initial: Option<String>,
57    extra_messages: &[String],
58    initial_images: Vec<ImageContent>,
59) -> i32 {
60    let lane: Arc<dyn AgentLane> = harness.lane("main");
61
62    let mut last_exit = 0;
63    let mut last_msg: Option<AssistantMessage> = None;
64
65    // The initial prompt (and its `@file` attachments) go in one user message;
66    // extra positionals are separate prompts (mirrors the TS loop).
67    let mut prompts: Vec<String> = Vec::new();
68    if let Some(init) = initial {
69        prompts.push(init);
70    }
71    for m in extra_messages {
72        prompts.push(m.clone());
73    }
74
75    if prompts.is_empty() {
76        // Nothing to do — print mode with no prompt is a no-op success.
77        return 0;
78    }
79
80    let mut images = initial_images;
81    for prompt in prompts {
82        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
83            Ok(result) => {
84                last_exit = outcome_exit_code(&result.outcome);
85                match &result.outcome {
86                    HarnessRunOutcome::Completed { final_message, .. }
87                    | HarnessRunOutcome::Aborted { final_message, .. } => {
88                        last_msg = Some(final_message.clone());
89                    }
90                    HarnessRunOutcome::Failed {
91                        error,
92                        final_message,
93                        ..
94                    } => {
95                        if let Some(m) = final_message {
96                            if m.stop_reason == StopReason::Error {
97                                if let Some(em) = &m.error_message {
98                                    eprintln!("{em}");
99                                }
100                            }
101                        }
102                        eprintln!("run failed: {error:?}");
103                    }
104                    HarnessRunOutcome::Suspended { .. } => {
105                        eprintln!("run suspended (deferred) — resume is not supported in v1");
106                        last_exit = 1;
107                    }
108                }
109            }
110            Err(e) => {
111                eprintln!("prompt rejected: {e}");
112                return 1;
113            }
114        }
115    }
116
117    // Print the final assistant text to stdout (TS: writeRawStdout text + "\n").
118    if let Some(m) = &last_msg {
119        match m.stop_reason {
120            StopReason::Error => {
121                if let Some(em) = &m.error_message {
122                    eprintln!("{em}");
123                }
124                last_exit = 1;
125            }
126            StopReason::Aborted => {
127                eprintln!("request aborted");
128                last_exit = 1;
129            }
130            _ => {
131                let text = assistant_text(m);
132                let mut out = std::io::stdout();
133                let _ = out.write_all(text.as_bytes());
134                if !text.ends_with('\n') {
135                    let _ = out.write_all(b"\n");
136                }
137                let _ = out.flush();
138            }
139        }
140    }
141
142    last_exit
143}
144
145/// `json` mode: emit each harness event as a JSON line on stdout, run the
146/// prompts, then emit a terminal `result` line carrying the outcome + final
147/// text. Mirrors TS `runPrintMode` (`mode === "json"`) streaming every event.
148pub async fn json(
149    harness: &AgentHarness,
150    _args: &Args,
151    initial: Option<String>,
152    extra_messages: &[String],
153    initial_images: Vec<ImageContent>,
154    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
155) -> i32 {
156    let lane: Arc<dyn AgentLane> = harness.lane("main");
157    let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
158    let collected_for_watch = collected.clone();
159
160    // A watch captures every event (RunStart fires inline during prompt_text,
161    // before a post-call listener could attach — same reason as the M5g test).
162    let mut watch = harness.events().watch(|| ());
163    watch.start(Arc::new(move |event: &HarnessEvent| {
164        // Emit each event live as JSON, and also buffer for the final summary.
165        emit_json_event(event);
166        collected_for_watch.lock().unwrap().push(event.clone());
167    }));
168    // Keep the watch alive for the whole run. Leaking is acceptable for a
169    // single-shot CLI process (the bus outlives this scope anyway).
170    std::mem::forget(watch);
171
172    // The harness bus carries run lifecycle events; the agent receiver carries
173    // the native fine-grained stream (turns, message deltas, and tools).
174    let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
175    let agent_event_task = agent_events.take().map(|mut rx| {
176        tokio::spawn(async move {
177            let done_tx = agent_done_tx;
178            loop {
179                match rx.recv().await {
180                    Ok(event) => {
181                        let terminal = event.is_terminal();
182                        emit_agent_event(&event);
183                        if terminal {
184                            let _ = done_tx.send(());
185                        }
186                    }
187                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
188                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
189                }
190            }
191        })
192    });
193
194    let mut prompts: Vec<String> = Vec::new();
195    if let Some(init) = initial {
196        prompts.push(init);
197    }
198    for m in extra_messages {
199        prompts.push(m.clone());
200    }
201
202    let mut last_exit = 0;
203    let mut final_outcome: Option<HarnessRunOutcome> = None;
204
205    let mut images = initial_images;
206    for prompt in prompts {
207        match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
208            Ok(result) => {
209                // The harness resolves after its run outcome, while the
210                // broadcast listener may still be scheduling the terminal
211                // AgentEnd line. Wait briefly so JSON consumers see the full
212                // lifecycle before the final result summary. The timeout is
213                // deliberately bounded for custom/older harness emitters.
214                let _ = tokio::time::timeout(
215                    std::time::Duration::from_millis(250),
216                    agent_done_rx.recv(),
217                )
218                .await;
219                last_exit = outcome_exit_code(&result.outcome);
220                final_outcome = Some(result.outcome);
221            }
222            Err(e) => {
223                // Emit a structured error line + exit.
224                let line = serde_json::json!({
225                    "type": "error",
226                    "error": e.to_string(),
227                });
228                println!("{line}");
229                if let Some(task) = agent_event_task {
230                    task.abort();
231                }
232                return 1;
233            }
234        }
235    }
236
237    // Terminal result summary.
238    let (outcome_str, final_text) = match final_outcome {
239        Some(HarnessRunOutcome::Completed { final_message, .. }) => {
240            ("completed", Some(assistant_text(&final_message)))
241        }
242        Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
243            ("aborted", Some(assistant_text(&final_message)))
244        }
245        Some(HarnessRunOutcome::Failed { final_message, .. }) => {
246            let t = final_message.as_ref().map(assistant_text);
247            ("failed", t)
248        }
249        Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
250        None => ("idle", None),
251    };
252    let result_line = serde_json::json!({
253        "type": "result",
254        "outcome": outcome_str,
255        "finalText": final_text,
256    });
257    println!("{result_line}");
258    if let Some(task) = agent_event_task {
259        task.abort();
260    }
261    last_exit
262}
263
264fn emit_agent_event(event: &AgentEvent) {
265    println!("{}", agent_event_json(event));
266}
267
268/// `rpc` mode: a headless JSONL command loop (the server-side agent).
269///
270/// Reads one JSON command per line from stdin (`{id?, type, ...}`), drives the
271/// `main` lane, and writes one JSON object per line to stdout:
272/// - fine-grained `AgentEvent`s via [`agent_event_json`] (each carries `type`),
273/// - a `{"type":"response","id":<id>,"status":"ok"|"error",...}` per command.
274///
275/// Commands: `prompt` (streams the run), `abort`, `get_state`, `set_model`,
276/// `set_thinking_level`, `set_active_tools`, `ping`, `stop`. `stop` (or stdin
277/// EOF) ends the loop. This is the process `rpi-server` spawns and that
278/// `rpi --connect` talks to. Mirrors TS `runRpcMode` / `modes/rpc`.
279pub async fn rpc(
280    harness: &AgentHarness,
281    mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
282    model_catalog: Vec<rpi_ai::Model>,
283) -> i32 {
284    use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
285    use tokio::sync::Mutex as AsyncMutex;
286
287    let lane: Arc<dyn AgentLane> = harness.lane("main");
288    let stdout = Arc::new(AsyncMutex::new(tokio::io::stdout()));
289
290    // Stream fine-grained AgentEvents as JSONL until the receiver closes.
291    if let Some(mut rx) = agent_events.take() {
292        let out = Arc::clone(&stdout);
293        tokio::spawn(async move {
294            loop {
295                match rx.recv().await {
296                    Ok(event) => write_line(&out, &agent_event_json(&event)).await,
297                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
298                    Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
299                }
300            }
301        });
302    }
303
304    // Readiness marker: a host/client can wait for the first line.
305    write_line(&stdout, &serde_json::json!({"type": "ready"})).await;
306
307    let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines();
308    loop {
309        let line = match lines.next_line().await {
310            Ok(Some(line)) => line,
311            Ok(None) => break, // EOF
312            Err(error) => {
313                eprintln!("[rpi] rpc stdin error: {error}");
314                break;
315            }
316        };
317        let line = line.trim();
318        if line.is_empty() {
319            continue;
320        }
321        let msg: serde_json::Value = match serde_json::from_str(line) {
322            Ok(value) => value,
323            Err(error) => {
324                write_line(
325                    &stdout,
326                    &RemoteResponse::error(
327                        serde_json::Value::Null,
328                        format!("invalid json: {error}"),
329                    )
330                    .to_json(),
331                )
332                .await;
333                continue;
334            }
335        };
336        let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
337        let kind = msg
338            .get("type")
339            .and_then(|v| v.as_str())
340            .unwrap_or("")
341            .to_string();
342
343        if kind == "stop" {
344            write_line(
345                &stdout,
346                &RemoteResponse::ok(id.clone(), serde_json::json!({"stopped": true})).to_json(),
347            )
348            .await;
349            break;
350        }
351
352        let result = match kind.as_str() {
353            "prompt" => rpc_prompt(&lane, &msg).await,
354            "abort" => lane
355                .abort()
356                .await
357                .map(|_| serde_json::json!({"aborted": true}))
358                .map_err(|e| e.to_string()),
359            "get_state" => Ok(rpc_get_state(&lane, harness).await),
360            "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
361            "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
362            "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
363            "ping" => Ok(serde_json::json!({"pong": true})),
364            other => Err(format!("unknown command type: {other}")),
365        };
366
367        let response = match result {
368            Ok(value) => RemoteResponse::ok(id.clone(), value),
369            Err(error) => RemoteResponse::error(id.clone(), error),
370        };
371        write_line(&stdout, &response.to_json()).await;
372    }
373
374    let _ = stdout.lock().await.flush().await;
375    0
376}
377
378async fn write_line(out: &Arc<tokio::sync::Mutex<tokio::io::Stdout>>, value: &serde_json::Value) {
379    use tokio::io::AsyncWriteExt;
380    let mut guard = out.lock().await;
381    let _ = guard.write_all(value.to_string().as_bytes()).await;
382    let _ = guard.write_all(b"\n").await;
383    let _ = guard.flush().await;
384}
385
386fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>) {
387    match outcome {
388        HarnessRunOutcome::Completed { final_message, .. } => {
389            ("completed", Some(assistant_text(final_message)))
390        }
391        HarnessRunOutcome::Aborted { final_message, .. } => {
392            ("aborted", Some(assistant_text(final_message)))
393        }
394        HarnessRunOutcome::Failed { final_message, .. } => {
395            ("failed", final_message.as_ref().map(assistant_text))
396        }
397        HarnessRunOutcome::Suspended { .. } => ("suspended", None),
398    }
399}
400
401async fn rpc_prompt(
402    lane: &Arc<dyn AgentLane>,
403    msg: &serde_json::Value,
404) -> Result<serde_json::Value, String> {
405    let content = msg
406        .get("content")
407        .or_else(|| msg.get("prompt"))
408        .and_then(|v| v.as_str())
409        .ok_or("prompt requires a string `content`")?
410        .to_string();
411    let result = lane
412        .prompt_text(&content, Vec::new())
413        .await
414        .map_err(|e| e.to_string())?;
415    let (outcome, final_text) = rpc_outcome_str(&result.outcome);
416    Ok(serde_json::json!({"outcome": outcome, "finalText": final_text}))
417}
418
419async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
420    let model = lane.get_model().await.ok().map(|m| m.id);
421    let thinking = lane
422        .get_thinking_level()
423        .await
424        .ok()
425        .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
426    let active_tools = lane.get_active_tools().await.unwrap_or_default();
427    let leaf_id = lane.get_leaf_id().await.ok().flatten();
428    serde_json::json!({
429        "model": model,
430        "thinkingLevel": thinking,
431        "activeTools": active_tools,
432        "leafId": leaf_id,
433        "sessionId": harness.session().storage().metadata().id,
434    })
435}
436
437async fn rpc_set_model(
438    lane: &Arc<dyn AgentLane>,
439    catalog: &[rpi_ai::Model],
440    msg: &serde_json::Value,
441) -> Result<serde_json::Value, String> {
442    let id = msg
443        .get("model")
444        .and_then(|v| v.as_str())
445        .ok_or("set_model requires `model`")?;
446    let model = catalog
447        .iter()
448        .find(|m| m.id.eq_ignore_ascii_case(id))
449        .cloned()
450        .ok_or_else(|| format!("unknown model: {id}"))?;
451    lane.set_model(model).await.map_err(|e| e.to_string())?;
452    Ok(serde_json::json!({"model": id}))
453}
454
455async fn rpc_set_thinking_level(
456    lane: &Arc<dyn AgentLane>,
457    msg: &serde_json::Value,
458) -> Result<serde_json::Value, String> {
459    let value = msg
460        .get("level")
461        .or_else(|| msg.get("thinkingLevel"))
462        .ok_or("set_thinking_level requires `level`")?;
463    let level: rpi_ai::types::ThinkingLevel = serde_json::from_value(value.clone()).map_err(|_| {
464        "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
465    })?;
466    lane.set_thinking_level(level)
467        .await
468        .map_err(|e| e.to_string())?;
469    Ok(serde_json::json!({"thinkingLevel": level}))
470}
471
472async fn rpc_set_active_tools(
473    lane: &Arc<dyn AgentLane>,
474    msg: &serde_json::Value,
475) -> Result<serde_json::Value, String> {
476    let tools = msg
477        .get("tools")
478        .and_then(|v| v.as_array())
479        .map(|arr| {
480            arr.iter()
481                .filter_map(|v| v.as_str().map(str::to_owned))
482                .collect::<Vec<_>>()
483        })
484        .ok_or("set_active_tools requires a `tools` array")?;
485    lane.set_active_tools(tools.clone())
486        .await
487        .map_err(|e| e.to_string())?;
488    Ok(serde_json::json!({"activeTools": tools}))
489}
490
491/// Stable JSON projection for the fine-grained agent lifecycle stream.
492///
493/// Delegates to the shared [`crate::remote::protocol::RemoteEvent`] so the
494/// server and the `--connect` client agree on the wire shape by construction.
495fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
496    serde_json::to_value(crate::remote::protocol::RemoteEvent::from_agent_event(event))
497        .unwrap_or(serde_json::Value::Null)
498}
499
500/// Emit a single harness event as a JSON line on stdout. Mirrors the TS
501/// `toJsonEvent` projection (here a lossy but stable shape: `type` + the event
502/// payload's key fields).
503fn emit_json_event(event: &HarnessEvent) {
504    let line = match event {
505        HarnessEvent::RunStart(e) => serde_json::json!({
506            "type": "run_start",
507            "lane": e.lane,
508            "runId": e.run_id,
509        }),
510        HarnessEvent::RunEnd(e) => serde_json::json!({
511            "type": "run_end",
512            "lane": e.lane,
513            "runId": e.run_id,
514            "outcome": run_end_outcome_str(e.outcome),
515            "leafId": e.leaf_id,
516        }),
517    };
518    println!("{line}");
519}
520
521fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
522    match o {
523        RunEndOutcome::Completed => "completed",
524        RunEndOutcome::Aborted => "aborted",
525        RunEndOutcome::Failed => "failed",
526    }
527}
528
529/// `interactive` mode: uses TUI if terminal supports it, falls back to minimal REPL.
530///
531/// `event_rx` carries the live `AgentEvent` stream (drained by the TUI to
532/// render streaming responses). The REPL fallback ignores it.
533///
534/// `model_catalog` is the resolved provider's full model list, passed through
535/// so the TUI's `/model` selector can display available models (read-only —
536/// v1 does not switch models mid-session; see `docs/m6-cli-open-questions.md`).
537pub async fn interactive(
538    harness: &AgentHarness,
539    event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
540    args: &Args,
541    model_catalog: Vec<rpi_ai::Model>,
542    initial: Option<String>,
543    extra_messages: &[String],
544    initial_images: Vec<ImageContent>,
545    theme: Option<&str>,
546    no_themes: bool,
547    reload_context: &crate::session::ReloadContext,
548) -> i32 {
549    // ---- Session lifecycle (P1): BeforeTuiStart ----
550    // `BeforeTuiStart`: extensions may prepare (connect a server, load a
551    // resource) before the interactive UI initializes. Runs with a per-event
552    // timeout; a handler returning `EVENT_HANDLER_ABORT` **vetoes** startup —
553    // we print the reason and exit without entering the TUI. No-op when no
554    // extension subscribes.
555    if let Some(reason) = crate::session::dispatch_session_event_async(
556        reload_context,
557        rpi_plugin_sdk::EventTag::BeforeTuiStart,
558    )
559    .await
560    {
561        eprintln!("[rpi] startup vetoed by extension: {reason}");
562        return crate::app::EXIT_VETOED;
563    }
564
565    // Check if TUI is supported
566    let force_tui = std::env::var("RPI_FORCE_TUI")
567        .map(|v| v == "1")
568        .unwrap_or(false);
569    let code = if force_tui || crate::interactive_tui::is_tui_supported() {
570        // Use TUI-based interactive mode
571        crate::interactive_tui::interactive_tui(
572            harness,
573            event_rx,
574            args,
575            model_catalog,
576            initial,
577            extra_messages,
578            initial_images,
579            theme,
580            no_themes,
581            reload_context,
582        )
583        .await
584    } else {
585        // Fall back to simple REPL
586        interactive_repl(harness, args, initial, extra_messages, initial_images).await
587    };
588
589    // `SessionShutdown`: the interactive session closed (TUI/REPL returned) —
590    // extensions release resources, disconnect, persist state. Fired after the
591    // UI returns so it always runs (even on early-return paths inside the TUI).
592    // A veto here is **advisory** (the session is already closing) — warn and
593    // continue.
594    if let Some(reason) = crate::session::dispatch_session_event_async(
595        reload_context,
596        rpi_plugin_sdk::EventTag::SessionShutdown,
597    )
598    .await
599    {
600        tracing::warn!("[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}");
601    }
602
603    code
604}
605
606/// Simple REPL-based interactive mode (fallback for non-TTY environments).
607pub async fn interactive_repl(
608    harness: &AgentHarness,
609    #[allow(unused_variables)] args: &Args,
610    initial: Option<String>,
611    extra_messages: &[String],
612    initial_images: Vec<ImageContent>,
613) -> i32 {
614    // Debug: confirm we entered REPL mode
615    let lane: Arc<dyn AgentLane> = harness.lane("main");
616    let stdin = std::io::stdin();
617    let is_tty = stdin.is_terminal();
618
619    if is_tty {
620        println!(
621            "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
622        );
623    }
624
625    // Run the initial prompt + extra messages first (same as print mode).
626    let mut prompts: Vec<String> = Vec::new();
627    if let Some(init) = initial {
628        prompts.push(init);
629    }
630    for m in extra_messages {
631        prompts.push(m.clone());
632    }
633    let mut images = initial_images;
634    for prompt in prompts {
635        if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
636            return code;
637        }
638    }
639
640    // Then read lines from stdin until EOF / `/exit`.
641    let mut line = String::new();
642    loop {
643        if is_tty {
644            print!("> ");
645            let _ = std::io::stdout().flush();
646        }
647        line.clear();
648        match stdin.lock().read_line(&mut line) {
649            Ok(0) => break, // EOF
650            Ok(_) => {}
651            Err(_) => break,
652        }
653        let trimmed = line.trim();
654        if trimmed.is_empty() {
655            continue;
656        }
657        if trimmed == "/exit" || trimmed == "/quit" {
658            break;
659        }
660        if trimmed == "/abort" {
661            let _ = lane.abort().await;
662            eprintln!("(aborted)");
663            continue;
664        }
665        if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
666            return code;
667        }
668    }
669    0
670}
671
672/// Run a single prompt in interactive mode, printing the assistant reply (or
673/// the error). Returns `Ok(())` on success/soft-failure, `Err(exit_code)` on a
674/// hard rejection.
675async fn run_one(
676    lane: &Arc<dyn AgentLane>,
677    prompt: &str,
678    images: Vec<ImageContent>,
679) -> Result<(), i32> {
680    match lane.prompt_text(prompt, images).await {
681        Ok(result) => {
682            match &result.outcome {
683                HarnessRunOutcome::Completed { final_message, .. }
684                | HarnessRunOutcome::Aborted { final_message, .. } => {
685                    let text = assistant_text(final_message);
686                    if !text.is_empty() {
687                        println!("{text}");
688                    }
689                }
690                HarnessRunOutcome::Failed {
691                    error,
692                    final_message,
693                    ..
694                } => {
695                    if let Some(m) = final_message {
696                        if let Some(em) = &m.error_message {
697                            eprintln!("error: {em}");
698                        }
699                    }
700                    eprintln!("run failed: {error:?}");
701                }
702                HarnessRunOutcome::Suspended { .. } => {
703                    eprintln!("run suspended (deferred) — resume not supported in v1");
704                }
705            }
706            Ok(())
707        }
708        Err(e) => {
709            eprintln!("prompt rejected: {e}");
710            Err(1)
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use rpi_agent::events::AgentEvent;
719    use rpi_agent::types::AgentToolResult;
720    use rpi_ai::types::{
721        AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
722    };
723    use rpi_harness::session::types::OperationError;
724
725    fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
726        AssistantMessage {
727            role: rpi_ai::types::AssistantRole,
728            content: vec![Content::Text(TextContent {
729                kind: TextContentType,
730                text: text.into(),
731                text_signature: None,
732            })],
733            api: rpi_ai::Api::AnthropicMessages,
734            provider: "anthropic".into(),
735            model: "claude-sonnet-5".into(),
736            response_model: None,
737            response_id: None,
738            usage: Usage::zero(),
739            stop_reason: stop,
740            deferred: None,
741            error_message: None,
742            raw_stop_reason: None,
743            end_turn: None,
744            timestamp: 0,
745        }
746    }
747
748    #[test]
749    fn assistant_text_concatenates_text_blocks() {
750        let m = assistant("hello", StopReason::Stop);
751        assert_eq!(assistant_text(&m), "hello");
752    }
753
754    #[test]
755    fn outcome_exit_code_maps_failed_aborted_to_1() {
756        let failed = HarnessRunOutcome::Failed {
757            leaf_id: "l".into(),
758            error: OperationError {
759                code: "boom".into(),
760                message: "boom".into(),
761            },
762            final_entry_id: None,
763            final_message: None,
764        };
765        assert_eq!(outcome_exit_code(&failed), 1);
766        let completed = HarnessRunOutcome::Completed {
767            leaf_id: "l".into(),
768            final_entry_id: "e".into(),
769            final_message: assistant("ok", StopReason::Stop),
770        };
771        assert_eq!(outcome_exit_code(&completed), 0);
772    }
773
774    #[test]
775    fn run_end_outcome_str_roundtrip() {
776        assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
777        assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
778        assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
779    }
780
781    #[test]
782    fn agent_event_projection_keeps_terminal_and_tool_payloads() {
783        let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
784        assert_eq!(end["type"], "agent_end");
785        assert_eq!(end["messages"], serde_json::json!([]));
786
787        let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
788            tool_call_id: "call-1".into(),
789            tool_name: "read".into(),
790            result: AgentToolResult::text("hello"),
791            is_error: false,
792        });
793        assert_eq!(tool["type"], "tool_execution_end");
794        assert_eq!(tool["result"]["content"][0]["text"], "hello");
795        assert_eq!(tool["result"]["terminate"], false);
796
797        let retry = agent_event_json(&AgentEvent::RetryScheduled {
798            attempt: 3,
799            max_retries: 10,
800            delay_ms: 8_000,
801            error: "503 service unavailable".into(),
802        });
803        assert_eq!(retry["type"], "retry_scheduled");
804        assert_eq!(retry["attempt"], 3);
805        assert_eq!(retry["maxRetries"], 10);
806        assert_eq!(retry["delayMs"], 8_000);
807        assert_eq!(retry["error"], "503 service unavailable");
808    }
809}