1use 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
27pub 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
39pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
42 match outcome {
43 HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
44 _ => 0,
45 }
46}
47
48pub 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 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 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 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
141pub 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 let mut watch = harness.events().watch(|| ());
157 watch.start(Arc::new(move |event: &HarnessEvent| {
158 emit_json_event(event);
160 collected_for_watch.lock().unwrap().push(event.clone());
161 }));
162 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 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 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
219fn 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
248pub async fn interactive(
257 harness: &AgentHarness,
258 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
259 args: &Args,
260 model_catalog: Vec<rpi_ai::Model>,
261 initial: Option<String>,
262 extra_messages: &[String],
263 theme: Option<&str>,
264 reload_context: &crate::session::ReloadContext,
265) -> i32 {
266 let force_tui = std::env::var("RPI_FORCE_TUI")
268 .map(|v| v == "1")
269 .unwrap_or(false);
270 if force_tui || crate::interactive_tui::is_tui_supported() {
271 crate::interactive_tui::interactive_tui(
273 harness,
274 event_rx,
275 args,
276 model_catalog,
277 initial,
278 extra_messages,
279 theme,
280 reload_context,
281 )
282 .await
283 } else {
284 interactive_repl(harness, args, initial, extra_messages).await
286 }
287}
288
289pub async fn interactive_repl(
291 harness: &AgentHarness,
292 #[allow(unused_variables)] args: &Args,
293 initial: Option<String>,
294 extra_messages: &[String],
295) -> i32 {
296 let lane: Arc<dyn AgentLane> = harness.lane("main");
298 let stdin = std::io::stdin();
299 let is_tty = stdin.is_terminal();
300
301 if is_tty {
302 println!(
303 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
304 );
305 }
306
307 let mut prompts: Vec<String> = Vec::new();
309 if let Some(init) = initial {
310 prompts.push(init);
311 }
312 for m in extra_messages {
313 prompts.push(m.clone());
314 }
315 for prompt in prompts {
316 if let Err(code) = run_one(&lane, &prompt).await {
317 return code;
318 }
319 }
320
321 let mut line = String::new();
323 loop {
324 if is_tty {
325 print!("> ");
326 let _ = std::io::stdout().flush();
327 }
328 line.clear();
329 match stdin.lock().read_line(&mut line) {
330 Ok(0) => break, Ok(_) => {}
332 Err(_) => break,
333 }
334 let trimmed = line.trim();
335 if trimmed.is_empty() {
336 continue;
337 }
338 if trimmed == "/exit" || trimmed == "/quit" {
339 break;
340 }
341 if trimmed == "/abort" {
342 let _ = lane.abort().await;
343 eprintln!("(aborted)");
344 continue;
345 }
346 if let Err(code) = run_one(&lane, trimmed).await {
347 return code;
348 }
349 }
350 0
351}
352
353async fn run_one(lane: &Arc<dyn AgentLane>, prompt: &str) -> Result<(), i32> {
357 match lane.prompt_text(prompt, Vec::new()).await {
358 Ok(result) => {
359 match &result.outcome {
360 HarnessRunOutcome::Completed { final_message, .. }
361 | HarnessRunOutcome::Aborted { final_message, .. } => {
362 let text = assistant_text(final_message);
363 if !text.is_empty() {
364 println!("{text}");
365 }
366 }
367 HarnessRunOutcome::Failed {
368 error,
369 final_message,
370 ..
371 } => {
372 if let Some(m) = final_message {
373 if let Some(em) = &m.error_message {
374 eprintln!("error: {em}");
375 }
376 }
377 eprintln!("run failed: {error:?}");
378 }
379 HarnessRunOutcome::Suspended { .. } => {
380 eprintln!("run suspended (deferred) — resume not supported in v1");
381 }
382 }
383 Ok(())
384 }
385 Err(e) => {
386 eprintln!("prompt rejected: {e}");
387 Err(1)
388 }
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use rpi_ai::types::{
396 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
397 };
398 use rpi_harness::session::types::OperationError;
399
400 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
401 AssistantMessage {
402 role: rpi_ai::types::AssistantRole,
403 content: vec![Content::Text(TextContent {
404 kind: TextContentType,
405 text: text.into(),
406 text_signature: None,
407 })],
408 api: rpi_ai::Api::AnthropicMessages,
409 provider: "anthropic".into(),
410 model: "claude-sonnet-5".into(),
411 response_model: None,
412 response_id: None,
413 usage: Usage::zero(),
414 stop_reason: stop,
415 deferred: None,
416 error_message: None,
417 raw_stop_reason: None,
418 end_turn: None,
419 timestamp: 0,
420 }
421 }
422
423 #[test]
424 fn assistant_text_concatenates_text_blocks() {
425 let m = assistant("hello", StopReason::Stop);
426 assert_eq!(assistant_text(&m), "hello");
427 }
428
429 #[test]
430 fn outcome_exit_code_maps_failed_aborted_to_1() {
431 let failed = HarnessRunOutcome::Failed {
432 leaf_id: "l".into(),
433 error: OperationError {
434 code: "boom".into(),
435 message: "boom".into(),
436 },
437 final_entry_id: None,
438 final_message: None,
439 };
440 assert_eq!(outcome_exit_code(&failed), 1);
441 let completed = HarnessRunOutcome::Completed {
442 leaf_id: "l".into(),
443 final_entry_id: "e".into(),
444 final_message: assistant("ok", StopReason::Stop),
445 };
446 assert_eq!(outcome_exit_code(&completed), 0);
447 }
448
449 #[test]
450 fn run_end_outcome_str_roundtrip() {
451 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
452 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
453 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
454 }
455}