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