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;
27use crate::remote::protocol::RemoteResponse;
28
29pub 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
41pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
44 match outcome {
45 HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
46 _ => 0,
47 }
48}
49
50pub 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 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 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 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
145pub 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 let mut watch = harness.events().watch(|| ());
163 watch.start(Arc::new(move |event: &HarnessEvent| {
164 emit_json_event(event);
166 collected_for_watch.lock().unwrap().push(event.clone());
167 }));
168 std::mem::forget(watch);
171
172 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 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 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 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
268pub async fn rpc(
286 harness: &AgentHarness,
287 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
288 model_catalog: Vec<rpi_ai::Model>,
289 ui_dialog: rpi_extensions::UiDialogMailbox,
290) -> i32 {
291 rpc_with_io(
292 harness,
293 agent_events.take(),
294 model_catalog,
295 ui_dialog,
296 tokio::io::BufReader::new(tokio::io::stdin()),
297 std::io::stdout(),
298 )
299 .await
300}
301
302async fn rpc_with_io<R, W>(
303 harness: &AgentHarness,
304 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
305 model_catalog: Vec<rpi_ai::Model>,
306 ui_dialog: rpi_extensions::UiDialogMailbox,
307 input: R,
308 output: W,
309) -> i32
310where
311 R: tokio::io::AsyncBufRead + Unpin,
312 W: Write + Send + 'static,
313{
314 use tokio::io::AsyncBufReadExt;
315
316 let lane: Arc<dyn AgentLane> = harness.lane("main");
317 let (stdout, writer_task, writer_failed) = start_rpc_writer(output);
320 let pump_stop = tokio_util::sync::CancellationToken::new();
321
322 let (event_flush_tx, mut event_flush_rx) =
325 tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
326 let event_task = agent_events.take().map(|mut rx| {
328 let out = stdout.clone();
329 let stop = pump_stop.clone();
330 tokio::spawn(async move {
331 loop {
332 let received = tokio::select! {
333 _ = stop.cancelled() => {
334 drain_rpc_events(&out, &mut rx).await;
335 break;
336 }
337 Some(done) = event_flush_rx.recv() => {
338 drain_rpc_events(&out, &mut rx).await;
339 let _ = done.send(());
340 continue;
341 }
342 event = rx.recv() => event,
343 };
344 match received {
345 Ok(event) => write_line(&out, &agent_event_json(&event)).await,
346 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
347 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
348 }
349 }
350 })
351 });
352
353 let event_flush = event_task.as_ref().map(|_| event_flush_tx);
354
355 ui_dialog.attach();
361 let dialog_task = {
362 let mailbox = ui_dialog.clone();
363 let out = stdout.clone();
364 let stop = pump_stop.clone();
365 tokio::spawn(async move {
366 let mut shown = std::collections::HashSet::new();
367 while !stop.is_cancelled() {
368 while let Some(request) = mailbox.take_pending() {
369 let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
370 request_id: request.request_id.clone(),
371 tool_call_id: request.tool_call_id,
372 ui: request.ui,
373 };
374 write_line(&out, &serde_json::to_value(event).unwrap()).await;
375 shown.insert(request.request_id);
376 }
377 let closed: Vec<String> = shown
378 .iter()
379 .filter(|id| !mailbox.is_pending(id))
380 .cloned()
381 .collect();
382 for request_id in closed {
383 shown.remove(&request_id);
384 let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
385 write_line(&out, &serde_json::to_value(event).unwrap()).await;
386 }
387 tokio::select! {
388 _ = stop.cancelled() => break,
389 _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
390 }
391 }
392 })
393 };
394
395 let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
399 let mut run_task: Option<tokio::task::JoinHandle<()>> = None;
400 let mut run_cancel = tokio_util::sync::CancellationToken::new();
401 let mut cancel_on_exit = false;
402
403 write_line(&stdout, &serde_json::json!({"type": "ready"})).await;
405
406 let mut lines = input.lines();
407 loop {
408 let incoming = tokio::select! {
409 _ = writer_failed.cancelled() => {
410 cancel_on_exit = true;
411 break;
412 }
413 line = lines.next_line() => line,
414 };
415 let line = match incoming {
416 Ok(Some(line)) => line,
417 Ok(None) => break, Err(error) => {
419 eprintln!("[rpi] rpc stdin error: {error}");
420 cancel_on_exit = true;
421 break;
422 }
423 };
424 let line = line.trim();
425 if line.is_empty() {
426 continue;
427 }
428 let msg: serde_json::Value = match serde_json::from_str(line) {
429 Ok(value) => value,
430 Err(error) => {
431 write_line(
432 &stdout,
433 &RemoteResponse::error(
434 serde_json::Value::Null,
435 format!("invalid json: {error}"),
436 )
437 .to_json(),
438 )
439 .await;
440 continue;
441 }
442 };
443 let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
444 let kind = msg
445 .get("type")
446 .and_then(|v| v.as_str())
447 .unwrap_or("")
448 .to_string();
449
450 match kind.as_str() {
451 "ui_dialog_response" => {
452 let request_id = msg
453 .get("requestId")
454 .and_then(|v| v.as_str())
455 .unwrap_or("")
456 .to_string();
457 let result = if msg
458 .get("cancelled")
459 .and_then(|v| v.as_bool())
460 .unwrap_or(false)
461 {
462 ui_dialog.cancel(&request_id)
463 } else {
464 let answer = msg
465 .get("answer")
466 .cloned()
467 .unwrap_or(serde_json::Value::Null);
468 ui_dialog.respond(&request_id, answer)
469 };
470 if msg.get("id").is_some() {
471 let response = match result {
472 Ok(()) => RemoteResponse::ok(
473 id,
474 serde_json::json!({"accepted":true,"requestId":request_id}),
475 ),
476 Err(error) => RemoteResponse::error(id, error),
477 };
478 write_line(&stdout, &response.to_json()).await;
479 } else if let Err(error) = result {
480 eprintln!("[rpi] ui_dialog_response: {error}");
481 }
482 continue;
483 }
484 "stop" => {
485 cancel_on_exit = true;
486 write_line(
487 &stdout,
488 &RemoteResponse::ok(id.clone(), serde_json::json!({"stopped": true})).to_json(),
489 )
490 .await;
491 break;
492 }
493 "prompt" => {
494 if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
495 write_line(
496 &stdout,
497 &RemoteResponse::error(id.clone(), "a run is already active").to_json(),
498 )
499 .await;
500 continue;
501 }
502 if let Some(task) = run_task.take() {
503 let _ = task.await;
504 }
505 run_cancel = tokio_util::sync::CancellationToken::new();
506 let cancel_task = run_cancel.clone();
507 let content = msg
508 .get("content")
509 .or_else(|| msg.get("prompt"))
510 .and_then(|v| v.as_str())
511 .map(str::to_owned);
512 let lane_task = Arc::clone(&lane);
513 let out_task = stdout.clone();
514 let active_task = Arc::clone(&run_active);
515 let mailbox_task = ui_dialog.clone();
516 let id_task = id.clone();
517 let flush_events = event_flush.clone();
518 run_task = Some(tokio::spawn(async move {
519 let result = match content {
520 Some(content) => {
521 rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
522 }
523 None => Err("prompt requires a string `content`".to_string()),
524 };
525 let response = match result {
526 Ok(value) => RemoteResponse::ok(id_task, value),
527 Err(error) => RemoteResponse::error(id_task, error),
528 };
529 if let Some(flush) = flush_events {
530 let (done, drained) = tokio::sync::oneshot::channel();
531 if flush.send(done).is_ok() {
532 let _ = drained.await;
533 }
534 }
535 write_line(&out_task, &response.to_json()).await;
536 mailbox_task.cancel_all();
538 active_task.store(false, std::sync::atomic::Ordering::Release);
539 }));
540 continue;
541 }
542 _ => {}
543 }
544
545 let result = match kind.as_str() {
546 "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
547 run_cancel.cancel();
549 Ok(serde_json::json!({"aborted": true}))
550 }
551 "abort" => lane
552 .abort()
553 .await
554 .map(|_| serde_json::json!({"aborted": true}))
555 .map_err(|e| e.to_string()),
556 "get_state" => Ok(rpc_get_state(&lane, harness).await),
557 "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
558 "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
559 "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
560 "ping" => Ok(serde_json::json!({"pong": true})),
561 other => Err(format!("unknown command type: {other}")),
562 };
563
564 let response = match result {
565 Ok(value) => RemoteResponse::ok(id.clone(), value),
566 Err(error) => RemoteResponse::error(id.clone(), error),
567 };
568 write_line(&stdout, &response.to_json()).await;
569 }
570
571 ui_dialog.detach();
575 if cancel_on_exit {
576 run_cancel.cancel();
577 }
578 let mut exit_code = 0;
579 if let Some(task) = run_task {
580 if let Err(error) = task.await {
581 eprintln!("[rpi] rpc prompt task failed: {error}");
582 exit_code = 1;
583 }
584 }
585 pump_stop.cancel();
586 let _ = dialog_task.await;
587 if let Some(task) = event_task {
588 let _ = task.await;
589 }
590 drop(stdout);
592 match writer_task.await {
593 Ok(Ok(())) => {}
594 error => {
595 eprintln!("[rpi] rpc output failed: {error:?}");
596 exit_code = 1;
597 }
598 }
599 exit_code
600}
601
602#[derive(Clone)]
603struct RpcWriter(tokio::sync::mpsc::Sender<Vec<u8>>);
604
605fn start_rpc_writer<W: Write + Send + 'static>(
606 mut output: W,
607) -> (
608 RpcWriter,
609 tokio::task::JoinHandle<std::io::Result<()>>,
610 tokio_util::sync::CancellationToken,
611) {
612 let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
613 let failed = tokio_util::sync::CancellationToken::new();
614 let failure_signal = failed.clone();
615 let task = tokio::task::spawn_blocking(move || {
616 let result = (|| {
617 while let Some(line) = rx.blocking_recv() {
618 output.write_all(&line)?;
619 output.flush()?;
620 }
621 output.flush()
622 })();
623 if result.is_err() {
624 failure_signal.cancel();
625 }
626 result
627 });
628 (RpcWriter(tx), task, failed)
629}
630
631async fn write_line(out: &RpcWriter, value: &serde_json::Value) {
632 let mut line = value.to_string().into_bytes();
633 line.push(b'\n');
634 let _ = out.0.send(line).await;
635}
636
637async fn drain_rpc_events(
638 out: &RpcWriter,
639 events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
640) {
641 loop {
642 match events.try_recv() {
643 Ok(event) => write_line(out, &agent_event_json(&event)).await,
644 Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
645 Err(_) => break,
646 }
647 }
648}
649
650async fn rpc_prompt_cancellable(
653 lane: &Arc<dyn AgentLane>,
654 content: &str,
655 cancel: tokio_util::sync::CancellationToken,
656) -> Result<serde_json::Value, String> {
657 let prompt = rpc_prompt_text(lane, content);
658 tokio::pin!(prompt);
659 tokio::select! {
660 biased;
661 _ = cancel.cancelled() => {
662 match lane.abort().await {
663 Ok(_) => prompt.await,
664 Err(_) => Err("Operation aborted".to_string()),
665 }
666 }
667 result = &mut prompt => result,
668 }
669}
670
671fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>) {
672 match outcome {
673 HarnessRunOutcome::Completed { final_message, .. } => {
674 ("completed", Some(assistant_text(final_message)))
675 }
676 HarnessRunOutcome::Aborted { final_message, .. } => {
677 ("aborted", Some(assistant_text(final_message)))
678 }
679 HarnessRunOutcome::Failed { final_message, .. } => {
680 ("failed", final_message.as_ref().map(assistant_text))
681 }
682 HarnessRunOutcome::Suspended { .. } => ("suspended", None),
683 }
684}
685
686async fn rpc_prompt_text(
687 lane: &Arc<dyn AgentLane>,
688 content: &str,
689) -> Result<serde_json::Value, String> {
690 let result = lane
691 .prompt_text(content, Vec::new())
692 .await
693 .map_err(|e| e.to_string())?;
694 let (outcome, final_text) = rpc_outcome_str(&result.outcome);
695 Ok(serde_json::json!({"outcome": outcome, "finalText": final_text}))
696}
697
698async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
699 let model = lane.get_model().await.ok().map(|m| m.id);
700 let thinking = lane
701 .get_thinking_level()
702 .await
703 .ok()
704 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
705 let active_tools = lane.get_active_tools().await.unwrap_or_default();
706 let leaf_id = lane.get_leaf_id().await.ok().flatten();
707 serde_json::json!({
708 "model": model,
709 "thinkingLevel": thinking,
710 "activeTools": active_tools,
711 "leafId": leaf_id,
712 "sessionId": harness.session().storage().metadata().id,
713 })
714}
715
716async fn rpc_set_model(
717 lane: &Arc<dyn AgentLane>,
718 catalog: &[rpi_ai::Model],
719 msg: &serde_json::Value,
720) -> Result<serde_json::Value, String> {
721 let id = msg
722 .get("model")
723 .and_then(|v| v.as_str())
724 .ok_or("set_model requires `model`")?;
725 let model = catalog
726 .iter()
727 .find(|m| m.id.eq_ignore_ascii_case(id))
728 .cloned()
729 .ok_or_else(|| format!("unknown model: {id}"))?;
730 lane.set_model(model).await.map_err(|e| e.to_string())?;
731 Ok(serde_json::json!({"model": id}))
732}
733
734async fn rpc_set_thinking_level(
735 lane: &Arc<dyn AgentLane>,
736 msg: &serde_json::Value,
737) -> Result<serde_json::Value, String> {
738 let value = msg
739 .get("level")
740 .or_else(|| msg.get("thinkingLevel"))
741 .ok_or("set_thinking_level requires `level`")?;
742 let level: rpi_ai::types::ThinkingLevel =
743 serde_json::from_value(value.clone()).map_err(|_| {
744 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
745 })?;
746 lane.set_thinking_level(level)
747 .await
748 .map_err(|e| e.to_string())?;
749 Ok(serde_json::json!({"thinkingLevel": level}))
750}
751
752async fn rpc_set_active_tools(
753 lane: &Arc<dyn AgentLane>,
754 msg: &serde_json::Value,
755) -> Result<serde_json::Value, String> {
756 let tools = msg
757 .get("tools")
758 .and_then(|v| v.as_array())
759 .map(|arr| {
760 arr.iter()
761 .filter_map(|v| v.as_str().map(str::to_owned))
762 .collect::<Vec<_>>()
763 })
764 .ok_or("set_active_tools requires a `tools` array")?;
765 lane.set_active_tools(tools.clone())
766 .await
767 .map_err(|e| e.to_string())?;
768 Ok(serde_json::json!({"activeTools": tools}))
769}
770
771fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
776 serde_json::to_value(crate::remote::protocol::RemoteEvent::from_agent_event(
777 event,
778 ))
779 .unwrap_or(serde_json::Value::Null)
780}
781
782fn emit_json_event(event: &HarnessEvent) {
786 let line = match event {
787 HarnessEvent::CompactionProgress(e) => {
788 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
789 }
790 HarnessEvent::RunStart(e) => serde_json::json!({
791 "type": "run_start",
792 "lane": e.lane,
793 "runId": e.run_id,
794 }),
795 HarnessEvent::RunEnd(e) => serde_json::json!({
796 "type": "run_end",
797 "lane": e.lane,
798 "runId": e.run_id,
799 "outcome": run_end_outcome_str(e.outcome),
800 "leafId": e.leaf_id,
801 }),
802 };
803 println!("{line}");
804}
805
806fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
807 match o {
808 RunEndOutcome::Completed => "completed",
809 RunEndOutcome::Aborted => "aborted",
810 RunEndOutcome::Failed => "failed",
811 }
812}
813
814pub async fn interactive(
823 harness: &AgentHarness,
824 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
825 args: &Args,
826 model_catalog: Vec<rpi_ai::Model>,
827 initial: Option<String>,
828 extra_messages: &[String],
829 initial_images: Vec<ImageContent>,
830 theme: Option<&str>,
831 no_themes: bool,
832 reload_context: &crate::session::ReloadContext,
833) -> i32 {
834 if let Some(reason) = crate::session::dispatch_session_event_async(
841 reload_context,
842 rpi_plugin_sdk::EventTag::BeforeTuiStart,
843 )
844 .await
845 {
846 eprintln!("[rpi] startup vetoed by extension: {reason}");
847 return crate::app::EXIT_VETOED;
848 }
849
850 let force_tui = std::env::var("RPI_FORCE_TUI")
852 .map(|v| v == "1")
853 .unwrap_or(false);
854 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
855 crate::interactive_tui::interactive_tui(
857 harness,
858 event_rx,
859 args,
860 model_catalog,
861 initial,
862 extra_messages,
863 initial_images,
864 theme,
865 no_themes,
866 reload_context,
867 )
868 .await
869 } else {
870 interactive_repl(harness, args, initial, extra_messages, initial_images).await
872 };
873
874 if let Some(reason) = crate::session::dispatch_session_event_async(
880 reload_context,
881 rpi_plugin_sdk::EventTag::SessionShutdown,
882 )
883 .await
884 {
885 tracing::warn!(
886 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
887 );
888 }
889
890 code
891}
892
893pub async fn interactive_repl(
895 harness: &AgentHarness,
896 #[allow(unused_variables)] args: &Args,
897 initial: Option<String>,
898 extra_messages: &[String],
899 initial_images: Vec<ImageContent>,
900) -> i32 {
901 let lane: Arc<dyn AgentLane> = harness.lane("main");
903 let stdin = std::io::stdin();
904 let is_tty = stdin.is_terminal();
905
906 if is_tty {
907 println!(
908 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
909 );
910 }
911
912 let mut prompts: Vec<String> = Vec::new();
914 if let Some(init) = initial {
915 prompts.push(init);
916 }
917 for m in extra_messages {
918 prompts.push(m.clone());
919 }
920 let mut images = initial_images;
921 for prompt in prompts {
922 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
923 return code;
924 }
925 }
926
927 let mut line = String::new();
929 loop {
930 if is_tty {
931 print!("> ");
932 let _ = std::io::stdout().flush();
933 }
934 line.clear();
935 match stdin.lock().read_line(&mut line) {
936 Ok(0) => break, Ok(_) => {}
938 Err(_) => break,
939 }
940 let trimmed = line.trim();
941 if trimmed.is_empty() {
942 continue;
943 }
944 if trimmed == "/exit" || trimmed == "/quit" {
945 break;
946 }
947 if trimmed == "/abort" {
948 let _ = lane.abort().await;
949 eprintln!("(aborted)");
950 continue;
951 }
952 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
953 return code;
954 }
955 }
956 0
957}
958
959async fn run_one(
963 lane: &Arc<dyn AgentLane>,
964 prompt: &str,
965 images: Vec<ImageContent>,
966) -> Result<(), i32> {
967 match lane.prompt_text(prompt, images).await {
968 Ok(result) => {
969 match &result.outcome {
970 HarnessRunOutcome::Completed { final_message, .. }
971 | HarnessRunOutcome::Aborted { final_message, .. } => {
972 let text = assistant_text(final_message);
973 if !text.is_empty() {
974 println!("{text}");
975 }
976 }
977 HarnessRunOutcome::Failed {
978 error,
979 final_message,
980 ..
981 } => {
982 if let Some(m) = final_message {
983 if let Some(em) = &m.error_message {
984 eprintln!("error: {em}");
985 }
986 }
987 eprintln!("run failed: {error:?}");
988 }
989 HarnessRunOutcome::Suspended { .. } => {
990 eprintln!("run suspended (deferred) — resume not supported in v1");
991 }
992 }
993 Ok(())
994 }
995 Err(e) => {
996 eprintln!("prompt rejected: {e}");
997 Err(1)
998 }
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use rpi_agent::events::AgentEvent;
1006 use rpi_agent::types::AgentToolResult;
1007 use rpi_ai::types::{
1008 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
1009 };
1010 use rpi_harness::session::types::OperationError;
1011
1012 #[derive(Clone, Default)]
1013 struct RpcOutput(Arc<Mutex<Vec<u8>>>);
1014
1015 impl Write for RpcOutput {
1016 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1017 self.0.lock().unwrap().extend_from_slice(bytes);
1018 Ok(bytes.len())
1019 }
1020 fn flush(&mut self) -> std::io::Result<()> {
1021 Ok(())
1022 }
1023 }
1024
1025 impl RpcOutput {
1026 fn messages(&self) -> Vec<serde_json::Value> {
1027 String::from_utf8(self.0.lock().unwrap().clone())
1028 .unwrap()
1029 .lines()
1030 .map(|line| serde_json::from_str(line).unwrap())
1031 .collect()
1032 }
1033
1034 async fn wait_for(
1035 &self,
1036 predicate: impl Fn(&serde_json::Value) -> bool,
1037 ) -> serde_json::Value {
1038 tokio::time::timeout(std::time::Duration::from_secs(3), async {
1039 loop {
1040 if let Some(message) = self.messages().into_iter().find(&predicate) {
1041 return message;
1042 }
1043 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1044 }
1045 })
1046 .await
1047 .expect("RPC message not received")
1048 }
1049 }
1050
1051 async fn rpc_test_harness(
1052 text: &str,
1053 tokens_per_second: f64,
1054 ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
1055 let provider = rpi_ai::providers::faux::FauxProvider::new(
1056 rpi_ai::providers::faux::FauxScript::new()
1057 .with_text(text)
1058 .with_tokens_per_second(tokens_per_second),
1059 );
1060 let options = rpi_harness::types::AgentHarnessOptions {
1061 model: provider.default_model().clone(),
1062 models: vec![provider.clone()],
1063 ..Default::default()
1064 };
1065 (AgentHarness::create(options).await.unwrap(), provider)
1066 }
1067
1068 fn start_test_rpc(
1069 harness: AgentHarness,
1070 ui: rpi_extensions::UiDialogMailbox,
1071 ) -> (
1072 tokio::io::DuplexStream,
1073 RpcOutput,
1074 tokio::task::JoinHandle<i32>,
1075 ) {
1076 let (client, input) = tokio::io::duplex(4096);
1077 let output = RpcOutput::default();
1078 let captured = output.clone();
1079 let task = tokio::spawn(async move {
1080 rpc_with_io(
1081 &harness,
1082 None,
1083 vec![],
1084 ui,
1085 tokio::io::BufReader::new(input),
1086 captured,
1087 )
1088 .await
1089 });
1090 (client, output, task)
1091 }
1092
1093 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1094 async fn rpc_eof_waits_for_accepted_prompt() {
1095 use tokio::io::AsyncWriteExt;
1096 let (harness, _) = rpc_test_harness("completed", 100.0).await;
1097 let ui = rpi_extensions::UiDialogMailbox::new();
1098 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1099 client
1100 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n")
1101 .await
1102 .unwrap();
1103 client.shutdown().await.unwrap();
1104 assert_eq!(
1105 tokio::time::timeout(std::time::Duration::from_secs(3), task)
1106 .await
1107 .unwrap()
1108 .unwrap(),
1109 0
1110 );
1111 let response = output
1112 .messages()
1113 .into_iter()
1114 .find(|m| m["id"] == 1)
1115 .unwrap();
1116 assert_eq!(response["result"]["outcome"], "completed");
1117 assert_eq!(response["result"]["finalText"], "completed");
1118 assert!(!ui.is_attached());
1119 }
1120
1121 #[tokio::test]
1122 async fn rpc_eof_drains_queued_final_events() {
1123 let harness = AgentHarness::create(Default::default()).await.unwrap();
1124 let (tx, rx) = tokio::sync::broadcast::channel(256);
1125 for _ in 0..100 {
1126 tx.send(AgentEvent::AgentStart).unwrap();
1127 }
1128 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
1129 let output = RpcOutput::default();
1130 let code = rpc_with_io(
1131 &harness,
1132 Some(rx),
1133 vec![],
1134 rpi_extensions::UiDialogMailbox::new(),
1135 tokio::io::BufReader::new(&b""[..]),
1136 output.clone(),
1137 )
1138 .await;
1139 assert_eq!(code, 0);
1140 let messages = output.messages();
1141 assert_eq!(
1142 messages.len(),
1143 102,
1144 "ready plus every queued event must be flushed"
1145 );
1146 assert_eq!(messages.last().unwrap()["type"], "agent_end");
1147 }
1148
1149 struct BackpressuredOutput {
1150 entered: Arc<std::sync::atomic::AtomicBool>,
1151 release: Arc<std::sync::atomic::AtomicBool>,
1152 output: RpcOutput,
1153 }
1154
1155 impl Write for BackpressuredOutput {
1156 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1157 self.entered
1158 .store(true, std::sync::atomic::Ordering::SeqCst);
1159 while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
1160 std::thread::sleep(std::time::Duration::from_millis(1));
1161 }
1162 self.output.write(bytes)
1163 }
1164 fn flush(&mut self) -> std::io::Result<()> {
1165 Ok(())
1166 }
1167 }
1168
1169 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1170 async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
1171 use std::sync::atomic::{AtomicBool, Ordering};
1172 let release = Arc::new(AtomicBool::new(false));
1173 struct ReleaseOnDrop(Arc<AtomicBool>);
1174 impl Drop for ReleaseOnDrop {
1175 fn drop(&mut self) {
1176 self.0.store(true, Ordering::SeqCst);
1177 }
1178 }
1179 let _release = ReleaseOnDrop(release.clone());
1180 let entered = Arc::new(AtomicBool::new(false));
1181 let output = RpcOutput::default();
1182 let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
1183 entered: entered.clone(),
1184 release: release.clone(),
1185 output: output.clone(),
1186 });
1187 write_line(&writer, &serde_json::json!({"id": 0})).await;
1188 tokio::time::timeout(std::time::Duration::from_secs(2), async {
1189 while !entered.load(Ordering::SeqCst) {
1190 tokio::task::yield_now().await;
1191 }
1192 })
1193 .await
1194 .unwrap();
1195 let producer_writer = writer.clone();
1196 let producer = tokio::spawn(async move {
1197 for id in 1..=100 {
1198 write_line(&producer_writer, &serde_json::json!({"id": id})).await;
1199 }
1200 });
1201 tokio::time::timeout(
1202 std::time::Duration::from_millis(150),
1203 tokio::time::sleep(std::time::Duration::from_millis(20)),
1204 )
1205 .await
1206 .unwrap();
1207 assert!(
1208 !producer.is_finished(),
1209 "the bounded queue must apply backpressure"
1210 );
1211 release.store(true, Ordering::SeqCst);
1212 producer.await.unwrap();
1213 drop(writer);
1214 task.await.unwrap().unwrap();
1215 let messages = output.messages();
1216 assert_eq!(messages.len(), 101);
1217 for (index, message) in messages.iter().enumerate() {
1218 assert_eq!(message["id"], index);
1219 }
1220 }
1221
1222 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1223 async fn rpc_completion_response_follows_final_event_under_backpressure() {
1224 use std::sync::atomic::{AtomicBool, Ordering};
1225 let (harness, _) = rpc_test_harness("completed", 0.0).await;
1226 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
1227 harness
1228 .set_agent_emitter(Some(Arc::new(emitter)))
1229 .await
1230 .unwrap();
1231 let output = RpcOutput::default();
1232 let release = Arc::new(AtomicBool::new(false));
1233 let release_writer = release.clone();
1234 let release_thread = std::thread::spawn(move || {
1235 std::thread::sleep(std::time::Duration::from_millis(50));
1236 release_writer.store(true, Ordering::SeqCst);
1237 });
1238 let code = rpc_with_io(
1239 &harness,
1240 Some(events),
1241 vec![],
1242 rpi_extensions::UiDialogMailbox::new(),
1243 tokio::io::BufReader::new(
1244 &b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n"[..],
1245 ),
1246 BackpressuredOutput {
1247 entered: Arc::new(AtomicBool::new(false)),
1248 release,
1249 output: output.clone(),
1250 },
1251 )
1252 .await;
1253 release_thread.join().unwrap();
1254 assert_eq!(code, 0);
1255 let messages = output.messages();
1256 let end = messages
1257 .iter()
1258 .position(|m| m["type"] == "agent_end")
1259 .unwrap();
1260 let response = messages.iter().position(|m| m["id"] == 1).unwrap();
1261 assert!(
1262 end < response,
1263 "a new prompt must not overtake the previous agent_end"
1264 );
1265 assert_eq!(messages[response]["result"]["outcome"], "completed");
1266 }
1267
1268 #[tokio::test]
1269 async fn rpc_output_failure_closes_without_waiting_for_more_input() {
1270 struct BrokenOutput;
1271 impl Write for BrokenOutput {
1272 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
1273 Err(std::io::Error::new(
1274 std::io::ErrorKind::BrokenPipe,
1275 "closed client output",
1276 ))
1277 }
1278 fn flush(&mut self) -> std::io::Result<()> {
1279 Ok(())
1280 }
1281 }
1282 let harness = AgentHarness::create(Default::default()).await.unwrap();
1283 let (_client, input) = tokio::io::duplex(4096);
1284 let ui = rpi_extensions::UiDialogMailbox::new();
1285 let code = tokio::time::timeout(
1286 std::time::Duration::from_secs(2),
1287 rpc_with_io(
1288 &harness,
1289 None,
1290 vec![],
1291 ui.clone(),
1292 tokio::io::BufReader::new(input),
1293 BrokenOutput,
1294 ),
1295 )
1296 .await
1297 .unwrap();
1298 assert_eq!(code, 1);
1299 assert!(!ui.is_attached());
1300 }
1301
1302 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1303 async fn rpc_stop_aborts_and_joins_active_prompt() {
1304 use tokio::io::AsyncWriteExt;
1305 let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
1306 let lane = harness.lane("main");
1307 let (mut client, output, task) =
1308 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
1309 client
1310 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n")
1311 .await
1312 .unwrap();
1313 tokio::time::timeout(std::time::Duration::from_secs(3), async {
1314 while provider
1315 .state()
1316 .call_count
1317 .load(std::sync::atomic::Ordering::SeqCst)
1318 == 0
1319 {
1320 tokio::task::yield_now().await;
1321 }
1322 })
1323 .await
1324 .unwrap();
1325 client
1326 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
1327 .await
1328 .unwrap();
1329 assert_eq!(
1330 tokio::time::timeout(std::time::Duration::from_secs(3), task)
1331 .await
1332 .unwrap()
1333 .unwrap(),
1334 0
1335 );
1336 let messages = output.messages();
1337 assert_eq!(
1338 messages.iter().find(|m| m["id"] == 1).unwrap()["result"]["outcome"],
1339 "aborted"
1340 );
1341 assert_eq!(
1342 messages.iter().find(|m| m["id"] == 2).unwrap()["result"]["stopped"],
1343 true
1344 );
1345 assert!(
1346 lane.abort().await.is_err(),
1347 "RPC returned with an active lane"
1348 );
1349 }
1350
1351 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1352 async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
1353 use tokio::io::AsyncWriteExt;
1354 let (harness, _) = rpc_test_harness("not completed", 20.0).await;
1355 let lane = harness.lane("main");
1356 let (mut client, output, task) =
1357 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
1358 client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
1359 assert_eq!(
1360 tokio::time::timeout(std::time::Duration::from_secs(3), task)
1361 .await
1362 .unwrap()
1363 .unwrap(),
1364 0
1365 );
1366 let response = output
1367 .messages()
1368 .into_iter()
1369 .find(|m| m["id"] == 1)
1370 .unwrap();
1371 assert!(
1372 response["result"]["outcome"] == "aborted" || response["error"] == "Operation aborted",
1373 "{response}"
1374 );
1375 assert!(lane.abort().await.is_err());
1376 }
1377
1378 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1379 async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
1380 use tokio::io::AsyncWriteExt;
1381 let (harness, _) =
1382 rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
1383 let ui = rpi_extensions::UiDialogMailbox::new();
1384 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1385 output.wait_for(|m| m["type"] == "ready").await;
1386 client
1387 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"content\":\"test\"}\n")
1388 .await
1389 .unwrap();
1390 ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
1391 .unwrap();
1392 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
1393 client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
1394 output.wait_for(|m| m["id"] == 2).await;
1395 assert_eq!(ui.poll("approval").unwrap()["answer"], true);
1396 output
1397 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
1398 .await;
1399 client.shutdown().await.unwrap();
1400 assert_eq!(
1401 tokio::time::timeout(std::time::Duration::from_secs(3), task)
1402 .await
1403 .unwrap()
1404 .unwrap(),
1405 0
1406 );
1407 assert_eq!(
1408 output
1409 .messages()
1410 .into_iter()
1411 .find(|m| m["id"] == 1)
1412 .unwrap()["result"]["outcome"],
1413 "completed"
1414 );
1415 }
1416
1417 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1418 async fn rpc_notifies_client_when_an_approval_is_cancelled() {
1419 use tokio::io::AsyncWriteExt;
1420 let harness = AgentHarness::create(Default::default()).await.unwrap();
1421 let ui = rpi_extensions::UiDialogMailbox::new();
1422 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1423 output.wait_for(|m| m["type"] == "ready").await;
1424 ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
1425 .unwrap();
1426 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
1427 ui.cancel_tool_call("c");
1428 output
1429 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
1430 .await;
1431 assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
1433 client.shutdown().await.unwrap();
1434 assert_eq!(task.await.unwrap(), 0);
1435 }
1436
1437 #[tokio::test]
1438 async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
1439 use crate::remote::protocol::RemoteCommand;
1440 use tokio::io::AsyncWriteExt;
1441 let harness = AgentHarness::create(Default::default()).await.unwrap();
1442 let ui = rpi_extensions::UiDialogMailbox::new();
1443 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1444 output.wait_for(|m| m["type"] == "ready").await;
1445 for (request_id, answer, response_id) in [
1446 (
1447 "answered",
1448 Some(serde_json::json!({"values":["No"],"kind":"selector"})),
1449 5,
1450 ),
1451 ("cancelled", None, 6),
1452 ] {
1453 ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
1454 .unwrap();
1455 output
1456 .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
1457 .await;
1458 let command = RemoteCommand::UiDialogResponse {
1459 request_id: request_id.into(),
1460 cancelled: answer.is_none(),
1461 answer,
1462 }
1463 .with_id(response_id);
1464 client
1465 .write_all(format!("{command}\n").as_bytes())
1466 .await
1467 .unwrap();
1468 let ack = output.wait_for(|m| m["id"] == response_id).await;
1469 assert_eq!(ack["result"]["accepted"], true);
1470 let result = ui.poll(request_id).unwrap();
1471 if request_id == "answered" {
1472 assert_eq!(result["answer"]["values"][0], "No");
1473 } else {
1474 assert_eq!(result["status"], "cancelled");
1475 }
1476 }
1477 client.shutdown().await.unwrap();
1478 assert_eq!(task.await.unwrap(), 0);
1479 }
1480
1481 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
1482 AssistantMessage {
1483 role: rpi_ai::types::AssistantRole,
1484 content: vec![Content::Text(TextContent {
1485 kind: TextContentType,
1486 text: text.into(),
1487 text_signature: None,
1488 })],
1489 api: rpi_ai::Api::AnthropicMessages,
1490 provider: "anthropic".into(),
1491 model: "claude-sonnet-5".into(),
1492 response_model: None,
1493 response_id: None,
1494 usage: Usage::zero(),
1495 stop_reason: stop,
1496 deferred: None,
1497 error_message: None,
1498 raw_stop_reason: None,
1499 end_turn: None,
1500 timestamp: 0,
1501 }
1502 }
1503
1504 #[test]
1505 fn assistant_text_concatenates_text_blocks() {
1506 let m = assistant("hello", StopReason::Stop);
1507 assert_eq!(assistant_text(&m), "hello");
1508 }
1509
1510 #[test]
1511 fn outcome_exit_code_maps_failed_aborted_to_1() {
1512 let failed = HarnessRunOutcome::Failed {
1513 leaf_id: "l".into(),
1514 error: OperationError {
1515 code: "boom".into(),
1516 message: "boom".into(),
1517 },
1518 final_entry_id: None,
1519 final_message: None,
1520 };
1521 assert_eq!(outcome_exit_code(&failed), 1);
1522 let completed = HarnessRunOutcome::Completed {
1523 leaf_id: "l".into(),
1524 final_entry_id: "e".into(),
1525 final_message: assistant("ok", StopReason::Stop),
1526 };
1527 assert_eq!(outcome_exit_code(&completed), 0);
1528 }
1529
1530 #[test]
1531 fn run_end_outcome_str_roundtrip() {
1532 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
1533 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
1534 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
1535 }
1536
1537 #[test]
1538 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
1539 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
1540 assert_eq!(end["type"], "agent_end");
1541 assert_eq!(end["messages"], serde_json::json!([]));
1542
1543 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
1544 tool_call_id: "call-1".into(),
1545 tool_name: "read".into(),
1546 result: AgentToolResult::text("hello"),
1547 is_error: false,
1548 });
1549 assert_eq!(tool["type"], "tool_execution_end");
1550 assert_eq!(tool["result"]["content"][0]["text"], "hello");
1551 assert_eq!(tool["result"]["terminate"], false);
1552
1553 let retry = agent_event_json(&AgentEvent::RetryScheduled {
1554 attempt: 3,
1555 max_retries: 10,
1556 delay_ms: 8_000,
1557 error: "503 service unavailable".into(),
1558 });
1559 assert_eq!(retry["type"], "retry_scheduled");
1560 assert_eq!(retry["attempt"], 3);
1561 assert_eq!(retry["maxRetries"], 10);
1562 assert_eq!(retry["delayMs"], 8_000);
1563 assert_eq!(retry["error"], "503 service unavailable");
1564 }
1565}