1use 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
28pub 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
40pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
43 match outcome {
44 HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
45 _ => 0,
46 }
47}
48
49pub 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 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 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 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
144pub 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 let mut watch = harness.events().watch(|| ());
162 watch.start(Arc::new(move |event: &HarnessEvent| {
163 emit_json_event(event);
165 collected_for_watch.lock().unwrap().push(event.clone());
166 }));
167 std::mem::forget(watch);
170
171 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 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 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 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
267fn 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::TurnStart => serde_json::json!({"type":"turn_start"}),
279 AgentEvent::TurnEnd {
280 message,
281 tool_results,
282 } => serde_json::json!({
283 "type":"turn_end", "message": serde_json::to_value(message).ok(),
284 "toolResultCount": tool_results.len(),
285 "toolResults": serde_json::to_value(tool_results).unwrap_or(serde_json::Value::Null)
286 }),
287 AgentEvent::MessageStart { message } => serde_json::json!({
288 "type":"message_start", "message": serde_json::to_value(message).ok()
289 }),
290 AgentEvent::MessageEnd { message } => serde_json::json!({
291 "type":"message_end", "message": serde_json::to_value(message).ok()
292 }),
293 AgentEvent::MessageUpdate {
294 message,
295 assistant_message_event,
296 } => {
297 let mut value = serde_json::json!({
298 "type":"message_update",
299 "message": serde_json::to_value(message).unwrap_or(serde_json::Value::Null),
300 "assistantMessageEvent": serde_json::to_value(assistant_message_event)
301 .unwrap_or(serde_json::Value::Null),
302 "eventType": assistant_message_event.type_tag(),
303 });
304 let object = value.as_object_mut().expect("json object");
305 match assistant_message_event {
306 AssistantMessageEvent::TextDelta {
307 content_index,
308 delta,
309 ..
310 }
311 | AssistantMessageEvent::ThinkingDelta {
312 content_index,
313 delta,
314 ..
315 }
316 | AssistantMessageEvent::ToolCallDelta {
317 content_index,
318 delta,
319 ..
320 } => {
321 object.insert("contentIndex".into(), (*content_index).into());
322 object.insert("delta".into(), delta.clone().into());
323 }
324 _ => {}
325 }
326 value
327 }
328 AgentEvent::ToolExecutionStart {
329 tool_call_id,
330 tool_name,
331 args,
332 } => serde_json::json!({
333 "type":"tool_execution_start", "toolCallId":tool_call_id,
334 "toolName":tool_name, "args":args
335 }),
336 AgentEvent::ToolExecutionUpdate {
337 tool_call_id,
338 tool_name,
339 args,
340 partial_result,
341 } => serde_json::json!({
342 "type":"tool_execution_update", "toolCallId":tool_call_id, "toolName":tool_name,
343 "args": args,
344 "partialResult": tool_result_json(partial_result)
345 }),
346 AgentEvent::ToolExecutionEnd {
347 tool_call_id,
348 tool_name,
349 result,
350 is_error,
351 } => serde_json::json!({
352 "type":"tool_execution_end", "toolCallId":tool_call_id,
353 "toolName":tool_name, "isError":is_error,
354 "result": tool_result_json(result)
355 }),
356 }
357}
358
359fn tool_result_json(result: &rpi_agent::types::AgentToolResult) -> serde_json::Value {
360 let content: Vec<serde_json::Value> = result
361 .content
362 .iter()
363 .map(|item| match item {
364 rpi_agent::types::TextContentOrImage::Text(text) => serde_json::json!({
365 "type": "text",
366 "text": text.text,
367 }),
368 rpi_agent::types::TextContentOrImage::Image(image) => {
369 serde_json::to_value(image).unwrap_or(serde_json::Value::Null)
370 }
371 })
372 .collect();
373 serde_json::json!({
374 "content": content,
375 "details": result.details,
376 "usage": result.usage.as_ref().and_then(|usage| serde_json::to_value(usage).ok()),
377 "addedToolNames": result.added_tool_names,
378 "terminate": result.terminate,
379 })
380}
381
382fn emit_json_event(event: &HarnessEvent) {
386 let line = match event {
387 HarnessEvent::RunStart(e) => serde_json::json!({
388 "type": "run_start",
389 "lane": e.lane,
390 "runId": e.run_id,
391 }),
392 HarnessEvent::RunEnd(e) => serde_json::json!({
393 "type": "run_end",
394 "lane": e.lane,
395 "runId": e.run_id,
396 "outcome": run_end_outcome_str(e.outcome),
397 "leafId": e.leaf_id,
398 }),
399 };
400 println!("{line}");
401}
402
403fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
404 match o {
405 RunEndOutcome::Completed => "completed",
406 RunEndOutcome::Aborted => "aborted",
407 RunEndOutcome::Failed => "failed",
408 }
409}
410
411pub async fn interactive(
420 harness: &AgentHarness,
421 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
422 args: &Args,
423 model_catalog: Vec<rpi_ai::Model>,
424 initial: Option<String>,
425 extra_messages: &[String],
426 initial_images: Vec<ImageContent>,
427 theme: Option<&str>,
428 no_themes: bool,
429 reload_context: &crate::session::ReloadContext,
430) -> i32 {
431 let force_tui = std::env::var("RPI_FORCE_TUI")
433 .map(|v| v == "1")
434 .unwrap_or(false);
435 if force_tui || crate::interactive_tui::is_tui_supported() {
436 crate::interactive_tui::interactive_tui(
438 harness,
439 event_rx,
440 args,
441 model_catalog,
442 initial,
443 extra_messages,
444 initial_images,
445 theme,
446 no_themes,
447 reload_context,
448 )
449 .await
450 } else {
451 interactive_repl(harness, args, initial, extra_messages, initial_images).await
453 }
454}
455
456pub async fn interactive_repl(
458 harness: &AgentHarness,
459 #[allow(unused_variables)] args: &Args,
460 initial: Option<String>,
461 extra_messages: &[String],
462 initial_images: Vec<ImageContent>,
463) -> i32 {
464 let lane: Arc<dyn AgentLane> = harness.lane("main");
466 let stdin = std::io::stdin();
467 let is_tty = stdin.is_terminal();
468
469 if is_tty {
470 println!(
471 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
472 );
473 }
474
475 let mut prompts: Vec<String> = Vec::new();
477 if let Some(init) = initial {
478 prompts.push(init);
479 }
480 for m in extra_messages {
481 prompts.push(m.clone());
482 }
483 let mut images = initial_images;
484 for prompt in prompts {
485 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
486 return code;
487 }
488 }
489
490 let mut line = String::new();
492 loop {
493 if is_tty {
494 print!("> ");
495 let _ = std::io::stdout().flush();
496 }
497 line.clear();
498 match stdin.lock().read_line(&mut line) {
499 Ok(0) => break, Ok(_) => {}
501 Err(_) => break,
502 }
503 let trimmed = line.trim();
504 if trimmed.is_empty() {
505 continue;
506 }
507 if trimmed == "/exit" || trimmed == "/quit" {
508 break;
509 }
510 if trimmed == "/abort" {
511 let _ = lane.abort().await;
512 eprintln!("(aborted)");
513 continue;
514 }
515 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
516 return code;
517 }
518 }
519 0
520}
521
522async fn run_one(
526 lane: &Arc<dyn AgentLane>,
527 prompt: &str,
528 images: Vec<ImageContent>,
529) -> Result<(), i32> {
530 match lane.prompt_text(prompt, images).await {
531 Ok(result) => {
532 match &result.outcome {
533 HarnessRunOutcome::Completed { final_message, .. }
534 | HarnessRunOutcome::Aborted { final_message, .. } => {
535 let text = assistant_text(final_message);
536 if !text.is_empty() {
537 println!("{text}");
538 }
539 }
540 HarnessRunOutcome::Failed {
541 error,
542 final_message,
543 ..
544 } => {
545 if let Some(m) = final_message {
546 if let Some(em) = &m.error_message {
547 eprintln!("error: {em}");
548 }
549 }
550 eprintln!("run failed: {error:?}");
551 }
552 HarnessRunOutcome::Suspended { .. } => {
553 eprintln!("run suspended (deferred) — resume not supported in v1");
554 }
555 }
556 Ok(())
557 }
558 Err(e) => {
559 eprintln!("prompt rejected: {e}");
560 Err(1)
561 }
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568 use rpi_agent::events::AgentEvent;
569 use rpi_agent::types::AgentToolResult;
570 use rpi_ai::types::{
571 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
572 };
573 use rpi_harness::session::types::OperationError;
574
575 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
576 AssistantMessage {
577 role: rpi_ai::types::AssistantRole,
578 content: vec![Content::Text(TextContent {
579 kind: TextContentType,
580 text: text.into(),
581 text_signature: None,
582 })],
583 api: rpi_ai::Api::AnthropicMessages,
584 provider: "anthropic".into(),
585 model: "claude-sonnet-5".into(),
586 response_model: None,
587 response_id: None,
588 usage: Usage::zero(),
589 stop_reason: stop,
590 deferred: None,
591 error_message: None,
592 raw_stop_reason: None,
593 end_turn: None,
594 timestamp: 0,
595 }
596 }
597
598 #[test]
599 fn assistant_text_concatenates_text_blocks() {
600 let m = assistant("hello", StopReason::Stop);
601 assert_eq!(assistant_text(&m), "hello");
602 }
603
604 #[test]
605 fn outcome_exit_code_maps_failed_aborted_to_1() {
606 let failed = HarnessRunOutcome::Failed {
607 leaf_id: "l".into(),
608 error: OperationError {
609 code: "boom".into(),
610 message: "boom".into(),
611 },
612 final_entry_id: None,
613 final_message: None,
614 };
615 assert_eq!(outcome_exit_code(&failed), 1);
616 let completed = HarnessRunOutcome::Completed {
617 leaf_id: "l".into(),
618 final_entry_id: "e".into(),
619 final_message: assistant("ok", StopReason::Stop),
620 };
621 assert_eq!(outcome_exit_code(&completed), 0);
622 }
623
624 #[test]
625 fn run_end_outcome_str_roundtrip() {
626 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
627 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
628 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
629 }
630
631 #[test]
632 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
633 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
634 assert_eq!(end["type"], "agent_end");
635 assert_eq!(end["messages"], serde_json::json!([]));
636
637 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
638 tool_call_id: "call-1".into(),
639 tool_name: "read".into(),
640 result: AgentToolResult::text("hello"),
641 is_error: false,
642 });
643 assert_eq!(tool["type"], "tool_execution_end");
644 assert_eq!(tool["result"]["content"][0]["text"], "hello");
645 assert_eq!(tool["result"]["terminate"], false);
646 }
647}