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, UserMessage};
23use rpi_harness::agent_harness::{
24 AgentHarness, AgentLane, CompactionOutcome, CompactionResult, HarnessRunOutcome,
25};
26use rpi_harness::events::{HarnessEvent, RunEndOutcome};
27use rpi_harness::session::types::{EntryOrder, LaneRecord, RecordQuery};
28
29use crate::args::Args;
30use crate::rpc::protocol as rpc_protocol;
31use crate::rpc::writer::{start_rpc_writer, write_line, RpcWriter};
32
33pub fn assistant_text(msg: &AssistantMessage) -> String {
36 msg.content
37 .iter()
38 .filter_map(|c| match c {
39 Content::Text(t) => Some(t.text.clone()),
40 _ => None,
41 })
42 .collect()
43}
44
45pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
48 match outcome {
49 HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
50 _ => 0,
51 }
52}
53
54pub async fn print(
58 harness: &AgentHarness,
59 _args: &Args,
60 initial: Option<String>,
61 extra_messages: &[String],
62 initial_images: Vec<ImageContent>,
63) -> i32 {
64 let lane: Arc<dyn AgentLane> = harness.lane("main");
65
66 let mut last_exit = 0;
67 let mut last_msg: Option<AssistantMessage> = None;
68
69 let mut prompts: Vec<String> = Vec::new();
72 if let Some(init) = initial {
73 prompts.push(init);
74 }
75 for m in extra_messages {
76 prompts.push(m.clone());
77 }
78
79 if prompts.is_empty() {
80 return 0;
82 }
83
84 let mut images = initial_images;
85 for prompt in prompts {
86 match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
87 Ok(result) => {
88 last_exit = outcome_exit_code(&result.outcome);
89 match &result.outcome {
90 HarnessRunOutcome::Completed { final_message, .. }
91 | HarnessRunOutcome::Aborted { final_message, .. } => {
92 last_msg = Some(final_message.clone());
93 }
94 HarnessRunOutcome::Failed {
95 error,
96 final_message,
97 ..
98 } => {
99 if let Some(m) = final_message {
100 if m.stop_reason == StopReason::Error {
101 if let Some(em) = &m.error_message {
102 eprintln!("{em}");
103 }
104 }
105 }
106 eprintln!("run failed: {error:?}");
107 }
108 HarnessRunOutcome::Suspended { .. } => {
109 eprintln!("run suspended (deferred) — resume is not supported in v1");
110 last_exit = 1;
111 }
112 }
113 }
114 Err(e) => {
115 eprintln!("prompt rejected: {e}");
116 return 1;
117 }
118 }
119 }
120
121 if let Some(m) = &last_msg {
123 match m.stop_reason {
124 StopReason::Error => {
125 if let Some(em) = &m.error_message {
126 eprintln!("{em}");
127 }
128 last_exit = 1;
129 }
130 StopReason::Aborted => {
131 eprintln!("request aborted");
132 last_exit = 1;
133 }
134 _ => {
135 let text = assistant_text(m);
136 let mut out = std::io::stdout();
137 let _ = out.write_all(text.as_bytes());
138 if !text.ends_with('\n') {
139 let _ = out.write_all(b"\n");
140 }
141 let _ = out.flush();
142 }
143 }
144 }
145
146 last_exit
147}
148
149pub async fn json(
153 harness: &AgentHarness,
154 _args: &Args,
155 initial: Option<String>,
156 extra_messages: &[String],
157 initial_images: Vec<ImageContent>,
158 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
159) -> i32 {
160 let lane: Arc<dyn AgentLane> = harness.lane("main");
161 let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
162 let collected_for_watch = collected.clone();
163
164 let mut watch = harness.events().watch(|| ());
167 watch.start(Arc::new(move |event: &HarnessEvent| {
168 emit_json_event(event);
170 collected_for_watch.lock().unwrap().push(event.clone());
171 }));
172 std::mem::forget(watch);
175
176 let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
179 let agent_event_task = agent_events.take().map(|mut rx| {
180 tokio::spawn(async move {
181 let done_tx = agent_done_tx;
182 loop {
183 match rx.recv().await {
184 Ok(event) => {
185 let terminal = event.is_terminal();
186 emit_agent_event(&event);
187 if terminal {
188 let _ = done_tx.send(());
189 }
190 }
191 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
192 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
193 }
194 }
195 })
196 });
197
198 let mut prompts: Vec<String> = Vec::new();
199 if let Some(init) = initial {
200 prompts.push(init);
201 }
202 for m in extra_messages {
203 prompts.push(m.clone());
204 }
205
206 let mut last_exit = 0;
207 let mut final_outcome: Option<HarnessRunOutcome> = None;
208
209 let mut images = initial_images;
210 for prompt in prompts {
211 match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
212 Ok(result) => {
213 let _ = tokio::time::timeout(
219 std::time::Duration::from_millis(250),
220 agent_done_rx.recv(),
221 )
222 .await;
223 last_exit = outcome_exit_code(&result.outcome);
224 final_outcome = Some(result.outcome);
225 }
226 Err(e) => {
227 let line = serde_json::json!({
229 "type": "error",
230 "error": e.to_string(),
231 });
232 println!("{line}");
233 if let Some(task) = agent_event_task {
234 task.abort();
235 }
236 return 1;
237 }
238 }
239 }
240
241 let (outcome_str, final_text) = match final_outcome {
243 Some(HarnessRunOutcome::Completed { final_message, .. }) => {
244 ("completed", Some(assistant_text(&final_message)))
245 }
246 Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
247 ("aborted", Some(assistant_text(&final_message)))
248 }
249 Some(HarnessRunOutcome::Failed { final_message, .. }) => {
250 let t = final_message.as_ref().map(assistant_text);
251 ("failed", t)
252 }
253 Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
254 None => ("idle", None),
255 };
256 let result_line = serde_json::json!({
257 "type": "result",
258 "outcome": outcome_str,
259 "finalText": final_text,
260 });
261 println!("{result_line}");
262 if let Some(task) = agent_event_task {
263 task.abort();
264 }
265 last_exit
266}
267
268fn emit_agent_event(event: &AgentEvent) {
269 println!("{}", agent_event_json(event));
270}
271
272pub async fn rpc(
291 harness: &AgentHarness,
292 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
293 model_catalog: Vec<rpi_ai::Model>,
294 ui_dialog: rpi_extensions::UiDialogMailbox,
295 oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
296) -> i32 {
297 rpc_with_io(
298 harness,
299 agent_events.take(),
300 model_catalog,
301 ui_dialog,
302 oauth_snapshot,
303 tokio::io::BufReader::new(tokio::io::stdin()),
304 std::io::stdout(),
305 )
306 .await
307}
308
309async fn rpc_with_io<R, W>(
310 harness: &AgentHarness,
311 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
312 model_catalog: Vec<rpi_ai::Model>,
313 ui_dialog: rpi_extensions::UiDialogMailbox,
314 oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
315 input: R,
316 output: W,
317) -> i32
318where
319 R: tokio::io::AsyncBufRead + Unpin,
320 W: Write + Send + 'static,
321{
322 let lane: Arc<dyn AgentLane> = harness.lane("main");
323 let (stdout, writer_task, writer_failed) = start_rpc_writer(output);
326 let pump_stop = tokio_util::sync::CancellationToken::new();
327
328 let (event_flush_tx, mut event_flush_rx) =
331 tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
332 let event_task = agent_events.take().map(|mut rx| {
334 let out = stdout.clone();
335 let stop = pump_stop.clone();
336 tokio::spawn(async move {
337 loop {
338 let received = tokio::select! {
339 _ = stop.cancelled() => {
340 drain_rpc_events(&out, &mut rx).await;
341 break;
342 }
343 Some(done) = event_flush_rx.recv() => {
344 drain_rpc_events(&out, &mut rx).await;
345 let _ = done.send(());
346 continue;
347 }
348 event = rx.recv() => event,
349 };
350 match received {
351 Ok(event) => write_line(&out, &agent_event_json(&event)).await,
352 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
353 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
354 }
355 }
356 })
357 });
358
359 let event_flush = event_task.as_ref().map(|_| event_flush_tx);
360
361 ui_dialog.attach();
367 let dialog_task = {
368 let mailbox = ui_dialog.clone();
369 let out = stdout.clone();
370 let stop = pump_stop.clone();
371 tokio::spawn(async move {
372 let mut shown = std::collections::HashSet::new();
373 while !stop.is_cancelled() {
374 while let Some(request) = mailbox.take_pending() {
375 let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
376 request_id: request.request_id.clone(),
377 tool_call_id: request.tool_call_id,
378 ui: request.ui,
379 };
380 write_line(&out, &serde_json::to_value(event).unwrap()).await;
381 shown.insert(request.request_id);
382 }
383 let closed: Vec<String> = shown
384 .iter()
385 .filter(|id| !mailbox.is_pending(id))
386 .cloned()
387 .collect();
388 for request_id in closed {
389 shown.remove(&request_id);
390 let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
391 write_line(&out, &serde_json::to_value(event).unwrap()).await;
392 }
393 tokio::select! {
394 _ = stop.cancelled() => break,
395 _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
396 }
397 }
398 })
399 };
400
401 let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
405 let mut run_task: Option<tokio::task::JoinHandle<()>> = None;
406 let mut run_cancel = tokio_util::sync::CancellationToken::new();
407 let mut cancel_on_exit = false;
408
409 use tokio::io::AsyncBufReadExt;
410
411 let mut lines = input.lines();
412 write_line(&stdout, &rpc_protocol::ready()).await;
413
414 loop {
415 let incoming = tokio::select! {
416 _ = writer_failed.cancelled() => {
417 cancel_on_exit = true;
418 break;
419 }
420 line = lines.next_line() => line,
421 };
422 let line = match incoming {
423 Ok(Some(line)) => line,
424 Ok(None) => break, Err(error) => {
426 eprintln!("[rpi] rpc stdin error: {error}");
427 cancel_on_exit = true;
428 break;
429 }
430 };
431 let line = line.trim();
432 if line.is_empty() {
433 continue;
434 }
435 let msg: serde_json::Value = match serde_json::from_str(line) {
436 Ok(value) => value,
437 Err(error) => {
438 write_line(
439 &stdout,
440 &rpc_protocol::response(
441 None,
442 "invalid_request",
443 Err(format!("invalid json: {error}")),
444 ),
445 )
446 .await;
447 continue;
448 }
449 };
450 let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
451 let kind = msg
452 .get("type")
453 .and_then(|v| v.as_str())
454 .unwrap_or("")
455 .to_string();
456
457 if rpc_protocol::SUPPORTED_COMMANDS.contains(&kind.as_str())
458 && serde_json::from_value::<rpc_protocol::Request>(msg.clone()).is_err()
459 {
460 write_line(
461 &stdout,
462 &rpc_protocol::response(
463 Some(id.clone()),
464 &kind,
465 Err("invalid command payload".to_string()),
466 ),
467 )
468 .await;
469 continue;
470 }
471
472 match kind.as_str() {
473 "ui_dialog_response" => {
474 let request_id = msg
475 .get("requestId")
476 .and_then(|v| v.as_str())
477 .unwrap_or("")
478 .to_string();
479 let result = if msg
480 .get("cancelled")
481 .and_then(|v| v.as_bool())
482 .unwrap_or(false)
483 {
484 ui_dialog.cancel(&request_id)
485 } else {
486 let answer = msg
487 .get("answer")
488 .cloned()
489 .unwrap_or(serde_json::Value::Null);
490 ui_dialog.respond(&request_id, answer)
491 };
492 if msg.get("id").is_some() {
493 let response = match result {
494 Ok(()) => rpc_protocol::response(
495 Some(id),
496 "ui_dialog_response",
497 Ok(Some(
498 serde_json::json!({"accepted":true,"requestId":request_id}),
499 )),
500 ),
501 Err(error) => {
502 rpc_protocol::response(Some(id), "ui_dialog_response", Err(error))
503 }
504 };
505 write_line(&stdout, &response).await;
506 } else if let Err(error) = result {
507 eprintln!("[rpi] ui_dialog_response: {error}");
508 }
509 continue;
510 }
511 "stop" => {
512 cancel_on_exit = true;
513 write_line(
514 &stdout,
515 &rpc_protocol::response(
516 Some(id.clone()),
517 "stop",
518 Ok(Some(serde_json::json!({"stopped": true}))),
519 ),
520 )
521 .await;
522 break;
523 }
524 "prompt" | "resume_pending" | "compact" => {
525 let resume = kind == "resume_pending";
526 let compact = kind == "compact";
527 if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
528 write_line(
529 &stdout,
530 &rpc_protocol::response(
531 Some(id.clone()),
532 &kind,
533 Err("a run is already active".to_string()),
534 ),
535 )
536 .await;
537 continue;
538 }
539 if let Some(task) = run_task.take() {
540 let _ = task.await;
541 }
542 run_cancel = tokio_util::sync::CancellationToken::new();
543 let cancel_task = run_cancel.clone();
544 if resume && !lane.has_pending_resume().await {
547 run_active.store(false, std::sync::atomic::Ordering::Release);
548 write_line(
549 &stdout,
550 &rpc_protocol::response(
551 Some(id),
552 "resume_pending",
553 Ok(Some(serde_json::json!({"resumed": false}))),
554 ),
555 )
556 .await;
557 continue;
558 }
559 let content = msg
560 .get("message")
561 .and_then(|v| v.as_str())
562 .map(str::to_owned);
563 let lane_task = Arc::clone(&lane);
564 let out_task = stdout.clone();
565 let active_task = Arc::clone(&run_active);
566 let mailbox_task = ui_dialog.clone();
567 let flush_events = event_flush.clone();
568 let Some(content) = content.or_else(|| (resume || compact).then(String::new))
569 else {
570 active_task.store(false, std::sync::atomic::Ordering::Release);
571 write_line(
572 &stdout,
573 &rpc_protocol::response(
574 Some(id),
575 "prompt",
576 Err("prompt requires a string `message`".to_string()),
577 ),
578 )
579 .await;
580 continue;
581 };
582 write_line(
583 &stdout,
584 &rpc_protocol::response(
585 Some(id),
586 &kind,
587 Ok(Some(serde_json::json!({"accepted": true}))),
588 ),
589 )
590 .await;
591 let instructions = msg
592 .get("customInstructions")
593 .and_then(|v| v.as_str())
594 .map(str::to_owned);
595 run_task = Some(tokio::spawn(async move {
596 let result = if compact {
597 rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
598 } else if resume {
599 rpc_resume_cancellable(&lane_task, cancel_task).await
600 } else {
601 rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
602 };
603 if let Some(flush) = flush_events {
604 let (done, drained) = tokio::sync::oneshot::channel();
605 if flush.send(done).is_ok() {
606 let _ = drained.await;
607 }
608 }
609 let (outcome, aborted, error) = match &result {
610 Ok(value) => {
611 let outcome = value
612 .get("outcome")
613 .and_then(|v| v.as_str())
614 .unwrap_or("completed")
615 .to_string();
616 let error = value
617 .get("error")
618 .and_then(|v| v.as_str())
619 .map(str::to_owned);
620 (outcome.clone(), outcome == "aborted", error)
621 }
622 Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
623 };
624 let pending_resume = lane_task.has_pending_resume().await;
627 let summary = result
628 .as_ref()
629 .ok()
630 .and_then(|value| value.get("summary"))
631 .cloned();
632 let settled = serde_json::json!({
633 "type": "agent_settled",
634 "runId": result.as_ref().ok().and_then(|value| value.get("runId")),
635 "outcome": outcome,
636 "aborted": aborted,
637 "error": error,
638 "summary": summary,
639 "pendingResume": pending_resume,
640 });
641 write_line(&out_task, &settled).await;
642 mailbox_task.cancel_all();
643 active_task.store(false, std::sync::atomic::Ordering::Release);
644 }));
645 continue;
646 }
647 _ => {}
648 }
649
650 let result = match kind.as_str() {
651 "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
652 run_cancel.cancel();
654 Ok(serde_json::json!({"aborted": true}))
655 }
656 "abort" => lane
657 .abort()
658 .await
659 .map(|_| serde_json::json!({"aborted": true}))
660 .map_err(|e| e.to_string()),
661 "get_state" => Ok(rpc_get_state(&lane, harness).await),
662 "get_messages" => rpc_get_messages(&lane, harness, &msg).await,
663 "get_history" => rpc_get_history(&lane, harness, &msg).await,
664 "get_stats" => rpc_get_stats(&lane, harness).await,
665 "get_queue" => rpc_get_queue(&lane, harness, &msg).await,
666 "queue_message" => rpc_queue_message(&lane, &msg).await,
667 "cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
668 "clear_queue" => rpc_clear_queue(&lane).await,
669 "navigate" => rpc_navigate(&lane, &msg).await,
670 "get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
671 "get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
672 "oauth" => rpc_oauth(&oauth_snapshot, &msg),
673 "set_api_key" => rpc_set_api_key(&msg).await,
674 "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
675 "get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
676 "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
677 "get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
678 "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
679 "ping" => Ok(serde_json::json!({"pong": true})),
680 other => Err(format!("unknown command type: {other}")),
681 };
682
683 let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
684 write_line(&stdout, &response).await;
685 }
686
687 ui_dialog.detach();
691 if cancel_on_exit {
692 run_cancel.cancel();
693 }
694 let mut exit_code = 0;
695 if let Some(task) = run_task {
696 if let Err(error) = task.await {
697 eprintln!("[rpi] rpc prompt task failed: {error}");
698 exit_code = 1;
699 }
700 }
701 pump_stop.cancel();
702 let _ = dialog_task.await;
703 if let Some(task) = event_task {
704 let _ = task.await;
705 }
706 drop(stdout);
708 match writer_task.await {
709 Ok(Ok(())) => {}
710 error => {
711 eprintln!("[rpi] rpc output failed: {error:?}");
712 exit_code = 1;
713 }
714 }
715 exit_code
716}
717
718async fn drain_rpc_events(
719 out: &RpcWriter,
720 events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
721) {
722 loop {
723 match events.try_recv() {
724 Ok(event) => write_line(out, &agent_event_json(&event)).await,
725 Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
726 Err(_) => break,
727 }
728 }
729}
730
731async fn rpc_prompt_cancellable(
734 lane: &Arc<dyn AgentLane>,
735 content: &str,
736 cancel: tokio_util::sync::CancellationToken,
737) -> Result<serde_json::Value, String> {
738 let prompt = rpc_prompt_text(lane, content);
739 tokio::pin!(prompt);
740 tokio::select! {
741 biased;
742 _ = cancel.cancelled() => {
743 match lane.abort().await {
744 Ok(_) => prompt.await,
745 Err(_) => Err("Operation aborted".to_string()),
746 }
747 }
748 result = &mut prompt => result,
749 }
750}
751
752async fn rpc_compact_cancellable(
758 lane: &Arc<dyn AgentLane>,
759 instructions: Option<String>,
760 cancel: tokio_util::sync::CancellationToken,
761) -> Result<serde_json::Value, String> {
762 let compact = async {
763 match lane.compact(instructions.as_deref()).await {
764 Ok(result) => Ok(compaction_json(&result)),
765 Err(error) => Err(error.to_string()),
766 }
767 };
768 tokio::pin!(compact);
769 tokio::select! {
770 biased;
771 _ = cancel.cancelled() => {
772 let _ = lane.abort().await;
773 compact.await
774 }
775 result = &mut compact => result,
776 }
777}
778
779fn compaction_json(result: &CompactionResult) -> serde_json::Value {
782 let mut value = match &result.outcome {
783 CompactionOutcome::Completed { leaf_id, entry } => {
784 let (tokens_before, retained) = match entry {
785 rpi_harness::session::types::Entry::Compaction(compaction) => {
786 (compaction.tokens_before, compaction.retained_tail.len())
787 }
788 _ => (0, 0),
789 };
790 serde_json::json!({
791 "outcome": "compacted",
792 "leafId": leaf_id,
793 "tokensBefore": tokens_before,
794 "retainedMessages": retained,
795 "summary": format!(
796 "Conversation compacted · {} tokens before, {} messages kept.",
797 tokens_before, retained
798 ),
799 })
800 }
801 CompactionOutcome::Declined { leaf_id } => serde_json::json!({
802 "outcome": "declined",
803 "leafId": leaf_id,
804 "summary": "Nothing to compact yet.",
805 }),
806 CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
807 "outcome": "aborted",
808 "leafId": leaf_id,
809 "summary": "Compaction aborted.",
810 }),
811 CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
812 "outcome": "failed",
813 "leafId": leaf_id,
814 "error": error.message,
815 "summary": format!("Compaction failed: {}", error.message),
816 }),
817 };
818 value["runId"] = serde_json::json!(result.run_id);
819 value
820}
821
822async fn rpc_resume_cancellable(
827 lane: &Arc<dyn AgentLane>,
828 cancel: tokio_util::sync::CancellationToken,
829) -> Result<serde_json::Value, String> {
830 let resume = async {
831 match lane.resume_pending().await {
832 Ok(Some(result)) => {
833 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
834 Ok(serde_json::json!({
835 "runId": result.run_id,
836 "outcome": outcome,
837 "finalText": final_text,
838 "error": error,
839 "resumed": true,
840 }))
841 }
842 Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
843 Err(error) => Err(error.to_string()),
844 }
845 };
846 tokio::pin!(resume);
847 tokio::select! {
848 biased;
849 _ = cancel.cancelled() => {
850 let _ = lane.abort().await;
851 resume.await
852 }
853 result = &mut resume => result,
854 }
855}
856
857fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
858 match outcome {
859 HarnessRunOutcome::Completed { final_message, .. } => {
860 ("completed", Some(assistant_text(final_message)), None)
861 }
862 HarnessRunOutcome::Aborted { final_message, .. } => {
863 ("aborted", Some(assistant_text(final_message)), None)
864 }
865 HarnessRunOutcome::Failed {
868 final_message,
869 error,
870 ..
871 } => (
872 "failed",
873 final_message.as_ref().map(assistant_text),
874 Some(error.message.clone()),
875 ),
876 HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
877 }
878}
879
880async fn rpc_prompt_text(
881 lane: &Arc<dyn AgentLane>,
882 content: &str,
883) -> Result<serde_json::Value, String> {
884 let result = lane
885 .prompt_text(content, Vec::new())
886 .await
887 .map_err(|e| e.to_string())?;
888 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
889 Ok(
890 serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
891 )
892}
893
894async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
895 let model = lane.get_model().await.ok();
896 let thinking = lane
897 .get_thinking_level()
898 .await
899 .ok()
900 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
901 let active_tools = lane.get_active_tools().await.unwrap_or_default();
902 let leaf_id = lane.get_leaf_id().await.ok().flatten();
903 let context = rpc_context_usage(lane, harness).await;
904 let active_operation = lane
905 .snapshot()
906 .await
907 .ok()
908 .and_then(|snapshot| snapshot.active)
909 .map(|active| {
910 serde_json::json!({
911 "runId": active.run_id,
912 "lane": active.lane,
913 "kind": format!("{:?}", active.kind).to_lowercase(),
914 })
915 });
916 let queued = lane.queued_messages().await.unwrap_or_default();
917 let stats = rpc_get_stats(lane, harness).await.ok();
918 serde_json::json!({
919 "modelId": model.as_ref().map(|model| model.id.clone()),
920 "provider": model.as_ref().map(|model| model.provider.clone()),
921 "thinkingLevel": thinking,
922 "activeTools": active_tools,
923 "leafId": leaf_id,
924 "context": context,
925 "sessionId": harness.session().storage().metadata().id,
926 "activeOperation": active_operation,
927 "queue": {
928 "steering": queued.steering,
929 "followUp": queued.follow_up,
930 "steeringCount": queued.steering.len(),
931 "followUpCount": queued.follow_up.len(),
932 },
933 "stats": stats,
934 })
935}
936
937fn rpc_now_ms() -> i64 {
938 std::time::SystemTime::now()
939 .duration_since(std::time::UNIX_EPOCH)
940 .map(|duration| duration.as_millis() as i64)
941 .unwrap_or_default()
942}
943
944fn rpc_message_text(value: &serde_json::Value) -> String {
945 let Some(content) = value.get("content") else {
946 return String::new();
947 };
948 if let Some(text) = content.as_str() {
949 return text.to_string();
950 }
951 content
952 .as_array()
953 .into_iter()
954 .flatten()
955 .filter_map(|block| block.get("text").and_then(|text| text.as_str()))
956 .collect::<Vec<_>>()
957 .join("\\n")
958}
959
960async fn rpc_get_history(
961 lane: &Arc<dyn AgentLane>,
962 harness: &AgentHarness,
963 msg: &serde_json::Value,
964) -> Result<serde_json::Value, String> {
965 let limit = msg
966 .get("limit")
967 .and_then(serde_json::Value::as_u64)
968 .unwrap_or(100)
969 .clamp(1, 500) as usize;
970 let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
971 let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
972 let mut records = harness
973 .session()
974 .find_records(&RecordQuery {
975 lane: Some(lane.name().to_string()),
976 after_seq,
977 order: Some(EntryOrder::OldestFirst),
978 ..Default::default()
979 })
980 .await
981 .map_err(|error| error.to_string())?;
982 if let Some(before) = before_seq {
983 records.retain(|record| record.seq() < before);
984 }
985 let total = records.len();
986 let has_more = records.len() > limit;
987 if before_seq.is_some() {
988 if has_more {
989 records = records.split_off(records.len() - limit);
990 }
991 } else if after_seq.is_some() {
992 records.truncate(limit);
993 } else if has_more {
994 records = records.split_off(records.len() - limit);
995 }
996 let first_seq = records.first().map(LaneRecord::seq);
997 let last_seq = records.last().map(LaneRecord::seq);
998 let open_operations = harness
999 .session()
1000 .find_open_operations(lane.name(), Some(50))
1001 .await
1002 .map_err(|error| error.to_string())?;
1003 Ok(serde_json::json!({
1004 "records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1005 "total": total,
1006 "limit": limit,
1007 "afterSeq": after_seq,
1008 "beforeSeq": before_seq,
1009 "firstSeq": first_seq,
1010 "lastSeq": last_seq,
1011 "hasMore": has_more,
1012 "leafId": lane.get_leaf_id().await.ok().flatten(),
1013 "openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1014 }))
1015}
1016
1017async fn rpc_get_stats(
1018 lane: &Arc<dyn AgentLane>,
1019 harness: &AgentHarness,
1020) -> Result<serde_json::Value, String> {
1021 let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
1022 let records = harness
1023 .session()
1024 .find_records(&RecordQuery {
1025 lane: Some(lane.name().to_string()),
1026 order: Some(EntryOrder::OldestFirst),
1027 ..Default::default()
1028 })
1029 .await
1030 .map_err(|error| error.to_string())?;
1031 let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
1032 for record in &records {
1033 let kind = match record {
1034 LaneRecord::OperationStarted(started) => {
1035 let key = started.intent.kind();
1036 *operation_counts.entry(key.to_string()).or_default() += 1;
1037 if matches!(
1038 &started.intent,
1039 rpi_harness::session::types::OperationIntent::Run {
1040 resume_data: Some(_),
1041 ..
1042 }
1043 ) {
1044 *operation_counts.entry("resume".to_string()).or_default() += 1;
1045 }
1046 continue;
1047 }
1048 LaneRecord::OperationFinished(_) => "operation_finished",
1049 LaneRecord::RetryPending(_) => "retry_pending",
1050 LaneRecord::Usage(_) => "usage",
1051 _ => continue,
1052 };
1053 *operation_counts.entry(kind.to_string()).or_default() += 1;
1054 }
1055 let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
1056 let model = lane.get_model().await.ok();
1057 let input = stats.cached_tokens + stats.uncached_tokens;
1058 Ok(serde_json::json!({
1059 "messageCount": stats.message_count,
1060 "operationCount": count("operation_finished"),
1061 "retryCount": count("retry_pending"),
1062 "compactionCount": count("compaction"),
1063 "resumeCount": count("resume"),
1064 "operationCounts": operation_counts,
1065 "provider": model.as_ref().map(|value| value.provider.clone()),
1066 "modelId": model.as_ref().map(|value| value.id.clone()),
1067 "cachedTokens": stats.cached_tokens,
1068 "uncachedTokens": stats.uncached_tokens,
1069 "totalTokens": stats.total_tokens,
1070 "costTotal": stats.cost_total,
1071 "cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
1072 }))
1073}
1074
1075async fn rpc_get_queue(
1076 lane: &Arc<dyn AgentLane>,
1077 harness: &AgentHarness,
1078 msg: &serde_json::Value,
1079) -> Result<serde_json::Value, String> {
1080 let include_completed = msg
1081 .get("includeCompleted")
1082 .and_then(serde_json::Value::as_bool)
1083 .unwrap_or(false);
1084 let records = harness
1085 .session()
1086 .find_records(&RecordQuery {
1087 lane: Some(lane.name().to_string()),
1088 order: Some(EntryOrder::OldestFirst),
1089 ..Default::default()
1090 })
1091 .await
1092 .map_err(|error| error.to_string())?;
1093 let mut cancelled = std::collections::HashMap::<String, u64>::new();
1094 for record in &records {
1095 if let LaneRecord::QueueCancelled(record) = record {
1096 cancelled.insert(record.entry_id.clone(), record.base.seq);
1097 }
1098 }
1099 let mut items = Vec::new();
1100 for record in records {
1101 let LaneRecord::QueueEnqueued(record) = record else {
1102 continue;
1103 };
1104 let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
1105 continue;
1106 };
1107 let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
1108 ("cancelled", Some(*seq))
1109 } else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
1110 ("consumed", Some(entry.base().seq))
1111 } else {
1112 ("queued", None)
1113 };
1114 if status != "queued" && !include_completed {
1115 continue;
1116 }
1117 let message = record
1118 .target
1119 .get("message")
1120 .cloned()
1121 .unwrap_or(serde_json::Value::Null);
1122 items.push(serde_json::json!({
1123 "entryId": entry_id,
1124 "queue": record.queue,
1125 "status": status,
1126 "text": rpc_message_text(&message),
1127 "message": message,
1128 "runId": record.run_id,
1129 "seq": record.base.seq,
1130 "completedSeq": completed_seq,
1131 }));
1132 }
1133 Ok(
1134 serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
1135 )
1136}
1137
1138async fn rpc_queue_message(
1139 lane: &Arc<dyn AgentLane>,
1140 msg: &serde_json::Value,
1141) -> Result<serde_json::Value, String> {
1142 let text = msg
1143 .get("message")
1144 .and_then(serde_json::Value::as_str)
1145 .filter(|text| !text.trim().is_empty())
1146 .ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
1147 let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
1148 let queue = msg
1149 .get("deliverAs")
1150 .and_then(serde_json::Value::as_str)
1151 .unwrap_or("followUp");
1152 let result = match queue {
1153 "steer" => lane.steer(message).await,
1154 "followUp" | "follow_up" => lane.follow_up(message).await,
1155 "nextTurn" | "next_run" => lane.next_run(message).await,
1156 other => return Err(format!("unknown queue kind: {other}")),
1157 }
1158 .map_err(|error| error.to_string())?;
1159 Ok(serde_json::json!({
1160 "accepted": true,
1161 "entryId": result.entry_id,
1162 "queue": queue,
1163 }))
1164}
1165
1166async fn rpc_cancel_queued(
1167 lane: &Arc<dyn AgentLane>,
1168 msg: &serde_json::Value,
1169) -> Result<serde_json::Value, String> {
1170 let entry_id = msg
1171 .get("entryId")
1172 .and_then(serde_json::Value::as_str)
1173 .filter(|value| !value.is_empty())
1174 .ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
1175 let result = lane
1176 .cancel_queued(entry_id)
1177 .await
1178 .map_err(|error| error.to_string())?;
1179 Ok(serde_json::json!({
1180 "entryId": entry_id,
1181 "outcome": format!("{:?}", result.outcome).to_lowercase(),
1182 }))
1183}
1184
1185async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
1186 let result = lane
1187 .clear_queue()
1188 .await
1189 .map_err(|error| error.to_string())?;
1190 Ok(serde_json::json!({
1191 "cleared": result.steering.len() + result.follow_up.len(),
1192 "steering": result.steering,
1193 "followUp": result.follow_up,
1194 }))
1195}
1196
1197async fn rpc_navigate(
1198 lane: &Arc<dyn AgentLane>,
1199 msg: &serde_json::Value,
1200) -> Result<serde_json::Value, String> {
1201 let target_id = msg
1202 .get("targetId")
1203 .and_then(serde_json::Value::as_str)
1204 .filter(|value| !value.is_empty());
1205 if lane
1206 .snapshot()
1207 .await
1208 .map_err(|error| error.to_string())?
1209 .active
1210 .is_some()
1211 {
1212 return Err("cannot navigate while an operation is active".to_string());
1213 }
1214 let result = lane
1215 .navigate_tree(target_id, false, None, None)
1216 .await
1217 .map_err(|error| error.to_string())?;
1218 let (outcome, leaf_id, error) = match result.outcome {
1219 rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
1220 ("completed", new_leaf_id, None)
1221 }
1222 rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
1223 ("declined", leaf_id, None)
1224 }
1225 rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
1226 ("aborted", leaf_id, None)
1227 }
1228 rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
1229 ("failed", leaf_id, Some(error.message))
1230 }
1231 };
1232 Ok(serde_json::json!({
1233 "runId": result.run_id,
1234 "outcome": outcome,
1235 "leafId": leaf_id,
1236 "error": error,
1237 }))
1238}
1239
1240async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1246 use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
1247 use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
1248
1249 let entries = harness
1250 .find_entries_on_branch(
1251 &EntryQuery {
1252 order: Some(EntryOrder::OldestFirst),
1253 ..Default::default()
1254 },
1255 &BranchBounds::default(),
1256 )
1257 .await
1258 .unwrap_or_default();
1259 let estimate = estimate_context_tokens(&build_session_context(&entries).messages);
1260 let window = lane
1261 .get_model()
1262 .await
1263 .ok()
1264 .map(|model| model.context_window)
1265 .unwrap_or(0);
1266 let settings = harness.compaction_settings();
1267 let window = window as i64;
1268 let threshold = window - settings.reserve_tokens;
1269 let percent = if window > 0 {
1270 (estimate.tokens as f64) * 100.0 / (window as f64)
1271 } else {
1272 0.0
1273 };
1274 serde_json::json!({
1275 "tokens": estimate.tokens,
1276 "window": window,
1277 "threshold": threshold,
1278 "percent": (percent * 10.0).round() / 10.0,
1279 "reserveTokens": settings.reserve_tokens,
1280 "keepRecentTokens": settings.keep_recent_tokens,
1281 "compactionEnabled": settings.enabled,
1282 "estimated": estimate.last_usage_index.is_none(),
1285 "trailingTokens": estimate.trailing_tokens,
1286 })
1287}
1288
1289fn page_window(
1292 total: usize,
1293 limit: usize,
1294 before: Option<usize>,
1295) -> (std::ops::Range<usize>, bool) {
1296 match before {
1297 Some(index) => {
1298 let start = index.saturating_sub(limit);
1299 (start..index, start > 0)
1300 }
1301 None => {
1302 let skip = total.saturating_sub(limit);
1303 (skip..total, skip > 0)
1304 }
1305 }
1306}
1307
1308async fn rpc_get_messages(
1312 lane: &Arc<dyn AgentLane>,
1313 harness: &AgentHarness,
1314 msg: &serde_json::Value,
1315) -> Result<serde_json::Value, String> {
1316 use rpi_harness::session::types::{BranchBounds, Entry, EntryOrder, EntryQuery};
1317
1318 const DEFAULT_LIMIT: usize = 400;
1319 let limit = msg
1320 .get("limit")
1321 .and_then(|value| value.as_u64())
1322 .map(|value| value as usize)
1323 .unwrap_or(DEFAULT_LIMIT)
1324 .max(1);
1325 let before_id = msg
1329 .get("beforeId")
1330 .and_then(|value| value.as_str())
1331 .filter(|value| !value.is_empty())
1332 .map(str::to_owned);
1333 let entries = harness
1334 .find_entries_on_branch(
1335 &EntryQuery {
1336 order: Some(EntryOrder::OldestFirst),
1337 ..Default::default()
1338 },
1339 &BranchBounds::default(),
1340 )
1341 .await
1342 .map_err(|error| error.to_string())?;
1343 let mut items: Vec<(String, serde_json::Value)> = Vec::new();
1344 for entry in entries {
1345 match entry {
1346 Entry::Message(entry) => {
1347 let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
1348 value["entryId"] = serde_json::json!(entry.base.id);
1351 items.push((entry.base.id, value));
1352 }
1353 Entry::Compaction(compaction) => items.push((
1357 compaction.base.id.clone(),
1358 serde_json::json!({
1359 "role": "compaction",
1360 "entryId": compaction.base.id,
1361 "tokensBefore": compaction.tokens_before,
1362 "retainedMessages": compaction.retained_tail.len(),
1363 "timestamp": compaction.base.timestamp,
1364 }),
1365 )),
1366 _ => {}
1367 }
1368 }
1369 let total = items.len();
1370 let before_index = match &before_id {
1371 Some(before) => match items.iter().position(|(id, _)| id == before) {
1372 Some(index) => Some(index),
1373 None => return Err(format!("beforeId not found: {before}")),
1374 },
1375 None => None,
1376 };
1377 let (range, has_more) = page_window(total, limit, before_index);
1378 let window = items[range].to_vec();
1379 let first_id = window.first().map(|(id, _)| id.clone());
1380 let last_id = window.last().map(|(id, _)| id.clone());
1381 let messages = window
1382 .iter()
1383 .map(|(_, value)| value.clone())
1384 .collect::<Vec<_>>();
1385 let leaf_id = lane.get_leaf_id().await.ok().flatten();
1388 let pending_resume = lane.has_pending_resume().await;
1389 Ok(serde_json::json!({
1390 "messages": messages,
1391 "total": total,
1392 "truncated": has_more,
1393 "limit": limit,
1394 "firstId": first_id,
1395 "lastId": last_id,
1396 "hasMore": has_more,
1397 "beforeId": before_id,
1398 "leafId": leaf_id,
1399 "pendingResume": pending_resume,
1400 }))
1401}
1402
1403fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
1404 let mut providers =
1405 std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
1406 providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
1407 if let Ok(models) = crate::config::load_models_config() {
1408 providers.extend(models.providers.keys().cloned());
1409 }
1410 providers
1411}
1412
1413fn rpc_get_auth_status(
1414 model_catalog: &[rpi_ai::Model],
1415 oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
1416) -> serde_json::Value {
1417 let store = crate::config::read_auth().unwrap_or_default();
1418 let models = crate::config::load_models_config().unwrap_or_default();
1419 let providers = rpc_auth_provider_ids(model_catalog)
1420 .into_iter()
1421 .map(|provider| {
1422 let credential = store.get(&provider);
1423 let source = crate::credentials::resolve(
1424 &provider,
1425 models.providers.get(&provider),
1426 &store,
1427 None,
1428 );
1429 let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
1430 let oauth_available =
1431 oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
1432 serde_json::json!({
1433 "provider": provider,
1434 "ready": source.source.is_present(),
1435 "source": source.source.as_json_str(),
1436 "oauth": oauth,
1437 "oauthAvailable": oauth_available,
1438 })
1439 })
1440 .collect::<Vec<_>>();
1441 serde_json::json!({"providers": providers})
1442}
1443
1444fn rpc_oauth(
1445 snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
1446 msg: &serde_json::Value,
1447) -> Result<serde_json::Value, String> {
1448 let snapshot = snapshot
1449 .as_ref()
1450 .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
1451 let provider = msg
1452 .get("provider")
1453 .and_then(serde_json::Value::as_str)
1454 .map(str::trim)
1455 .filter(|value| !value.is_empty())
1456 .ok_or_else(|| "oauth requires a provider".to_string())?;
1457 let action = msg
1458 .get("action")
1459 .and_then(serde_json::Value::as_str)
1460 .map(str::trim)
1461 .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
1462 .ok_or_else(|| "oauth requires a valid action".to_string())?;
1463 let input = msg
1464 .get("input")
1465 .cloned()
1466 .unwrap_or_else(|| serde_json::json!({}));
1467 let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
1468 .map_err(|_| "OAuth provider request failed".to_string())?;
1469 let response = rpi_extensions::OAuthActionResponse::parse(raw)
1470 .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
1471 match response.kind {
1472 rpi_extensions::OAuthResponseKind::Credential => {
1473 let credential = response
1474 .credential()
1475 .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
1476 .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
1477 crate::config::upsert_oauth_token(
1478 provider,
1479 credential.access,
1480 credential.refresh,
1481 credential.expires_at,
1482 )
1483 .map_err(|_| "OAuth credentials could not be saved".to_string())?;
1484 Ok(serde_json::json!({
1485 "provider": provider,
1486 "status": "authenticated"
1487 }))
1488 }
1489 rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
1490 "provider": provider,
1491 "status": "interaction",
1492 "display": oauth_display(&response.payload)
1493 })),
1494 rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
1495 "provider": provider,
1496 "status": "success"
1497 })),
1498 rpi_extensions::OAuthResponseKind::Error => {
1499 Err("OAuth provider rejected the request".to_string())
1500 }
1501 }
1502}
1503
1504fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
1508 let display = payload.get("display").unwrap_or(payload);
1509 let Some(object) = display.as_object() else {
1510 return serde_json::json!({});
1511 };
1512 let mut safe = serde_json::Map::new();
1513 for key in [
1514 "title",
1515 "url",
1516 "instructions",
1517 "user_code",
1518 "verification_uri",
1519 ] {
1520 if let Some(value) = object.get(key) {
1521 safe.insert(key.to_string(), value.clone());
1522 }
1523 }
1524 serde_json::Value::Object(safe)
1525}
1526
1527async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
1528 let provider = msg
1529 .get("provider")
1530 .and_then(serde_json::Value::as_str)
1531 .map(str::trim)
1532 .filter(|value| !value.is_empty())
1533 .ok_or_else(|| "set_api_key requires a provider".to_string())?;
1534 let api_key = msg
1535 .get("apiKey")
1536 .and_then(serde_json::Value::as_str)
1537 .map(str::trim)
1538 .filter(|value| !value.is_empty())
1539 .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
1540 crate::config::upsert_credential(
1541 provider,
1542 crate::config::Credential::ApiKey {
1543 key: Some(api_key.to_string()),
1544 env: None,
1545 },
1546 )
1547 .map_err(|error| error.to_string())?;
1548 Ok(serde_json::json!({
1549 "provider": provider,
1550 "ready": true,
1551 "source": "auth.json",
1552 "requiresRestart": true,
1553 }))
1554}
1555
1556fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
1557 let models = catalog
1558 .iter()
1559 .map(crate::rpc::protocol::model_descriptor)
1560 .collect::<Vec<_>>();
1561 serde_json::json!({"models": models})
1562}
1563
1564async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
1565 let levels = lane
1566 .get_model()
1567 .await
1568 .map(|model| model.supported_thinking_levels())
1569 .unwrap_or_default();
1570 serde_json::json!({"levels": levels})
1571}
1572
1573async fn rpc_get_available_tools(
1574 lane: &Arc<dyn AgentLane>,
1575 harness: &AgentHarness,
1576) -> serde_json::Value {
1577 let active = lane.get_active_tools().await.unwrap_or_default();
1578 let tools = harness
1579 .get_tools()
1580 .await
1581 .map(|tools| {
1582 tools
1583 .iter()
1584 .map(|tool| tool.tool.schema().name.clone())
1585 .collect::<Vec<_>>()
1586 })
1587 .unwrap_or_else(|_| active.clone());
1588 serde_json::json!({"tools": tools, "activeTools": active})
1589}
1590
1591async fn rpc_set_model(
1592 lane: &Arc<dyn AgentLane>,
1593 catalog: &[rpi_ai::Model],
1594 msg: &serde_json::Value,
1595) -> Result<serde_json::Value, String> {
1596 let id = msg
1597 .get("modelId")
1598 .or_else(|| msg.get("model"))
1599 .and_then(|v| v.as_str())
1600 .ok_or("set_model requires `modelId`")?;
1601 let provider = msg.get("provider").and_then(|v| v.as_str());
1602 let model = catalog
1603 .iter()
1604 .find(|m| {
1605 m.id.eq_ignore_ascii_case(id)
1606 && provider.is_none_or(|provider| m.provider.eq_ignore_ascii_case(provider))
1607 })
1608 .cloned()
1609 .ok_or_else(|| format!("unknown model: {id}"))?;
1610 lane.set_model(model).await.map_err(|e| e.to_string())?;
1611 Ok(serde_json::json!({"modelId": id, "provider": provider}))
1612}
1613
1614async fn rpc_set_thinking_level(
1615 lane: &Arc<dyn AgentLane>,
1616 msg: &serde_json::Value,
1617) -> Result<serde_json::Value, String> {
1618 let value = msg
1619 .get("level")
1620 .or_else(|| msg.get("thinkingLevel"))
1621 .ok_or("set_thinking_level requires `level`")?;
1622 let level: rpi_ai::types::ThinkingLevel =
1623 serde_json::from_value(value.clone()).map_err(|_| {
1624 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
1625 })?;
1626 lane.set_thinking_level(level)
1627 .await
1628 .map_err(|e| e.to_string())?;
1629 Ok(serde_json::json!({"thinkingLevel": level}))
1630}
1631
1632async fn rpc_set_active_tools(
1633 lane: &Arc<dyn AgentLane>,
1634 msg: &serde_json::Value,
1635) -> Result<serde_json::Value, String> {
1636 let tools = msg
1637 .get("tools")
1638 .and_then(|v| v.as_array())
1639 .map(|arr| {
1640 arr.iter()
1641 .filter_map(|v| v.as_str().map(str::to_owned))
1642 .collect::<Vec<_>>()
1643 })
1644 .ok_or("set_active_tools requires a `tools` array")?;
1645 lane.set_active_tools(tools.clone())
1646 .await
1647 .map_err(|e| e.to_string())?;
1648 Ok(serde_json::json!({"activeTools": tools}))
1649}
1650
1651fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
1656 crate::rpc::events::project(event)
1657}
1658
1659fn emit_json_event(event: &HarnessEvent) {
1663 let line = match event {
1664 HarnessEvent::CompactionProgress(e) => {
1665 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
1666 }
1667 HarnessEvent::RunStart(e) => serde_json::json!({
1668 "type": "run_start",
1669 "lane": e.lane,
1670 "runId": e.run_id,
1671 }),
1672 HarnessEvent::RunEnd(e) => serde_json::json!({
1673 "type": "run_end",
1674 "lane": e.lane,
1675 "runId": e.run_id,
1676 "outcome": run_end_outcome_str(e.outcome),
1677 "leafId": e.leaf_id,
1678 }),
1679 };
1680 println!("{line}");
1681}
1682
1683fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
1684 match o {
1685 RunEndOutcome::Completed => "completed",
1686 RunEndOutcome::Aborted => "aborted",
1687 RunEndOutcome::Failed => "failed",
1688 }
1689}
1690
1691pub async fn interactive(
1700 harness: &AgentHarness,
1701 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
1702 args: &Args,
1703 model_catalog: Vec<rpi_ai::Model>,
1704 initial: Option<String>,
1705 extra_messages: &[String],
1706 initial_images: Vec<ImageContent>,
1707 theme: Option<&str>,
1708 no_themes: bool,
1709 reload_context: &crate::session::ReloadContext,
1710) -> i32 {
1711 if let Some(reason) = crate::session::dispatch_session_event_async(
1718 reload_context,
1719 rpi_plugin_sdk::EventTag::BeforeTuiStart,
1720 )
1721 .await
1722 {
1723 eprintln!("[rpi] startup vetoed by extension: {reason}");
1724 return crate::app::EXIT_VETOED;
1725 }
1726
1727 let force_tui = std::env::var("RPI_FORCE_TUI")
1729 .map(|v| v == "1")
1730 .unwrap_or(false);
1731 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
1732 crate::interactive_tui::interactive_tui(
1734 harness,
1735 event_rx,
1736 args,
1737 model_catalog,
1738 initial,
1739 extra_messages,
1740 initial_images,
1741 theme,
1742 no_themes,
1743 reload_context,
1744 )
1745 .await
1746 } else {
1747 interactive_repl(harness, args, initial, extra_messages, initial_images).await
1749 };
1750
1751 if let Some(reason) = crate::session::dispatch_session_event_async(
1757 reload_context,
1758 rpi_plugin_sdk::EventTag::SessionShutdown,
1759 )
1760 .await
1761 {
1762 tracing::warn!(
1763 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
1764 );
1765 }
1766
1767 code
1768}
1769
1770pub async fn interactive_repl(
1772 harness: &AgentHarness,
1773 #[allow(unused_variables)] args: &Args,
1774 initial: Option<String>,
1775 extra_messages: &[String],
1776 initial_images: Vec<ImageContent>,
1777) -> i32 {
1778 let lane: Arc<dyn AgentLane> = harness.lane("main");
1780 let stdin = std::io::stdin();
1781 let is_tty = stdin.is_terminal();
1782
1783 if is_tty {
1784 println!(
1785 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
1786 );
1787 }
1788
1789 let mut prompts: Vec<String> = Vec::new();
1791 if let Some(init) = initial {
1792 prompts.push(init);
1793 }
1794 for m in extra_messages {
1795 prompts.push(m.clone());
1796 }
1797 let mut images = initial_images;
1798 for prompt in prompts {
1799 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
1800 return code;
1801 }
1802 }
1803
1804 let mut line = String::new();
1806 loop {
1807 if is_tty {
1808 print!("> ");
1809 let _ = std::io::stdout().flush();
1810 }
1811 line.clear();
1812 match stdin.lock().read_line(&mut line) {
1813 Ok(0) => break, Ok(_) => {}
1815 Err(_) => break,
1816 }
1817 let trimmed = line.trim();
1818 if trimmed.is_empty() {
1819 continue;
1820 }
1821 if trimmed == "/exit" || trimmed == "/quit" {
1822 break;
1823 }
1824 if trimmed == "/abort" {
1825 let _ = lane.abort().await;
1826 eprintln!("(aborted)");
1827 continue;
1828 }
1829 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
1830 return code;
1831 }
1832 }
1833 0
1834}
1835
1836async fn run_one(
1840 lane: &Arc<dyn AgentLane>,
1841 prompt: &str,
1842 images: Vec<ImageContent>,
1843) -> Result<(), i32> {
1844 match lane.prompt_text(prompt, images).await {
1845 Ok(result) => {
1846 match &result.outcome {
1847 HarnessRunOutcome::Completed { final_message, .. }
1848 | HarnessRunOutcome::Aborted { final_message, .. } => {
1849 let text = assistant_text(final_message);
1850 if !text.is_empty() {
1851 println!("{text}");
1852 }
1853 }
1854 HarnessRunOutcome::Failed {
1855 error,
1856 final_message,
1857 ..
1858 } => {
1859 if let Some(m) = final_message {
1860 if let Some(em) = &m.error_message {
1861 eprintln!("error: {em}");
1862 }
1863 }
1864 eprintln!("run failed: {error:?}");
1865 }
1866 HarnessRunOutcome::Suspended { .. } => {
1867 eprintln!("run suspended (deferred) — resume not supported in v1");
1868 }
1869 }
1870 Ok(())
1871 }
1872 Err(e) => {
1873 eprintln!("prompt rejected: {e}");
1874 Err(1)
1875 }
1876 }
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881 use super::*;
1882 use rpi_agent::events::AgentEvent;
1883 use rpi_agent::types::AgentToolResult;
1884 use rpi_ai::types::{
1885 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
1886 };
1887 use rpi_harness::session::types::OperationError;
1888
1889 #[test]
1890 fn oauth_display_drops_token_like_fields() {
1891 let display = oauth_display(&serde_json::json!({
1892 "display": {
1893 "title": "Sign in",
1894 "url": "https://example.test/authorize",
1895 "instructions": ["Paste the code"],
1896 "access_token": "secret-access",
1897 "refresh_token": "secret-refresh",
1898 }
1899 }));
1900 assert_eq!(display["title"], "Sign in");
1901 assert!(display.get("access_token").is_none());
1902 assert!(display.get("refresh_token").is_none());
1903 }
1904
1905 #[derive(Clone, Default)]
1906 struct RpcOutput(Arc<Mutex<Vec<u8>>>);
1907
1908 impl Write for RpcOutput {
1909 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1910 self.0.lock().unwrap().extend_from_slice(bytes);
1911 Ok(bytes.len())
1912 }
1913 fn flush(&mut self) -> std::io::Result<()> {
1914 Ok(())
1915 }
1916 }
1917
1918 impl RpcOutput {
1919 fn messages(&self) -> Vec<serde_json::Value> {
1920 String::from_utf8(self.0.lock().unwrap().clone())
1921 .unwrap()
1922 .lines()
1923 .map(|line| serde_json::from_str(line).unwrap())
1924 .collect()
1925 }
1926
1927 async fn wait_for(
1928 &self,
1929 predicate: impl Fn(&serde_json::Value) -> bool,
1930 ) -> serde_json::Value {
1931 tokio::time::timeout(std::time::Duration::from_secs(3), async {
1932 loop {
1933 if let Some(message) = self.messages().into_iter().find(&predicate) {
1934 return message;
1935 }
1936 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1937 }
1938 })
1939 .await
1940 .expect("RPC message not received")
1941 }
1942 }
1943
1944 async fn rpc_test_harness(
1945 text: &str,
1946 tokens_per_second: f64,
1947 ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
1948 let provider = rpi_ai::providers::faux::FauxProvider::new(
1949 rpi_ai::providers::faux::FauxScript::new()
1950 .with_text(text)
1951 .with_tokens_per_second(tokens_per_second),
1952 );
1953 let options = rpi_harness::types::AgentHarnessOptions {
1954 model: provider.default_model().clone(),
1955 models: vec![provider.clone()],
1956 ..Default::default()
1957 };
1958 (AgentHarness::create(options).await.unwrap(), provider)
1959 }
1960
1961 fn start_test_rpc(
1962 harness: AgentHarness,
1963 ui: rpi_extensions::UiDialogMailbox,
1964 ) -> (
1965 tokio::io::DuplexStream,
1966 RpcOutput,
1967 tokio::task::JoinHandle<i32>,
1968 ) {
1969 let (client, input) = tokio::io::duplex(4096);
1970 let output = RpcOutput::default();
1971 let captured = output.clone();
1972 let task = tokio::spawn(async move {
1973 rpc_with_io(
1974 &harness,
1975 None,
1976 vec![],
1977 ui,
1978 None,
1979 tokio::io::BufReader::new(input),
1980 captured,
1981 )
1982 .await
1983 });
1984 (client, output, task)
1985 }
1986
1987 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1988 async fn rpc_eof_waits_for_accepted_prompt() {
1989 use tokio::io::AsyncWriteExt;
1990 let (harness, _) = rpc_test_harness("completed", 100.0).await;
1991 let ui = rpi_extensions::UiDialogMailbox::new();
1992 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
1993 client
1994 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
1995 .await
1996 .unwrap();
1997 client.shutdown().await.unwrap();
1998 assert_eq!(
1999 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2000 .await
2001 .unwrap()
2002 .unwrap(),
2003 0
2004 );
2005 let response = output
2006 .messages()
2007 .into_iter()
2008 .find(|m| m["id"] == 1)
2009 .unwrap();
2010 assert_eq!(response["command"], "prompt");
2011 assert_eq!(response["data"]["accepted"], true);
2012 assert!(output.messages().iter().any(|message| {
2013 message["type"] == "agent_settled" && message["outcome"] == "completed"
2014 }));
2015 assert!(!ui.is_attached());
2016 }
2017
2018 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2019 async fn rpc_compaction_settlement_identifies_its_durable_operation() {
2020 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2021 let output = RpcOutput::default();
2022 let code = rpc_with_io(
2023 &harness,
2024 None,
2025 vec![],
2026 rpi_extensions::UiDialogMailbox::new(),
2027 None,
2028 tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
2029 output.clone(),
2030 )
2031 .await;
2032 assert_eq!(code, 0);
2033 let messages = output.messages();
2034 let settled = messages
2035 .iter()
2036 .find(|message| message["type"] == "agent_settled")
2037 .unwrap();
2038 assert_eq!(settled["outcome"], "declined");
2039 let run_id = settled["runId"]
2040 .as_str()
2041 .expect("compaction settlement needs its durable run id");
2042 let records = harness
2043 .session()
2044 .find_records(&Default::default())
2045 .await
2046 .unwrap();
2047 assert!(records.iter().any(|record| matches!(record,
2048 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2049 if finished.run_id == run_id)));
2050 }
2051
2052 #[tokio::test]
2053 async fn rpc_eof_drains_queued_final_events() {
2054 let harness = AgentHarness::create(Default::default()).await.unwrap();
2055 let (tx, rx) = tokio::sync::broadcast::channel(256);
2056 for _ in 0..100 {
2057 tx.send(AgentEvent::AgentStart).unwrap();
2058 }
2059 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
2060 let output = RpcOutput::default();
2061 let code = rpc_with_io(
2062 &harness,
2063 Some(rx),
2064 vec![],
2065 rpi_extensions::UiDialogMailbox::new(),
2066 None,
2067 tokio::io::BufReader::new(&b""[..]),
2068 output.clone(),
2069 )
2070 .await;
2071 assert_eq!(code, 0);
2072 let messages = output.messages();
2073 assert_eq!(
2074 messages.len(),
2075 102,
2076 "ready plus every queued event must be flushed"
2077 );
2078 assert_eq!(messages.last().unwrap()["type"], "agent_end");
2079 }
2080
2081 struct BackpressuredOutput {
2082 entered: Arc<std::sync::atomic::AtomicBool>,
2083 release: Arc<std::sync::atomic::AtomicBool>,
2084 output: RpcOutput,
2085 }
2086
2087 impl Write for BackpressuredOutput {
2088 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2089 self.entered
2090 .store(true, std::sync::atomic::Ordering::SeqCst);
2091 while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
2092 std::thread::sleep(std::time::Duration::from_millis(1));
2093 }
2094 self.output.write(bytes)
2095 }
2096 fn flush(&mut self) -> std::io::Result<()> {
2097 Ok(())
2098 }
2099 }
2100
2101 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2102 async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
2103 use std::sync::atomic::{AtomicBool, Ordering};
2104 let release = Arc::new(AtomicBool::new(false));
2105 struct ReleaseOnDrop(Arc<AtomicBool>);
2106 impl Drop for ReleaseOnDrop {
2107 fn drop(&mut self) {
2108 self.0.store(true, Ordering::SeqCst);
2109 }
2110 }
2111 let _release = ReleaseOnDrop(release.clone());
2112 let entered = Arc::new(AtomicBool::new(false));
2113 let output = RpcOutput::default();
2114 let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
2115 entered: entered.clone(),
2116 release: release.clone(),
2117 output: output.clone(),
2118 });
2119 write_line(&writer, &serde_json::json!({"id": 0})).await;
2120 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2121 while !entered.load(Ordering::SeqCst) {
2122 tokio::task::yield_now().await;
2123 }
2124 })
2125 .await
2126 .unwrap();
2127 let producer_writer = writer.clone();
2128 let producer = tokio::spawn(async move {
2129 for id in 1..=100 {
2130 write_line(&producer_writer, &serde_json::json!({"id": id})).await;
2131 }
2132 });
2133 tokio::time::timeout(
2134 std::time::Duration::from_millis(150),
2135 tokio::time::sleep(std::time::Duration::from_millis(20)),
2136 )
2137 .await
2138 .unwrap();
2139 assert!(
2140 !producer.is_finished(),
2141 "the bounded queue must apply backpressure"
2142 );
2143 release.store(true, Ordering::SeqCst);
2144 producer.await.unwrap();
2145 drop(writer);
2146 task.await.unwrap().unwrap();
2147 let messages = output.messages();
2148 assert_eq!(messages.len(), 101);
2149 for (index, message) in messages.iter().enumerate() {
2150 assert_eq!(message["id"], index);
2151 }
2152 }
2153
2154 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2155 async fn rpc_completion_response_follows_final_event_under_backpressure() {
2156 use std::sync::atomic::{AtomicBool, Ordering};
2157 let (harness, _) = rpc_test_harness("completed", 0.0).await;
2158 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
2159 harness
2160 .set_agent_emitter(Some(Arc::new(emitter)))
2161 .await
2162 .unwrap();
2163 let output = RpcOutput::default();
2164 let release = Arc::new(AtomicBool::new(false));
2165 let release_writer = release.clone();
2166 let release_thread = std::thread::spawn(move || {
2167 std::thread::sleep(std::time::Duration::from_millis(50));
2168 release_writer.store(true, Ordering::SeqCst);
2169 });
2170 let code = rpc_with_io(
2171 &harness,
2172 Some(events),
2173 vec![],
2174 rpi_extensions::UiDialogMailbox::new(),
2175 None,
2176 tokio::io::BufReader::new(
2177 &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
2178 ),
2179 BackpressuredOutput {
2180 entered: Arc::new(AtomicBool::new(false)),
2181 release,
2182 output: output.clone(),
2183 },
2184 )
2185 .await;
2186 release_thread.join().unwrap();
2187 assert_eq!(code, 0);
2188 let messages = output.messages();
2189 let end = messages
2190 .iter()
2191 .position(|message| message["type"] == "agent_end")
2192 .unwrap();
2193 let settled = messages
2194 .iter()
2195 .position(|message| message["type"] == "agent_settled")
2196 .unwrap();
2197 assert!(end < settled);
2198 let run_id = messages[settled]["runId"].as_str().unwrap();
2199 assert!(!run_id.is_empty(), "settlement identifies its durable run");
2200 let records = harness
2201 .session()
2202 .find_records(&Default::default())
2203 .await
2204 .unwrap();
2205 assert!(records.iter().any(|record| matches!(record,
2206 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2207 if finished.run_id == run_id)));
2208 assert!(messages.iter().any(|message| {
2209 message["type"] == "response" && message["data"]["accepted"] == true
2210 }));
2211 assert!(messages.iter().any(|message| {
2212 message["type"] == "agent_settled" && message["outcome"] == "completed"
2213 }));
2214 }
2215
2216 #[tokio::test]
2217 async fn rpc_output_failure_closes_without_waiting_for_more_input() {
2218 struct BrokenOutput;
2219 impl Write for BrokenOutput {
2220 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
2221 Err(std::io::Error::new(
2222 std::io::ErrorKind::BrokenPipe,
2223 "closed client output",
2224 ))
2225 }
2226 fn flush(&mut self) -> std::io::Result<()> {
2227 Ok(())
2228 }
2229 }
2230 let harness = AgentHarness::create(Default::default()).await.unwrap();
2231 let (_client, input) = tokio::io::duplex(4096);
2232 let ui = rpi_extensions::UiDialogMailbox::new();
2233 let code = tokio::time::timeout(
2234 std::time::Duration::from_secs(2),
2235 rpc_with_io(
2236 &harness,
2237 None,
2238 vec![],
2239 ui.clone(),
2240 None,
2241 tokio::io::BufReader::new(input),
2242 BrokenOutput,
2243 ),
2244 )
2245 .await
2246 .unwrap();
2247 assert_eq!(code, 1);
2248 assert!(!ui.is_attached());
2249 }
2250
2251 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2252 async fn rpc_stop_aborts_and_joins_active_prompt() {
2253 use tokio::io::AsyncWriteExt;
2254 let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
2255 let lane = harness.lane("main");
2256 let (mut client, output, task) =
2257 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2258 client
2259 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2260 .await
2261 .unwrap();
2262 tokio::time::timeout(std::time::Duration::from_secs(3), async {
2263 while provider
2264 .state()
2265 .call_count
2266 .load(std::sync::atomic::Ordering::SeqCst)
2267 == 0
2268 {
2269 tokio::task::yield_now().await;
2270 }
2271 })
2272 .await
2273 .unwrap();
2274 client
2275 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
2276 .await
2277 .unwrap();
2278 assert_eq!(
2279 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2280 .await
2281 .unwrap()
2282 .unwrap(),
2283 0
2284 );
2285 let messages = output.messages();
2286 assert!(messages.iter().any(|message| {
2287 message["type"] == "agent_settled"
2288 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2289 }));
2290 assert_eq!(
2291 messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
2292 true
2293 );
2294 assert!(
2295 lane.abort().await.is_err(),
2296 "RPC returned with an active lane"
2297 );
2298 }
2299
2300 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2301 async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
2302 use tokio::io::AsyncWriteExt;
2303 let (harness, _) = rpc_test_harness("not completed", 20.0).await;
2304 let lane = harness.lane("main");
2305 let (mut client, output, task) =
2306 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2307 client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
2308 assert_eq!(
2309 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2310 .await
2311 .unwrap()
2312 .unwrap(),
2313 0
2314 );
2315 let messages = output.messages();
2316 assert!(messages.iter().any(|message| {
2317 message["type"] == "agent_settled"
2318 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2319 }));
2320 assert!(lane.abort().await.is_err());
2321 }
2322
2323 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2324 async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
2325 use tokio::io::AsyncWriteExt;
2326 let (harness, _) =
2327 rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
2328 let ui = rpi_extensions::UiDialogMailbox::new();
2329 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2330 output.wait_for(|m| m["type"] == "ready").await;
2331 client
2332 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2333 .await
2334 .unwrap();
2335 ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
2336 .unwrap();
2337 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2338 client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
2339 output.wait_for(|m| m["id"] == 2).await;
2340 assert_eq!(ui.poll("approval").unwrap()["answer"], true);
2341 output
2342 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
2343 .await;
2344 client.shutdown().await.unwrap();
2345 assert_eq!(
2346 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2347 .await
2348 .unwrap()
2349 .unwrap(),
2350 0
2351 );
2352 assert!(output.messages().iter().any(|message| {
2353 message["type"] == "agent_settled" && message["outcome"] == "completed"
2354 }));
2355 }
2356
2357 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2358 async fn rpc_notifies_client_when_an_approval_is_cancelled() {
2359 use tokio::io::AsyncWriteExt;
2360 let harness = AgentHarness::create(Default::default()).await.unwrap();
2361 let ui = rpi_extensions::UiDialogMailbox::new();
2362 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2363 output.wait_for(|m| m["type"] == "ready").await;
2364 ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
2365 .unwrap();
2366 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2367 ui.cancel_tool_call("c");
2368 output
2369 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
2370 .await;
2371 assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
2373 client.shutdown().await.unwrap();
2374 assert_eq!(task.await.unwrap(), 0);
2375 }
2376
2377 #[tokio::test]
2378 async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
2379 use crate::remote::protocol::RemoteCommand;
2380 use tokio::io::AsyncWriteExt;
2381 let harness = AgentHarness::create(Default::default()).await.unwrap();
2382 let ui = rpi_extensions::UiDialogMailbox::new();
2383 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2384 output.wait_for(|m| m["type"] == "ready").await;
2385 for (request_id, answer, response_id) in [
2386 (
2387 "answered",
2388 Some(serde_json::json!({"values":["No"],"kind":"selector"})),
2389 5,
2390 ),
2391 ("cancelled", None, 6),
2392 ] {
2393 ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
2394 .unwrap();
2395 output
2396 .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
2397 .await;
2398 let command = RemoteCommand::UiDialogResponse {
2399 request_id: request_id.into(),
2400 cancelled: answer.is_none(),
2401 answer,
2402 }
2403 .with_id(response_id);
2404 client
2405 .write_all(format!("{command}\n").as_bytes())
2406 .await
2407 .unwrap();
2408 let ack = output.wait_for(|m| m["id"] == response_id).await;
2409 assert_eq!(ack["data"]["accepted"], true);
2410 let result = ui.poll(request_id).unwrap();
2411 if request_id == "answered" {
2412 assert_eq!(result["answer"]["values"][0], "No");
2413 } else {
2414 assert_eq!(result["status"], "cancelled");
2415 }
2416 }
2417 client.shutdown().await.unwrap();
2418 assert_eq!(task.await.unwrap(), 0);
2419 }
2420
2421 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
2422 AssistantMessage {
2423 role: rpi_ai::types::AssistantRole,
2424 content: vec![Content::Text(TextContent {
2425 kind: TextContentType,
2426 text: text.into(),
2427 text_signature: None,
2428 })],
2429 api: rpi_ai::Api::AnthropicMessages,
2430 provider: "anthropic".into(),
2431 model: "claude-sonnet-5".into(),
2432 response_model: None,
2433 response_id: None,
2434 usage: Usage::zero(),
2435 stop_reason: stop,
2436 deferred: None,
2437 error_message: None,
2438 raw_stop_reason: None,
2439 end_turn: None,
2440 timestamp: 0,
2441 }
2442 }
2443
2444 #[test]
2445 fn assistant_text_concatenates_text_blocks() {
2446 let m = assistant("hello", StopReason::Stop);
2447 assert_eq!(assistant_text(&m), "hello");
2448 }
2449
2450 #[test]
2451 fn outcome_exit_code_maps_failed_aborted_to_1() {
2452 let failed = HarnessRunOutcome::Failed {
2453 leaf_id: "l".into(),
2454 error: OperationError {
2455 code: "boom".into(),
2456 message: "boom".into(),
2457 },
2458 final_entry_id: None,
2459 final_message: None,
2460 };
2461 assert_eq!(outcome_exit_code(&failed), 1);
2462 let completed = HarnessRunOutcome::Completed {
2463 leaf_id: "l".into(),
2464 final_entry_id: "e".into(),
2465 final_message: assistant("ok", StopReason::Stop),
2466 };
2467 assert_eq!(outcome_exit_code(&completed), 0);
2468 }
2469
2470 #[test]
2471 fn run_end_outcome_str_roundtrip() {
2472 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
2473 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
2474 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
2475 }
2476
2477 #[test]
2478 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
2479 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
2480 assert_eq!(end["type"], "agent_end");
2481 assert_eq!(end["messages"], serde_json::json!([]));
2482
2483 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
2484 tool_call_id: "call-1".into(),
2485 tool_name: "read".into(),
2486 result: AgentToolResult::text("hello"),
2487 is_error: false,
2488 });
2489 assert_eq!(tool["type"], "tool_execution_end");
2490 assert_eq!(tool["result"]["content"][0]["text"], "hello");
2491 assert_eq!(tool["result"]["terminate"], false);
2492
2493 let retry = agent_event_json(&AgentEvent::RetryScheduled {
2494 attempt: 3,
2495 max_retries: 10,
2496 delay_ms: 8_000,
2497 error: "503 service unavailable".into(),
2498 });
2499 assert_eq!(retry["type"], "retry_scheduled");
2500 assert_eq!(retry["attempt"], 3);
2501 assert_eq!(retry["maxRetries"], 10);
2502 assert_eq!(retry["delayMs"], 8_000);
2503 assert_eq!(retry["error"], "503 service unavailable");
2504 }
2505}
2506
2507#[cfg(test)]
2508mod page_window_tests {
2509 use super::page_window;
2510
2511 #[test]
2512 fn tail_is_kept_without_before_id() {
2513 assert_eq!(page_window(12, 5, None), (7..12, true));
2515 assert_eq!(page_window(3, 5, None), (0..3, false));
2517 }
2518
2519 #[test]
2520 fn before_id_opens_the_window_immediately_before_it() {
2521 assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
2523 assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
2525 }
2526
2527 #[test]
2528 fn limit_larger_than_the_branch_never_underflows() {
2529 assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
2530 assert_eq!(page_window(5, 100, None), (0..5, false));
2531 }
2532}