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