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_agent_event, 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_agent_event(&out, &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_agent_event(out, &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 manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
1420 snapshot
1421 .providers()
1422 .iter()
1423 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
1424 .collect()
1425 });
1426 let auth_states = crate::credentials::statuses(&models, &store, &manifests);
1427 let providers = rpc_auth_provider_ids(model_catalog)
1428 .into_iter()
1429 .map(|provider| {
1430 let credential = store.get(&provider);
1431 let state = auth_states.get(&provider).cloned().unwrap_or(
1432 rpi_runtime::credentials::AuthState {
1433 status: rpi_runtime::credentials::AuthStatus::Missing,
1434 source: crate::credentials::CredentialSource::Missing,
1435 },
1436 );
1437 let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
1438 let oauth_available =
1439 oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
1440 serde_json::json!({
1441 "provider": provider,
1442 "ready": state.is_ready(),
1443 "source": state.source.as_json_str(),
1444 "status": state.status,
1445 "oauth": oauth,
1446 "oauthAvailable": oauth_available,
1447 })
1448 })
1449 .collect::<Vec<_>>();
1450 serde_json::json!({"providers": providers})
1451}
1452
1453fn rpc_oauth(
1454 snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
1455 msg: &serde_json::Value,
1456) -> Result<serde_json::Value, String> {
1457 let snapshot = snapshot
1458 .as_ref()
1459 .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
1460 let provider = msg
1461 .get("provider")
1462 .and_then(serde_json::Value::as_str)
1463 .map(str::trim)
1464 .filter(|value| !value.is_empty())
1465 .ok_or_else(|| "oauth requires a provider".to_string())?;
1466 let action = msg
1467 .get("action")
1468 .and_then(serde_json::Value::as_str)
1469 .map(str::trim)
1470 .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
1471 .ok_or_else(|| "oauth requires a valid action".to_string())?;
1472 let input = msg
1473 .get("input")
1474 .cloned()
1475 .unwrap_or_else(|| serde_json::json!({}));
1476 let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
1477 .map_err(|_| "OAuth provider request failed".to_string())?;
1478 let response = rpi_extensions::OAuthActionResponse::parse(raw)
1479 .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
1480 match response.kind {
1481 rpi_extensions::OAuthResponseKind::Credential => {
1482 let credential = response
1483 .credential()
1484 .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
1485 .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
1486 crate::config::upsert_oauth_token(
1487 provider,
1488 credential.access,
1489 credential.refresh,
1490 credential.expires_at,
1491 )
1492 .map_err(|_| "OAuth credentials could not be saved".to_string())?;
1493 Ok(serde_json::json!({
1494 "provider": provider,
1495 "status": "authenticated"
1496 }))
1497 }
1498 rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
1499 "provider": provider,
1500 "status": "interaction",
1501 "display": oauth_display(&response.payload)
1502 })),
1503 rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
1504 "provider": provider,
1505 "status": "success"
1506 })),
1507 rpi_extensions::OAuthResponseKind::Error => {
1508 Err("OAuth provider rejected the request".to_string())
1509 }
1510 }
1511}
1512
1513fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
1517 let display = payload.get("display").unwrap_or(payload);
1518 let Some(object) = display.as_object() else {
1519 return serde_json::json!({});
1520 };
1521 let mut safe = serde_json::Map::new();
1522 for key in [
1523 "title",
1524 "url",
1525 "instructions",
1526 "user_code",
1527 "verification_uri",
1528 ] {
1529 if let Some(value) = object.get(key) {
1530 safe.insert(key.to_string(), value.clone());
1531 }
1532 }
1533 serde_json::Value::Object(safe)
1534}
1535
1536async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
1537 let provider = msg
1538 .get("provider")
1539 .and_then(serde_json::Value::as_str)
1540 .map(str::trim)
1541 .filter(|value| !value.is_empty())
1542 .ok_or_else(|| "set_api_key requires a provider".to_string())?;
1543 let api_key = msg
1544 .get("apiKey")
1545 .and_then(serde_json::Value::as_str)
1546 .map(str::trim)
1547 .filter(|value| !value.is_empty())
1548 .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
1549 crate::config::upsert_credential(
1550 provider,
1551 crate::config::Credential::ApiKey {
1552 key: Some(api_key.to_string()),
1553 env: None,
1554 },
1555 )
1556 .map_err(|error| error.to_string())?;
1557 Ok(serde_json::json!({
1558 "provider": provider,
1559 "ready": true,
1560 "source": "auth.json",
1561 "requiresRestart": true,
1562 }))
1563}
1564
1565fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
1566 let models = catalog
1567 .iter()
1568 .map(crate::rpc::protocol::model_descriptor)
1569 .collect::<Vec<_>>();
1570 serde_json::json!({"models": models})
1571}
1572
1573async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
1574 let levels = lane
1575 .get_model()
1576 .await
1577 .map(|model| model.supported_thinking_levels())
1578 .unwrap_or_default();
1579 serde_json::json!({"levels": levels})
1580}
1581
1582async fn rpc_get_available_tools(
1583 lane: &Arc<dyn AgentLane>,
1584 harness: &AgentHarness,
1585) -> serde_json::Value {
1586 let active = lane.get_active_tools().await.unwrap_or_default();
1587 let tools = harness
1588 .get_tools()
1589 .await
1590 .map(|tools| {
1591 tools
1592 .iter()
1593 .map(|tool| tool.tool.schema().name.clone())
1594 .collect::<Vec<_>>()
1595 })
1596 .unwrap_or_else(|_| active.clone());
1597 serde_json::json!({"tools": tools, "activeTools": active})
1598}
1599
1600async fn rpc_set_model(
1601 lane: &Arc<dyn AgentLane>,
1602 catalog: &[rpi_ai::Model],
1603 msg: &serde_json::Value,
1604) -> Result<serde_json::Value, String> {
1605 let id = msg
1606 .get("modelId")
1607 .or_else(|| msg.get("model"))
1608 .and_then(|v| v.as_str())
1609 .ok_or("set_model requires `modelId`")?;
1610 let provider = msg.get("provider").and_then(|v| v.as_str());
1611 let model = catalog
1612 .iter()
1613 .find(|m| {
1614 m.id.eq_ignore_ascii_case(id)
1615 && provider.is_none_or(|provider| m.provider.eq_ignore_ascii_case(provider))
1616 })
1617 .cloned()
1618 .ok_or_else(|| format!("unknown model: {id}"))?;
1619 lane.set_model(model).await.map_err(|e| e.to_string())?;
1620 Ok(serde_json::json!({"modelId": id, "provider": provider}))
1621}
1622
1623async fn rpc_set_thinking_level(
1624 lane: &Arc<dyn AgentLane>,
1625 msg: &serde_json::Value,
1626) -> Result<serde_json::Value, String> {
1627 let value = msg
1628 .get("level")
1629 .or_else(|| msg.get("thinkingLevel"))
1630 .ok_or("set_thinking_level requires `level`")?;
1631 let level: rpi_ai::types::ThinkingLevel =
1632 serde_json::from_value(value.clone()).map_err(|_| {
1633 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
1634 })?;
1635 lane.set_thinking_level(level)
1636 .await
1637 .map_err(|e| e.to_string())?;
1638 Ok(serde_json::json!({"thinkingLevel": level}))
1639}
1640
1641async fn rpc_set_active_tools(
1642 lane: &Arc<dyn AgentLane>,
1643 msg: &serde_json::Value,
1644) -> Result<serde_json::Value, String> {
1645 let tools = msg
1646 .get("tools")
1647 .and_then(|v| v.as_array())
1648 .map(|arr| {
1649 arr.iter()
1650 .filter_map(|v| v.as_str().map(str::to_owned))
1651 .collect::<Vec<_>>()
1652 })
1653 .ok_or("set_active_tools requires a `tools` array")?;
1654 lane.set_active_tools(tools.clone())
1655 .await
1656 .map_err(|e| e.to_string())?;
1657 Ok(serde_json::json!({"activeTools": tools}))
1658}
1659
1660fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
1665 crate::rpc::events::project(event)
1666}
1667
1668fn emit_json_event(event: &HarnessEvent) {
1672 let line = match event {
1673 HarnessEvent::CompactionProgress(e) => {
1674 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
1675 }
1676 HarnessEvent::RunStart(e) => serde_json::json!({
1677 "type": "run_start",
1678 "lane": e.lane,
1679 "runId": e.run_id,
1680 }),
1681 HarnessEvent::RunEnd(e) => serde_json::json!({
1682 "type": "run_end",
1683 "lane": e.lane,
1684 "runId": e.run_id,
1685 "outcome": run_end_outcome_str(e.outcome),
1686 "leafId": e.leaf_id,
1687 }),
1688 };
1689 println!("{line}");
1690}
1691
1692fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
1693 match o {
1694 RunEndOutcome::Completed => "completed",
1695 RunEndOutcome::Aborted => "aborted",
1696 RunEndOutcome::Failed => "failed",
1697 }
1698}
1699
1700pub async fn interactive(
1709 harness: &AgentHarness,
1710 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
1711 args: &Args,
1712 model_catalog: Vec<rpi_ai::Model>,
1713 initial: Option<String>,
1714 extra_messages: &[String],
1715 initial_images: Vec<ImageContent>,
1716 theme: Option<&str>,
1717 no_themes: bool,
1718 reload_context: &crate::session::ReloadContext,
1719) -> i32 {
1720 if let Some(reason) = crate::session::dispatch_session_event_async(
1727 reload_context,
1728 rpi_plugin_sdk::EventTag::BeforeTuiStart,
1729 )
1730 .await
1731 {
1732 eprintln!("[rpi] startup vetoed by extension: {reason}");
1733 return crate::app::EXIT_VETOED;
1734 }
1735
1736 let force_tui = std::env::var("RPI_FORCE_TUI")
1738 .map(|v| v == "1")
1739 .unwrap_or(false);
1740 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
1741 crate::interactive_tui::interactive_tui(
1743 harness,
1744 event_rx,
1745 args,
1746 model_catalog,
1747 initial,
1748 extra_messages,
1749 initial_images,
1750 theme,
1751 no_themes,
1752 reload_context,
1753 )
1754 .await
1755 } else {
1756 interactive_repl(harness, args, initial, extra_messages, initial_images).await
1758 };
1759
1760 if let Some(reason) = crate::session::dispatch_session_event_async(
1766 reload_context,
1767 rpi_plugin_sdk::EventTag::SessionShutdown,
1768 )
1769 .await
1770 {
1771 tracing::warn!(
1772 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
1773 );
1774 }
1775
1776 code
1777}
1778
1779pub async fn interactive_repl(
1781 harness: &AgentHarness,
1782 #[allow(unused_variables)] args: &Args,
1783 initial: Option<String>,
1784 extra_messages: &[String],
1785 initial_images: Vec<ImageContent>,
1786) -> i32 {
1787 let lane: Arc<dyn AgentLane> = harness.lane("main");
1789 let stdin = std::io::stdin();
1790 let is_tty = stdin.is_terminal();
1791
1792 if is_tty {
1793 println!(
1794 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
1795 );
1796 }
1797
1798 let mut prompts: Vec<String> = Vec::new();
1800 if let Some(init) = initial {
1801 prompts.push(init);
1802 }
1803 for m in extra_messages {
1804 prompts.push(m.clone());
1805 }
1806 let mut images = initial_images;
1807 for prompt in prompts {
1808 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
1809 return code;
1810 }
1811 }
1812
1813 let mut line = String::new();
1815 loop {
1816 if is_tty {
1817 print!("> ");
1818 let _ = std::io::stdout().flush();
1819 }
1820 line.clear();
1821 match stdin.lock().read_line(&mut line) {
1822 Ok(0) => break, Ok(_) => {}
1824 Err(_) => break,
1825 }
1826 let trimmed = line.trim();
1827 if trimmed.is_empty() {
1828 continue;
1829 }
1830 if trimmed == "/exit" || trimmed == "/quit" {
1831 break;
1832 }
1833 if trimmed == "/abort" {
1834 let _ = lane.abort().await;
1835 eprintln!("(aborted)");
1836 continue;
1837 }
1838 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
1839 return code;
1840 }
1841 }
1842 0
1843}
1844
1845async fn run_one(
1849 lane: &Arc<dyn AgentLane>,
1850 prompt: &str,
1851 images: Vec<ImageContent>,
1852) -> Result<(), i32> {
1853 match lane.prompt_text(prompt, images).await {
1854 Ok(result) => {
1855 match &result.outcome {
1856 HarnessRunOutcome::Completed { final_message, .. }
1857 | HarnessRunOutcome::Aborted { final_message, .. } => {
1858 let text = assistant_text(final_message);
1859 if !text.is_empty() {
1860 println!("{text}");
1861 }
1862 }
1863 HarnessRunOutcome::Failed {
1864 error,
1865 final_message,
1866 ..
1867 } => {
1868 if let Some(m) = final_message {
1869 if let Some(em) = &m.error_message {
1870 eprintln!("error: {em}");
1871 }
1872 }
1873 eprintln!("run failed: {error:?}");
1874 }
1875 HarnessRunOutcome::Suspended { .. } => {
1876 eprintln!("run suspended (deferred) — resume not supported in v1");
1877 }
1878 }
1879 Ok(())
1880 }
1881 Err(e) => {
1882 eprintln!("prompt rejected: {e}");
1883 Err(1)
1884 }
1885 }
1886}
1887
1888#[cfg(test)]
1889mod tests {
1890 use super::*;
1891 use rpi_agent::events::AgentEvent;
1892 use rpi_agent::types::AgentToolResult;
1893 use rpi_ai::types::{
1894 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
1895 };
1896 use rpi_harness::session::types::OperationError;
1897
1898 #[test]
1899 fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
1900 let _guard = crate::config::test_support::env_lock().lock().unwrap();
1901 struct RestoreConfig(Option<std::ffi::OsString>);
1902 impl Drop for RestoreConfig {
1903 fn drop(&mut self) {
1904 match self.0.take() {
1905 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1906 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1907 }
1908 }
1909 }
1910 let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
1911 let directory = tempfile::tempdir().unwrap();
1912 std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
1913 std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
1914 "header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
1915 "expired-account": {"api":"openai-completions", "models":[]}
1916 }}).to_string()).unwrap();
1917 std::fs::write(directory.path().join("auth.json"), serde_json::json!({
1918 "expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
1919 }).to_string()).unwrap();
1920 let status = rpc_get_auth_status(&[], None);
1921 let providers = status["providers"].as_array().unwrap();
1922 let header = providers
1923 .iter()
1924 .find(|row| row["provider"] == "header-gateway")
1925 .unwrap();
1926 assert_eq!(header["ready"], true);
1927 assert_eq!(header["source"], "headers");
1928 assert_eq!(header["status"], "ready");
1929 let expired = providers
1930 .iter()
1931 .find(|row| row["provider"] == "expired-account")
1932 .unwrap();
1933 assert_eq!(expired["ready"], false);
1934 assert_eq!(expired["status"], "expired");
1935 assert!(!status.to_string().contains("secret"));
1936 }
1937
1938 #[test]
1939 fn oauth_display_drops_token_like_fields() {
1940 let display = oauth_display(&serde_json::json!({
1941 "display": {
1942 "title": "Sign in",
1943 "url": "https://example.test/authorize",
1944 "instructions": ["Paste the code"],
1945 "access_token": "secret-access",
1946 "refresh_token": "secret-refresh",
1947 }
1948 }));
1949 assert_eq!(display["title"], "Sign in");
1950 assert!(display.get("access_token").is_none());
1951 assert!(display.get("refresh_token").is_none());
1952 }
1953
1954 #[derive(Clone, Default)]
1955 struct RpcOutput(Arc<Mutex<Vec<u8>>>);
1956
1957 impl Write for RpcOutput {
1958 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
1959 self.0.lock().unwrap().extend_from_slice(bytes);
1960 Ok(bytes.len())
1961 }
1962 fn flush(&mut self) -> std::io::Result<()> {
1963 Ok(())
1964 }
1965 }
1966
1967 impl RpcOutput {
1968 fn messages(&self) -> Vec<serde_json::Value> {
1969 String::from_utf8(self.0.lock().unwrap().clone())
1970 .unwrap()
1971 .lines()
1972 .map(|line| serde_json::from_str(line).unwrap())
1973 .collect()
1974 }
1975
1976 async fn wait_for(
1977 &self,
1978 predicate: impl Fn(&serde_json::Value) -> bool,
1979 ) -> serde_json::Value {
1980 tokio::time::timeout(std::time::Duration::from_secs(3), async {
1981 loop {
1982 if let Some(message) = self.messages().into_iter().find(&predicate) {
1983 return message;
1984 }
1985 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1986 }
1987 })
1988 .await
1989 .expect("RPC message not received")
1990 }
1991 }
1992
1993 async fn rpc_test_harness(
1994 text: &str,
1995 tokens_per_second: f64,
1996 ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
1997 let provider = rpi_ai::providers::faux::FauxProvider::new(
1998 rpi_ai::providers::faux::FauxScript::new()
1999 .with_text(text)
2000 .with_tokens_per_second(tokens_per_second),
2001 );
2002 let options = rpi_harness::types::AgentHarnessOptions {
2003 model: provider.default_model().clone(),
2004 models: vec![provider.clone()],
2005 ..Default::default()
2006 };
2007 (AgentHarness::create(options).await.unwrap(), provider)
2008 }
2009
2010 fn start_test_rpc(
2011 harness: AgentHarness,
2012 ui: rpi_extensions::UiDialogMailbox,
2013 ) -> (
2014 tokio::io::DuplexStream,
2015 RpcOutput,
2016 tokio::task::JoinHandle<i32>,
2017 ) {
2018 let (client, input) = tokio::io::duplex(4096);
2019 let output = RpcOutput::default();
2020 let captured = output.clone();
2021 let task = tokio::spawn(async move {
2022 rpc_with_io(
2023 &harness,
2024 None,
2025 vec![],
2026 ui,
2027 None,
2028 tokio::io::BufReader::new(input),
2029 captured,
2030 )
2031 .await
2032 });
2033 (client, output, task)
2034 }
2035
2036 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2037 async fn rpc_eof_waits_for_accepted_prompt() {
2038 use tokio::io::AsyncWriteExt;
2039 let (harness, _) = rpc_test_harness("completed", 100.0).await;
2040 let ui = rpi_extensions::UiDialogMailbox::new();
2041 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2042 client
2043 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2044 .await
2045 .unwrap();
2046 client.shutdown().await.unwrap();
2047 assert_eq!(
2048 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2049 .await
2050 .unwrap()
2051 .unwrap(),
2052 0
2053 );
2054 let response = output
2055 .messages()
2056 .into_iter()
2057 .find(|m| m["id"] == 1)
2058 .unwrap();
2059 assert_eq!(response["command"], "prompt");
2060 assert_eq!(response["data"]["accepted"], true);
2061 assert!(output.messages().iter().any(|message| {
2062 message["type"] == "agent_settled" && message["outcome"] == "completed"
2063 }));
2064 assert!(!ui.is_attached());
2065 }
2066
2067 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2068 async fn rpc_compaction_settlement_identifies_its_durable_operation() {
2069 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2070 let output = RpcOutput::default();
2071 let code = rpc_with_io(
2072 &harness,
2073 None,
2074 vec![],
2075 rpi_extensions::UiDialogMailbox::new(),
2076 None,
2077 tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
2078 output.clone(),
2079 )
2080 .await;
2081 assert_eq!(code, 0);
2082 let messages = output.messages();
2083 let settled = messages
2084 .iter()
2085 .find(|message| message["type"] == "agent_settled")
2086 .unwrap();
2087 assert_eq!(settled["outcome"], "declined");
2088 let run_id = settled["runId"]
2089 .as_str()
2090 .expect("compaction settlement needs its durable run id");
2091 let records = harness
2092 .session()
2093 .find_records(&Default::default())
2094 .await
2095 .unwrap();
2096 assert!(records.iter().any(|record| matches!(record,
2097 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2098 if finished.run_id == run_id)));
2099 }
2100
2101 #[tokio::test]
2102 async fn rpc_eof_drains_queued_final_events() {
2103 let harness = AgentHarness::create(Default::default()).await.unwrap();
2104 let (tx, rx) = tokio::sync::broadcast::channel(256);
2105 for _ in 0..100 {
2106 tx.send(AgentEvent::AgentStart).unwrap();
2107 }
2108 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
2109 let output = RpcOutput::default();
2110 let code = rpc_with_io(
2111 &harness,
2112 Some(rx),
2113 vec![],
2114 rpi_extensions::UiDialogMailbox::new(),
2115 None,
2116 tokio::io::BufReader::new(&b""[..]),
2117 output.clone(),
2118 )
2119 .await;
2120 assert_eq!(code, 0);
2121 let messages = output.messages();
2122 assert_eq!(
2123 messages.len(),
2124 102,
2125 "ready plus every queued event must be flushed"
2126 );
2127 assert_eq!(messages.last().unwrap()["type"], "agent_end");
2128 }
2129
2130 struct BackpressuredOutput {
2131 entered: Arc<std::sync::atomic::AtomicBool>,
2132 release: Arc<std::sync::atomic::AtomicBool>,
2133 output: RpcOutput,
2134 }
2135
2136 impl Write for BackpressuredOutput {
2137 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2138 self.entered
2139 .store(true, std::sync::atomic::Ordering::SeqCst);
2140 while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
2141 std::thread::sleep(std::time::Duration::from_millis(1));
2142 }
2143 self.output.write(bytes)
2144 }
2145 fn flush(&mut self) -> std::io::Result<()> {
2146 Ok(())
2147 }
2148 }
2149
2150 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2151 async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
2152 use std::sync::atomic::{AtomicBool, Ordering};
2153 let release = Arc::new(AtomicBool::new(false));
2154 struct ReleaseOnDrop(Arc<AtomicBool>);
2155 impl Drop for ReleaseOnDrop {
2156 fn drop(&mut self) {
2157 self.0.store(true, Ordering::SeqCst);
2158 }
2159 }
2160 let _release = ReleaseOnDrop(release.clone());
2161 let entered = Arc::new(AtomicBool::new(false));
2162 let output = RpcOutput::default();
2163 let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
2164 entered: entered.clone(),
2165 release: release.clone(),
2166 output: output.clone(),
2167 });
2168 write_line(&writer, &serde_json::json!({"id": 0})).await;
2169 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2170 while !entered.load(Ordering::SeqCst) {
2171 tokio::task::yield_now().await;
2172 }
2173 })
2174 .await
2175 .unwrap();
2176 let producer_writer = writer.clone();
2177 let producer = tokio::spawn(async move {
2178 for id in 1..=100 {
2179 write_line(&producer_writer, &serde_json::json!({"id": id})).await;
2180 }
2181 });
2182 tokio::time::timeout(
2183 std::time::Duration::from_millis(150),
2184 tokio::time::sleep(std::time::Duration::from_millis(20)),
2185 )
2186 .await
2187 .unwrap();
2188 assert!(
2189 !producer.is_finished(),
2190 "the bounded queue must apply backpressure"
2191 );
2192 release.store(true, Ordering::SeqCst);
2193 producer.await.unwrap();
2194 drop(writer);
2195 task.await.unwrap().unwrap();
2196 let messages = output.messages();
2197 assert_eq!(messages.len(), 101);
2198 for (index, message) in messages.iter().enumerate() {
2199 assert_eq!(message["id"], index);
2200 }
2201 }
2202
2203 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2204 async fn rpc_completion_response_follows_final_event_under_backpressure() {
2205 use std::sync::atomic::{AtomicBool, Ordering};
2206 let (harness, _) = rpc_test_harness("completed", 0.0).await;
2207 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
2208 harness
2209 .set_agent_emitter(Some(Arc::new(emitter)))
2210 .await
2211 .unwrap();
2212 let output = RpcOutput::default();
2213 let release = Arc::new(AtomicBool::new(false));
2214 let release_writer = release.clone();
2215 let release_thread = std::thread::spawn(move || {
2216 std::thread::sleep(std::time::Duration::from_millis(50));
2217 release_writer.store(true, Ordering::SeqCst);
2218 });
2219 let code = rpc_with_io(
2220 &harness,
2221 Some(events),
2222 vec![],
2223 rpi_extensions::UiDialogMailbox::new(),
2224 None,
2225 tokio::io::BufReader::new(
2226 &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
2227 ),
2228 BackpressuredOutput {
2229 entered: Arc::new(AtomicBool::new(false)),
2230 release,
2231 output: output.clone(),
2232 },
2233 )
2234 .await;
2235 release_thread.join().unwrap();
2236 assert_eq!(code, 0);
2237 let messages = output.messages();
2238 let end = messages
2239 .iter()
2240 .position(|message| message["type"] == "agent_end")
2241 .unwrap();
2242 let settled = messages
2243 .iter()
2244 .position(|message| message["type"] == "agent_settled")
2245 .unwrap();
2246 assert!(end < settled);
2247 let run_id = messages[settled]["runId"].as_str().unwrap();
2248 assert!(!run_id.is_empty(), "settlement identifies its durable run");
2249 let records = harness
2250 .session()
2251 .find_records(&Default::default())
2252 .await
2253 .unwrap();
2254 assert!(records.iter().any(|record| matches!(record,
2255 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2256 if finished.run_id == run_id)));
2257 assert!(messages.iter().any(|message| {
2258 message["type"] == "response" && message["data"]["accepted"] == true
2259 }));
2260 assert!(messages.iter().any(|message| {
2261 message["type"] == "agent_settled" && message["outcome"] == "completed"
2262 }));
2263 }
2264
2265 #[tokio::test]
2266 async fn rpc_output_failure_closes_without_waiting_for_more_input() {
2267 struct BrokenOutput;
2268 impl Write for BrokenOutput {
2269 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
2270 Err(std::io::Error::new(
2271 std::io::ErrorKind::BrokenPipe,
2272 "closed client output",
2273 ))
2274 }
2275 fn flush(&mut self) -> std::io::Result<()> {
2276 Ok(())
2277 }
2278 }
2279 let harness = AgentHarness::create(Default::default()).await.unwrap();
2280 let (_client, input) = tokio::io::duplex(4096);
2281 let ui = rpi_extensions::UiDialogMailbox::new();
2282 let code = tokio::time::timeout(
2283 std::time::Duration::from_secs(2),
2284 rpc_with_io(
2285 &harness,
2286 None,
2287 vec![],
2288 ui.clone(),
2289 None,
2290 tokio::io::BufReader::new(input),
2291 BrokenOutput,
2292 ),
2293 )
2294 .await
2295 .unwrap();
2296 assert_eq!(code, 1);
2297 assert!(!ui.is_attached());
2298 }
2299
2300 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2301 async fn rpc_stop_aborts_and_joins_active_prompt() {
2302 use tokio::io::AsyncWriteExt;
2303 let (harness, provider) = rpc_test_harness("still streaming the response", 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
2308 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2309 .await
2310 .unwrap();
2311 tokio::time::timeout(std::time::Duration::from_secs(3), async {
2312 while provider
2313 .state()
2314 .call_count
2315 .load(std::sync::atomic::Ordering::SeqCst)
2316 == 0
2317 {
2318 tokio::task::yield_now().await;
2319 }
2320 })
2321 .await
2322 .unwrap();
2323 client
2324 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
2325 .await
2326 .unwrap();
2327 assert_eq!(
2328 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2329 .await
2330 .unwrap()
2331 .unwrap(),
2332 0
2333 );
2334 let messages = output.messages();
2335 assert!(messages.iter().any(|message| {
2336 message["type"] == "agent_settled"
2337 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2338 }));
2339 assert_eq!(
2340 messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
2341 true
2342 );
2343 assert!(
2344 lane.abort().await.is_err(),
2345 "RPC returned with an active lane"
2346 );
2347 }
2348
2349 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2350 async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
2351 use tokio::io::AsyncWriteExt;
2352 let (harness, _) = rpc_test_harness("not completed", 20.0).await;
2353 let lane = harness.lane("main");
2354 let (mut client, output, task) =
2355 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2356 client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
2357 assert_eq!(
2358 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2359 .await
2360 .unwrap()
2361 .unwrap(),
2362 0
2363 );
2364 let messages = output.messages();
2365 assert!(messages.iter().any(|message| {
2366 message["type"] == "agent_settled"
2367 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2368 }));
2369 assert!(lane.abort().await.is_err());
2370 }
2371
2372 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2373 async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
2374 use tokio::io::AsyncWriteExt;
2375 let (harness, _) =
2376 rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
2377 let ui = rpi_extensions::UiDialogMailbox::new();
2378 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2379 output.wait_for(|m| m["type"] == "ready").await;
2380 client
2381 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2382 .await
2383 .unwrap();
2384 ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
2385 .unwrap();
2386 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2387 client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
2388 output.wait_for(|m| m["id"] == 2).await;
2389 assert_eq!(ui.poll("approval").unwrap()["answer"], true);
2390 output
2391 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
2392 .await;
2393 client.shutdown().await.unwrap();
2394 assert_eq!(
2395 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2396 .await
2397 .unwrap()
2398 .unwrap(),
2399 0
2400 );
2401 assert!(output.messages().iter().any(|message| {
2402 message["type"] == "agent_settled" && message["outcome"] == "completed"
2403 }));
2404 }
2405
2406 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2407 async fn rpc_notifies_client_when_an_approval_is_cancelled() {
2408 use tokio::io::AsyncWriteExt;
2409 let harness = AgentHarness::create(Default::default()).await.unwrap();
2410 let ui = rpi_extensions::UiDialogMailbox::new();
2411 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2412 output.wait_for(|m| m["type"] == "ready").await;
2413 ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
2414 .unwrap();
2415 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2416 ui.cancel_tool_call("c");
2417 output
2418 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
2419 .await;
2420 assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
2422 client.shutdown().await.unwrap();
2423 assert_eq!(task.await.unwrap(), 0);
2424 }
2425
2426 #[tokio::test]
2427 async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
2428 use crate::remote::protocol::RemoteCommand;
2429 use tokio::io::AsyncWriteExt;
2430 let harness = AgentHarness::create(Default::default()).await.unwrap();
2431 let ui = rpi_extensions::UiDialogMailbox::new();
2432 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2433 output.wait_for(|m| m["type"] == "ready").await;
2434 for (request_id, answer, response_id) in [
2435 (
2436 "answered",
2437 Some(serde_json::json!({"values":["No"],"kind":"selector"})),
2438 5,
2439 ),
2440 ("cancelled", None, 6),
2441 ] {
2442 ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
2443 .unwrap();
2444 output
2445 .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
2446 .await;
2447 let command = RemoteCommand::UiDialogResponse {
2448 request_id: request_id.into(),
2449 cancelled: answer.is_none(),
2450 answer,
2451 }
2452 .with_id(response_id);
2453 client
2454 .write_all(format!("{command}\n").as_bytes())
2455 .await
2456 .unwrap();
2457 let ack = output.wait_for(|m| m["id"] == response_id).await;
2458 assert_eq!(ack["data"]["accepted"], true);
2459 let result = ui.poll(request_id).unwrap();
2460 if request_id == "answered" {
2461 assert_eq!(result["answer"]["values"][0], "No");
2462 } else {
2463 assert_eq!(result["status"], "cancelled");
2464 }
2465 }
2466 client.shutdown().await.unwrap();
2467 assert_eq!(task.await.unwrap(), 0);
2468 }
2469
2470 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
2471 AssistantMessage {
2472 role: rpi_ai::types::AssistantRole,
2473 content: vec![Content::Text(TextContent {
2474 kind: TextContentType,
2475 text: text.into(),
2476 text_signature: None,
2477 })],
2478 api: rpi_ai::Api::AnthropicMessages,
2479 provider: "anthropic".into(),
2480 model: "claude-sonnet-5".into(),
2481 response_model: None,
2482 response_id: None,
2483 usage: Usage::zero(),
2484 stop_reason: stop,
2485 deferred: None,
2486 error_message: None,
2487 raw_stop_reason: None,
2488 end_turn: None,
2489 timestamp: 0,
2490 }
2491 }
2492
2493 #[test]
2494 fn assistant_text_concatenates_text_blocks() {
2495 let m = assistant("hello", StopReason::Stop);
2496 assert_eq!(assistant_text(&m), "hello");
2497 }
2498
2499 #[test]
2500 fn outcome_exit_code_maps_failed_aborted_to_1() {
2501 let failed = HarnessRunOutcome::Failed {
2502 leaf_id: "l".into(),
2503 error: OperationError {
2504 code: "boom".into(),
2505 message: "boom".into(),
2506 },
2507 final_entry_id: None,
2508 final_message: None,
2509 };
2510 assert_eq!(outcome_exit_code(&failed), 1);
2511 let completed = HarnessRunOutcome::Completed {
2512 leaf_id: "l".into(),
2513 final_entry_id: "e".into(),
2514 final_message: assistant("ok", StopReason::Stop),
2515 };
2516 assert_eq!(outcome_exit_code(&completed), 0);
2517 }
2518
2519 #[test]
2520 fn run_end_outcome_str_roundtrip() {
2521 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
2522 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
2523 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
2524 }
2525
2526 #[test]
2527 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
2528 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
2529 assert_eq!(end["type"], "agent_end");
2530 assert_eq!(end["messages"], serde_json::json!([]));
2531
2532 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
2533 tool_call_id: "call-1".into(),
2534 tool_name: "read".into(),
2535 result: AgentToolResult::text("hello"),
2536 is_error: false,
2537 });
2538 assert_eq!(tool["type"], "tool_execution_end");
2539 assert_eq!(tool["result"]["content"][0]["text"], "hello");
2540 assert_eq!(tool["result"]["terminate"], false);
2541
2542 let retry = agent_event_json(&AgentEvent::RetryScheduled {
2543 attempt: 3,
2544 max_retries: 10,
2545 delay_ms: 8_000,
2546 error: "503 service unavailable".into(),
2547 });
2548 assert_eq!(retry["type"], "retry_scheduled");
2549 assert_eq!(retry["attempt"], 3);
2550 assert_eq!(retry["maxRetries"], 10);
2551 assert_eq!(retry["delayMs"], 8_000);
2552 assert_eq!(retry["error"], "503 service unavailable");
2553 }
2554}
2555
2556#[cfg(test)]
2557mod page_window_tests {
2558 use super::page_window;
2559
2560 #[test]
2561 fn tail_is_kept_without_before_id() {
2562 assert_eq!(page_window(12, 5, None), (7..12, true));
2564 assert_eq!(page_window(3, 5, None), (0..3, false));
2566 }
2567
2568 #[test]
2569 fn before_id_opens_the_window_immediately_before_it() {
2570 assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
2572 assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
2574 }
2575
2576 #[test]
2577 fn limit_larger_than_the_branch_never_underflows() {
2578 assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
2579 assert_eq!(page_window(5, 100, None), (0..5, false));
2580 }
2581}