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::{BranchBounds, EntryOrder, EntryQuery, 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 .or_else(|| msg.get("content"))
562 .or_else(|| msg.get("prompt"))
563 .and_then(|v| v.as_str())
564 .map(str::to_owned);
565 let lane_task = Arc::clone(&lane);
566 let out_task = stdout.clone();
567 let active_task = Arc::clone(&run_active);
568 let mailbox_task = ui_dialog.clone();
569 let flush_events = event_flush.clone();
570 let Some(content) = content.or_else(|| (resume || compact).then(String::new))
571 else {
572 active_task.store(false, std::sync::atomic::Ordering::Release);
573 write_line(
574 &stdout,
575 &rpc_protocol::response(
576 Some(id),
577 "prompt",
578 Err("prompt requires a string `message`".to_string()),
579 ),
580 )
581 .await;
582 continue;
583 };
584 write_line(
585 &stdout,
586 &rpc_protocol::response(
587 Some(id),
588 &kind,
589 Ok(Some(serde_json::json!({"accepted": true}))),
590 ),
591 )
592 .await;
593 let instructions = msg
594 .get("customInstructions")
595 .and_then(|v| v.as_str())
596 .map(str::to_owned);
597 run_task = Some(tokio::spawn(async move {
598 let result = if compact {
599 rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
600 } else if resume {
601 rpc_resume_cancellable(&lane_task, cancel_task).await
602 } else {
603 rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
604 };
605 if let Some(flush) = flush_events {
606 let (done, drained) = tokio::sync::oneshot::channel();
607 if flush.send(done).is_ok() {
608 let _ = drained.await;
609 }
610 }
611 let (outcome, aborted, error) = match &result {
612 Ok(value) => {
613 let outcome = value
614 .get("outcome")
615 .and_then(|v| v.as_str())
616 .unwrap_or("completed")
617 .to_string();
618 let error = value
619 .get("error")
620 .and_then(|v| v.as_str())
621 .map(str::to_owned);
622 (outcome.clone(), outcome == "aborted", error)
623 }
624 Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
625 };
626 let pending_resume = lane_task.has_pending_resume().await;
629 let summary = result
630 .as_ref()
631 .ok()
632 .and_then(|value| value.get("summary"))
633 .cloned();
634 let settled = serde_json::json!({
635 "type": "agent_settled",
636 "runId": result.as_ref().ok().and_then(|value| value.get("runId")),
637 "outcome": outcome,
638 "aborted": aborted,
639 "error": error,
640 "summary": summary,
641 "pendingResume": pending_resume,
642 });
643 write_line(&out_task, &settled).await;
644 mailbox_task.cancel_all();
645 active_task.store(false, std::sync::atomic::Ordering::Release);
646 }));
647 continue;
648 }
649 _ => {}
650 }
651
652 let result = match kind.as_str() {
653 "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
654 run_cancel.cancel();
656 Ok(serde_json::json!({"aborted": true}))
657 }
658 "abort" => lane
659 .abort()
660 .await
661 .map(|_| serde_json::json!({"aborted": true}))
662 .map_err(|e| e.to_string()),
663 "get_state" => Ok(rpc_get_state(&lane, harness).await),
664 "get_messages" => rpc_get_messages(&lane, harness, &msg).await,
665 "get_history" => rpc_get_history(&lane, harness, &msg).await,
666 "get_session_branch" => rpc_get_session_branch(harness).await,
667 "append_entry" => rpc_append_entry(harness, &msg).await,
668 "get_stats" => rpc_get_stats(&lane, harness).await,
669 "get_queue" => rpc_get_queue(&lane, harness, &msg).await,
670 "queue_message" => rpc_queue_message(&lane, &msg).await,
671 "cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
672 "clear_queue" => rpc_clear_queue(&lane).await,
673 "navigate" => rpc_navigate(&lane, &msg).await,
674 "get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
675 "get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
676 "oauth" => rpc_oauth(&oauth_snapshot, &msg),
677 "set_api_key" => rpc_set_api_key(&msg).await,
678 "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
679 "get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
680 "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
681 "get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
682 "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
683 "ping" => Ok(serde_json::json!({"pong": true})),
684 other => Err(format!("unknown command type: {other}")),
685 };
686
687 let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
688 write_line(&stdout, &response).await;
689 }
690
691 ui_dialog.detach();
695 if cancel_on_exit {
696 run_cancel.cancel();
697 }
698 let mut exit_code = 0;
699 if let Some(task) = run_task {
700 if let Err(error) = task.await {
701 eprintln!("[rpi] rpc prompt task failed: {error}");
702 exit_code = 1;
703 }
704 }
705 pump_stop.cancel();
706 let _ = dialog_task.await;
707 if let Some(task) = event_task {
708 let _ = task.await;
709 }
710 drop(stdout);
712 match writer_task.await {
713 Ok(Ok(())) => {}
714 error => {
715 eprintln!("[rpi] rpc output failed: {error:?}");
716 exit_code = 1;
717 }
718 }
719 exit_code
720}
721
722async fn drain_rpc_events(
723 out: &RpcWriter,
724 events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
725) {
726 loop {
727 match events.try_recv() {
728 Ok(event) => write_agent_event(out, &event).await,
729 Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
730 Err(_) => break,
731 }
732 }
733}
734
735async fn rpc_prompt_cancellable(
738 lane: &Arc<dyn AgentLane>,
739 content: &str,
740 cancel: tokio_util::sync::CancellationToken,
741) -> Result<serde_json::Value, String> {
742 let prompt = rpc_prompt_text(lane, content);
743 tokio::pin!(prompt);
744 tokio::select! {
745 biased;
746 _ = cancel.cancelled() => {
747 match lane.abort().await {
748 Ok(_) => prompt.await,
749 Err(_) => Err("Operation aborted".to_string()),
750 }
751 }
752 result = &mut prompt => result,
753 }
754}
755
756async fn rpc_compact_cancellable(
762 lane: &Arc<dyn AgentLane>,
763 instructions: Option<String>,
764 cancel: tokio_util::sync::CancellationToken,
765) -> Result<serde_json::Value, String> {
766 let compact = async {
767 match lane.compact(instructions.as_deref()).await {
768 Ok(result) => Ok(compaction_json(&result)),
769 Err(error) => Err(error.to_string()),
770 }
771 };
772 tokio::pin!(compact);
773 tokio::select! {
774 biased;
775 _ = cancel.cancelled() => {
776 let _ = lane.abort().await;
777 compact.await
778 }
779 result = &mut compact => result,
780 }
781}
782
783fn compaction_json(result: &CompactionResult) -> serde_json::Value {
786 let mut value = match &result.outcome {
787 CompactionOutcome::Completed { leaf_id, entry } => {
788 let (tokens_before, retained) = match entry {
789 rpi_harness::session::types::Entry::Compaction(compaction) => {
790 (compaction.tokens_before, compaction.retained_tail.len())
791 }
792 _ => (0, 0),
793 };
794 serde_json::json!({
795 "outcome": "compacted",
796 "leafId": leaf_id,
797 "tokensBefore": tokens_before,
798 "retainedMessages": retained,
799 "summary": format!(
800 "Conversation compacted · {} tokens before, {} messages kept.",
801 tokens_before, retained
802 ),
803 })
804 }
805 CompactionOutcome::Declined { leaf_id } => serde_json::json!({
806 "outcome": "declined",
807 "leafId": leaf_id,
808 "summary": "Nothing to compact yet.",
809 }),
810 CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
811 "outcome": "aborted",
812 "leafId": leaf_id,
813 "summary": "Compaction aborted.",
814 }),
815 CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
816 "outcome": "failed",
817 "leafId": leaf_id,
818 "error": error.message,
819 "summary": format!("Compaction failed: {}", error.message),
820 }),
821 };
822 value["runId"] = serde_json::json!(result.run_id);
823 value
824}
825
826async fn rpc_resume_cancellable(
831 lane: &Arc<dyn AgentLane>,
832 cancel: tokio_util::sync::CancellationToken,
833) -> Result<serde_json::Value, String> {
834 let resume = async {
835 match lane.resume_pending().await {
836 Ok(Some(result)) => {
837 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
838 Ok(serde_json::json!({
839 "runId": result.run_id,
840 "outcome": outcome,
841 "finalText": final_text,
842 "error": error,
843 "resumed": true,
844 }))
845 }
846 Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
847 Err(error) => Err(error.to_string()),
848 }
849 };
850 tokio::pin!(resume);
851 tokio::select! {
852 biased;
853 _ = cancel.cancelled() => {
854 let _ = lane.abort().await;
855 resume.await
856 }
857 result = &mut resume => result,
858 }
859}
860
861fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
862 match outcome {
863 HarnessRunOutcome::Completed { final_message, .. } => {
864 ("completed", Some(assistant_text(final_message)), None)
865 }
866 HarnessRunOutcome::Aborted { final_message, .. } => {
867 ("aborted", Some(assistant_text(final_message)), None)
868 }
869 HarnessRunOutcome::Failed {
872 final_message,
873 error,
874 ..
875 } => (
876 "failed",
877 final_message.as_ref().map(assistant_text),
878 Some(error.message.clone()),
879 ),
880 HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
881 }
882}
883
884async fn rpc_prompt_text(
885 lane: &Arc<dyn AgentLane>,
886 content: &str,
887) -> Result<serde_json::Value, String> {
888 let result = lane
889 .prompt_text(content, Vec::new())
890 .await
891 .map_err(|e| e.to_string())?;
892 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
893 Ok(
894 serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
895 )
896}
897
898async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
899 let model = lane.get_model().await.ok();
900 let thinking = lane
901 .get_thinking_level()
902 .await
903 .ok()
904 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
905 let active_tools = lane.get_active_tools().await.unwrap_or_default();
906 let leaf_id = lane.get_leaf_id().await.ok().flatten();
907 let context = rpc_context_usage(lane, harness).await;
908 let active_operation = lane
909 .snapshot()
910 .await
911 .ok()
912 .and_then(|snapshot| snapshot.active)
913 .map(|active| {
914 serde_json::json!({
915 "runId": active.run_id,
916 "lane": active.lane,
917 "kind": format!("{:?}", active.kind).to_lowercase(),
918 })
919 });
920 let queued = lane.queued_messages().await.unwrap_or_default();
921 let stats = rpc_get_stats(lane, harness).await.ok();
922 serde_json::json!({
923 "modelId": model.as_ref().map(|model| model.id.clone()),
924 "model": model.as_ref().map(|model| model.id.clone()),
925 "provider": model.as_ref().map(|model| model.provider.clone()),
926 "thinkingLevel": thinking,
927 "activeTools": active_tools,
928 "leafId": leaf_id,
929 "context": context,
930 "sessionId": harness.session().storage().metadata().id,
931 "activeOperation": active_operation,
932 "queue": {
933 "steering": queued.steering,
934 "followUp": queued.follow_up,
935 "steeringCount": queued.steering.len(),
936 "followUpCount": queued.follow_up.len(),
937 },
938 "stats": stats,
939 })
940}
941
942fn rpc_now_ms() -> i64 {
943 std::time::SystemTime::now()
944 .duration_since(std::time::UNIX_EPOCH)
945 .map(|duration| duration.as_millis() as i64)
946 .unwrap_or_default()
947}
948
949fn rpc_message_text(value: &serde_json::Value) -> String {
950 let Some(content) = value.get("content") else {
951 return String::new();
952 };
953 if let Some(text) = content.as_str() {
954 return text.to_string();
955 }
956 content
957 .as_array()
958 .into_iter()
959 .flatten()
960 .filter_map(|block| block.get("text").and_then(|text| text.as_str()))
961 .collect::<Vec<_>>()
962 .join("\n")
963}
964
965async fn rpc_get_history(
966 lane: &Arc<dyn AgentLane>,
967 harness: &AgentHarness,
968 msg: &serde_json::Value,
969) -> Result<serde_json::Value, String> {
970 let limit = msg
971 .get("limit")
972 .and_then(serde_json::Value::as_u64)
973 .unwrap_or(100)
974 .clamp(1, 500) as usize;
975 let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
976 let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
977 let mut records = harness
978 .session()
979 .find_records(&RecordQuery {
980 lane: Some(lane.name().to_string()),
981 after_seq,
982 order: Some(EntryOrder::OldestFirst),
983 ..Default::default()
984 })
985 .await
986 .map_err(|error| error.to_string())?;
987 if let Some(before) = before_seq {
988 records.retain(|record| record.seq() < before);
989 }
990 let total = records.len();
991 let has_more = records.len() > limit;
992 if before_seq.is_some() {
993 if has_more {
994 records = records.split_off(records.len() - limit);
995 }
996 } else if after_seq.is_some() {
997 records.truncate(limit);
998 } else if has_more {
999 records = records.split_off(records.len() - limit);
1000 }
1001 let first_seq = records.first().map(LaneRecord::seq);
1002 let last_seq = records.last().map(LaneRecord::seq);
1003 let open_operations = harness
1004 .session()
1005 .find_open_operations(lane.name(), Some(50))
1006 .await
1007 .map_err(|error| error.to_string())?;
1008 Ok(serde_json::json!({
1009 "records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1010 "total": total,
1011 "limit": limit,
1012 "afterSeq": after_seq,
1013 "beforeSeq": before_seq,
1014 "firstSeq": first_seq,
1015 "lastSeq": last_seq,
1016 "hasMore": has_more,
1017 "leafId": lane.get_leaf_id().await.ok().flatten(),
1018 "openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1019 }))
1020}
1021
1022async fn rpc_get_session_branch(harness: &AgentHarness) -> Result<serde_json::Value, String> {
1023 let entries = harness
1024 .find_entries_on_branch(
1025 &EntryQuery {
1026 order: Some(EntryOrder::OldestFirst),
1027 ..Default::default()
1028 },
1029 &BranchBounds::default(),
1030 )
1031 .await
1032 .map_err(|error| error.to_string())?;
1033 let entries = entries
1034 .iter()
1035 .map(|entry| serde_json::to_value(entry).map_err(|error| error.to_string()))
1036 .collect::<Result<Vec<_>, _>>()?;
1037 Ok(serde_json::json!({"entries": entries, "count": entries.len()}))
1038}
1039
1040async fn rpc_append_entry(
1041 harness: &AgentHarness,
1042 msg: &serde_json::Value,
1043) -> Result<serde_json::Value, String> {
1044 let custom_type = msg
1045 .get("customType")
1046 .and_then(serde_json::Value::as_str)
1047 .filter(|value| !value.trim().is_empty())
1048 .ok_or_else(|| "append_entry requires a non-empty string `customType`".to_string())?;
1049 let entry_id = harness
1050 .session()
1051 .append_custom_entry(custom_type, msg.get("data").cloned())
1052 .await
1053 .map_err(|error| error.to_string())?;
1054 Ok(serde_json::json!({"entryId": entry_id, "customType": custom_type}))
1055}
1056
1057async fn rpc_get_stats(
1058 lane: &Arc<dyn AgentLane>,
1059 harness: &AgentHarness,
1060) -> Result<serde_json::Value, String> {
1061 let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
1062 let records = harness
1063 .session()
1064 .find_records(&RecordQuery {
1065 lane: Some(lane.name().to_string()),
1066 order: Some(EntryOrder::OldestFirst),
1067 ..Default::default()
1068 })
1069 .await
1070 .map_err(|error| error.to_string())?;
1071 let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
1072 for record in &records {
1073 let kind = match record {
1074 LaneRecord::OperationStarted(started) => {
1075 let key = started.intent.kind();
1076 *operation_counts.entry(key.to_string()).or_default() += 1;
1077 if matches!(
1078 &started.intent,
1079 rpi_harness::session::types::OperationIntent::Run {
1080 resume_data: Some(_),
1081 ..
1082 }
1083 ) {
1084 *operation_counts.entry("resume".to_string()).or_default() += 1;
1085 }
1086 continue;
1087 }
1088 LaneRecord::OperationFinished(_) => "operation_finished",
1089 LaneRecord::RetryPending(_) => "retry_pending",
1090 LaneRecord::Usage(_) => "usage",
1091 _ => continue,
1092 };
1093 *operation_counts.entry(kind.to_string()).or_default() += 1;
1094 }
1095 let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
1096 let model = lane.get_model().await.ok();
1097 let input = stats.cached_tokens + stats.uncached_tokens;
1098 Ok(serde_json::json!({
1099 "messageCount": stats.message_count,
1100 "operationCount": count("operation_finished"),
1101 "retryCount": count("retry_pending"),
1102 "compactionCount": count("compaction"),
1103 "resumeCount": count("resume"),
1104 "operationCounts": operation_counts,
1105 "provider": model.as_ref().map(|value| value.provider.clone()),
1106 "modelId": model.as_ref().map(|value| value.id.clone()),
1107 "cachedTokens": stats.cached_tokens,
1108 "uncachedTokens": stats.uncached_tokens,
1109 "totalTokens": stats.total_tokens,
1110 "costTotal": stats.cost_total,
1111 "cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
1112 }))
1113}
1114
1115async fn rpc_get_queue(
1116 lane: &Arc<dyn AgentLane>,
1117 harness: &AgentHarness,
1118 msg: &serde_json::Value,
1119) -> Result<serde_json::Value, String> {
1120 let include_completed = msg
1121 .get("includeCompleted")
1122 .and_then(serde_json::Value::as_bool)
1123 .unwrap_or(false);
1124 let records = harness
1125 .session()
1126 .find_records(&RecordQuery {
1127 lane: Some(lane.name().to_string()),
1128 order: Some(EntryOrder::OldestFirst),
1129 ..Default::default()
1130 })
1131 .await
1132 .map_err(|error| error.to_string())?;
1133 let mut cancelled = std::collections::HashMap::<String, u64>::new();
1134 for record in &records {
1135 if let LaneRecord::QueueCancelled(record) = record {
1136 cancelled.insert(record.entry_id.clone(), record.base.seq);
1137 }
1138 }
1139 let mut items = Vec::new();
1140 for record in records {
1141 let LaneRecord::QueueEnqueued(record) = record else {
1142 continue;
1143 };
1144 let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
1145 continue;
1146 };
1147 let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
1148 ("cancelled", Some(*seq))
1149 } else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
1150 ("consumed", Some(entry.base().seq))
1151 } else {
1152 ("queued", None)
1153 };
1154 if status != "queued" && !include_completed {
1155 continue;
1156 }
1157 let message = record
1158 .target
1159 .get("message")
1160 .cloned()
1161 .unwrap_or(serde_json::Value::Null);
1162 items.push(serde_json::json!({
1163 "entryId": entry_id,
1164 "queue": record.queue,
1165 "status": status,
1166 "text": rpc_message_text(&message),
1167 "message": message,
1168 "runId": record.run_id,
1169 "seq": record.base.seq,
1170 "completedSeq": completed_seq,
1171 }));
1172 }
1173 Ok(
1174 serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
1175 )
1176}
1177
1178async fn rpc_queue_message(
1179 lane: &Arc<dyn AgentLane>,
1180 msg: &serde_json::Value,
1181) -> Result<serde_json::Value, String> {
1182 let text = msg
1183 .get("message")
1184 .and_then(serde_json::Value::as_str)
1185 .filter(|text| !text.trim().is_empty())
1186 .ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
1187 let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
1188 let queue = msg
1189 .get("deliverAs")
1190 .and_then(serde_json::Value::as_str)
1191 .unwrap_or("followUp");
1192 let result = match queue {
1193 "steer" => lane.steer(message).await,
1194 "followUp" | "follow_up" => lane.follow_up(message).await,
1195 "nextTurn" | "next_run" => lane.next_run(message).await,
1196 other => return Err(format!("unknown queue kind: {other}")),
1197 }
1198 .map_err(|error| error.to_string())?;
1199 Ok(serde_json::json!({
1200 "accepted": true,
1201 "entryId": result.entry_id,
1202 "queue": queue,
1203 }))
1204}
1205
1206async fn rpc_cancel_queued(
1207 lane: &Arc<dyn AgentLane>,
1208 msg: &serde_json::Value,
1209) -> Result<serde_json::Value, String> {
1210 let entry_id = msg
1211 .get("entryId")
1212 .and_then(serde_json::Value::as_str)
1213 .filter(|value| !value.is_empty())
1214 .ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
1215 let result = lane
1216 .cancel_queued(entry_id)
1217 .await
1218 .map_err(|error| error.to_string())?;
1219 Ok(serde_json::json!({
1220 "entryId": entry_id,
1221 "outcome": format!("{:?}", result.outcome).to_lowercase(),
1222 }))
1223}
1224
1225async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
1226 let result = lane
1227 .clear_queue()
1228 .await
1229 .map_err(|error| error.to_string())?;
1230 Ok(serde_json::json!({
1231 "cleared": result.steering.len() + result.follow_up.len(),
1232 "steering": result.steering,
1233 "followUp": result.follow_up,
1234 }))
1235}
1236
1237async fn rpc_navigate(
1238 lane: &Arc<dyn AgentLane>,
1239 msg: &serde_json::Value,
1240) -> Result<serde_json::Value, String> {
1241 let target_id = msg
1242 .get("targetId")
1243 .and_then(serde_json::Value::as_str)
1244 .filter(|value| !value.is_empty());
1245 if lane
1246 .snapshot()
1247 .await
1248 .map_err(|error| error.to_string())?
1249 .active
1250 .is_some()
1251 {
1252 return Err("cannot navigate while an operation is active".to_string());
1253 }
1254 let result = lane
1255 .navigate_tree(target_id, false, None, None)
1256 .await
1257 .map_err(|error| error.to_string())?;
1258 let (outcome, leaf_id, error) = match result.outcome {
1259 rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
1260 ("completed", new_leaf_id, None)
1261 }
1262 rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
1263 ("declined", leaf_id, None)
1264 }
1265 rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
1266 ("aborted", leaf_id, None)
1267 }
1268 rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
1269 ("failed", leaf_id, Some(error.message))
1270 }
1271 };
1272 Ok(serde_json::json!({
1273 "runId": result.run_id,
1274 "outcome": outcome,
1275 "leafId": leaf_id,
1276 "error": error,
1277 }))
1278}
1279
1280async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1286 use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
1287 use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
1288
1289 let entries = harness
1290 .find_entries_on_branch(
1291 &EntryQuery {
1292 order: Some(EntryOrder::OldestFirst),
1293 ..Default::default()
1294 },
1295 &BranchBounds::default(),
1296 )
1297 .await
1298 .unwrap_or_default();
1299 let estimate = estimate_context_tokens(&build_session_context(&entries).messages);
1300 let window = lane
1301 .get_model()
1302 .await
1303 .ok()
1304 .map(|model| model.context_window)
1305 .unwrap_or(0);
1306 let settings = harness.compaction_settings();
1307 let window = window as i64;
1308 let threshold = window - settings.reserve_tokens;
1309 let percent = if window > 0 {
1310 (estimate.tokens as f64) * 100.0 / (window as f64)
1311 } else {
1312 0.0
1313 };
1314 serde_json::json!({
1315 "tokens": estimate.tokens,
1316 "window": window,
1317 "threshold": threshold,
1318 "percent": (percent * 10.0).round() / 10.0,
1319 "reserveTokens": settings.reserve_tokens,
1320 "keepRecentTokens": settings.keep_recent_tokens,
1321 "compactionEnabled": settings.enabled,
1322 "estimated": estimate.last_usage_index.is_none(),
1325 "trailingTokens": estimate.trailing_tokens,
1326 })
1327}
1328
1329fn page_window(
1332 total: usize,
1333 limit: usize,
1334 before: Option<usize>,
1335) -> (std::ops::Range<usize>, bool) {
1336 match before {
1337 Some(index) => {
1338 let start = index.saturating_sub(limit);
1339 (start..index, start > 0)
1340 }
1341 None => {
1342 let skip = total.saturating_sub(limit);
1343 (skip..total, skip > 0)
1344 }
1345 }
1346}
1347
1348async fn rpc_get_messages(
1352 lane: &Arc<dyn AgentLane>,
1353 harness: &AgentHarness,
1354 msg: &serde_json::Value,
1355) -> Result<serde_json::Value, String> {
1356 use rpi_harness::session::types::{BranchBounds, Entry, EntryOrder, EntryQuery};
1357
1358 const DEFAULT_LIMIT: usize = 400;
1359 let limit = msg
1360 .get("limit")
1361 .and_then(|value| value.as_u64())
1362 .map(|value| value as usize)
1363 .unwrap_or(DEFAULT_LIMIT)
1364 .max(1);
1365 let before_id = msg
1369 .get("beforeId")
1370 .and_then(|value| value.as_str())
1371 .filter(|value| !value.is_empty())
1372 .map(str::to_owned);
1373 let entries = harness
1374 .find_entries_on_branch(
1375 &EntryQuery {
1376 order: Some(EntryOrder::OldestFirst),
1377 ..Default::default()
1378 },
1379 &BranchBounds::default(),
1380 )
1381 .await
1382 .map_err(|error| error.to_string())?;
1383 let mut items: Vec<(String, serde_json::Value)> = Vec::new();
1384 for entry in entries {
1385 match entry {
1386 Entry::Message(entry) => {
1387 let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
1388 value["entryId"] = serde_json::json!(entry.base.id);
1391 items.push((entry.base.id, value));
1392 }
1393 Entry::Compaction(compaction) => items.push((
1397 compaction.base.id.clone(),
1398 serde_json::json!({
1399 "role": "compaction",
1400 "entryId": compaction.base.id,
1401 "tokensBefore": compaction.tokens_before,
1402 "retainedMessages": compaction.retained_tail.len(),
1403 "timestamp": compaction.base.timestamp,
1404 }),
1405 )),
1406 _ => {}
1407 }
1408 }
1409 let total = items.len();
1410 let before_index = match &before_id {
1411 Some(before) => match items.iter().position(|(id, _)| id == before) {
1412 Some(index) => Some(index),
1413 None => return Err(format!("beforeId not found: {before}")),
1414 },
1415 None => None,
1416 };
1417 let (range, has_more) = page_window(total, limit, before_index);
1418 let window = items[range].to_vec();
1419 let first_id = window.first().map(|(id, _)| id.clone());
1420 let last_id = window.last().map(|(id, _)| id.clone());
1421 let messages = window
1422 .iter()
1423 .map(|(_, value)| value.clone())
1424 .collect::<Vec<_>>();
1425 let leaf_id = lane.get_leaf_id().await.ok().flatten();
1428 let pending_resume = lane.has_pending_resume().await;
1429 Ok(serde_json::json!({
1430 "messages": messages,
1431 "total": total,
1432 "truncated": has_more,
1433 "limit": limit,
1434 "firstId": first_id,
1435 "lastId": last_id,
1436 "hasMore": has_more,
1437 "beforeId": before_id,
1438 "leafId": leaf_id,
1439 "pendingResume": pending_resume,
1440 }))
1441}
1442
1443fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
1444 let mut providers =
1445 std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
1446 providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
1447 if let Ok(models) = crate::config::load_models_config() {
1448 providers.extend(models.providers.keys().cloned());
1449 }
1450 providers
1451}
1452
1453fn rpc_get_auth_status(
1454 model_catalog: &[rpi_ai::Model],
1455 oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
1456) -> serde_json::Value {
1457 let store = crate::config::read_auth().unwrap_or_default();
1458 let models = crate::config::load_models_config().unwrap_or_default();
1459 let manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
1460 snapshot
1461 .providers()
1462 .iter()
1463 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
1464 .collect()
1465 });
1466 let auth_states = crate::credentials::statuses(&models, &store, &manifests);
1467 let providers = rpc_auth_provider_ids(model_catalog)
1468 .into_iter()
1469 .map(|provider| {
1470 let credential = store.get(&provider);
1471 let state = auth_states.get(&provider).cloned().unwrap_or(
1472 rpi_runtime::credentials::AuthState {
1473 status: rpi_runtime::credentials::AuthStatus::Missing,
1474 source: crate::credentials::CredentialSource::Missing,
1475 },
1476 );
1477 let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
1478 let oauth_available =
1479 oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
1480 serde_json::json!({
1481 "provider": provider,
1482 "ready": state.is_ready(),
1483 "source": state.source.as_json_str(),
1484 "status": state.status,
1485 "oauth": oauth,
1486 "oauthAvailable": oauth_available,
1487 })
1488 })
1489 .collect::<Vec<_>>();
1490 serde_json::json!({"providers": providers})
1491}
1492
1493fn rpc_oauth(
1494 snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
1495 msg: &serde_json::Value,
1496) -> Result<serde_json::Value, String> {
1497 let snapshot = snapshot
1498 .as_ref()
1499 .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
1500 let provider = msg
1501 .get("provider")
1502 .and_then(serde_json::Value::as_str)
1503 .map(str::trim)
1504 .filter(|value| !value.is_empty())
1505 .ok_or_else(|| "oauth requires a provider".to_string())?;
1506 let action = msg
1507 .get("action")
1508 .and_then(serde_json::Value::as_str)
1509 .map(str::trim)
1510 .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
1511 .ok_or_else(|| "oauth requires a valid action".to_string())?;
1512 let input = msg
1513 .get("input")
1514 .cloned()
1515 .unwrap_or_else(|| serde_json::json!({}));
1516 let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
1517 .map_err(|_| "OAuth provider request failed".to_string())?;
1518 let response = rpi_extensions::OAuthActionResponse::parse(raw)
1519 .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
1520 match response.kind {
1521 rpi_extensions::OAuthResponseKind::Credential => {
1522 let credential = response
1523 .credential()
1524 .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
1525 .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
1526 crate::config::upsert_oauth_token(
1527 provider,
1528 credential.access,
1529 credential.refresh,
1530 credential.expires_at,
1531 )
1532 .map_err(|_| "OAuth credentials could not be saved".to_string())?;
1533 Ok(serde_json::json!({
1534 "provider": provider,
1535 "status": "authenticated"
1536 }))
1537 }
1538 rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
1539 "provider": provider,
1540 "status": "interaction",
1541 "display": oauth_display(&response.payload)
1542 })),
1543 rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
1544 "provider": provider,
1545 "status": "success"
1546 })),
1547 rpi_extensions::OAuthResponseKind::Error => {
1548 Err("OAuth provider rejected the request".to_string())
1549 }
1550 }
1551}
1552
1553fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
1557 let display = payload.get("display").unwrap_or(payload);
1558 let Some(object) = display.as_object() else {
1559 return serde_json::json!({});
1560 };
1561 let mut safe = serde_json::Map::new();
1562 for key in [
1563 "title",
1564 "url",
1565 "instructions",
1566 "user_code",
1567 "verification_uri",
1568 ] {
1569 if let Some(value) = object.get(key) {
1570 safe.insert(key.to_string(), value.clone());
1571 }
1572 }
1573 serde_json::Value::Object(safe)
1574}
1575
1576async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
1577 let provider = msg
1578 .get("provider")
1579 .and_then(serde_json::Value::as_str)
1580 .map(str::trim)
1581 .filter(|value| !value.is_empty())
1582 .ok_or_else(|| "set_api_key requires a provider".to_string())?;
1583 let api_key = msg
1584 .get("apiKey")
1585 .and_then(serde_json::Value::as_str)
1586 .map(str::trim)
1587 .filter(|value| !value.is_empty())
1588 .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
1589 crate::config::upsert_credential(
1590 provider,
1591 crate::config::Credential::ApiKey {
1592 key: Some(api_key.to_string()),
1593 env: None,
1594 },
1595 )
1596 .map_err(|error| error.to_string())?;
1597 Ok(serde_json::json!({
1598 "provider": provider,
1599 "ready": true,
1600 "source": "auth.json",
1601 "requiresRestart": true,
1602 }))
1603}
1604
1605fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
1606 let models = catalog
1607 .iter()
1608 .map(crate::rpc::protocol::model_descriptor)
1609 .collect::<Vec<_>>();
1610 serde_json::json!({"models": models})
1611}
1612
1613async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
1614 let levels = lane
1615 .get_model()
1616 .await
1617 .map(|model| model.supported_thinking_levels())
1618 .unwrap_or_default();
1619 serde_json::json!({"levels": levels})
1620}
1621
1622async fn rpc_get_available_tools(
1623 lane: &Arc<dyn AgentLane>,
1624 harness: &AgentHarness,
1625) -> serde_json::Value {
1626 let active = lane.get_active_tools().await.unwrap_or_default();
1627 let tools = harness
1628 .get_tools()
1629 .await
1630 .map(|tools| {
1631 tools
1632 .iter()
1633 .map(|tool| tool.tool.schema().name.clone())
1634 .collect::<Vec<_>>()
1635 })
1636 .unwrap_or_else(|_| active.clone());
1637 serde_json::json!({"tools": tools, "activeTools": active})
1638}
1639
1640async fn rpc_set_model(
1641 lane: &Arc<dyn AgentLane>,
1642 catalog: &[rpi_ai::Model],
1643 msg: &serde_json::Value,
1644) -> Result<serde_json::Value, String> {
1645 let id = msg
1646 .get("modelId")
1647 .or_else(|| msg.get("model"))
1648 .and_then(|v| v.as_str())
1649 .ok_or("set_model requires `modelId`")?;
1650 let provider = msg.get("provider").and_then(|v| v.as_str());
1651 let model = catalog
1652 .iter()
1653 .find(|m| {
1654 m.id.eq_ignore_ascii_case(id)
1655 && provider.map_or(true, |provider| m.provider.eq_ignore_ascii_case(provider))
1656 })
1657 .cloned()
1658 .ok_or_else(|| format!("unknown model: {id}"))?;
1659 lane.set_model(model).await.map_err(|e| e.to_string())?;
1660 Ok(serde_json::json!({"modelId": id, "provider": provider}))
1661}
1662
1663async fn rpc_set_thinking_level(
1664 lane: &Arc<dyn AgentLane>,
1665 msg: &serde_json::Value,
1666) -> Result<serde_json::Value, String> {
1667 let value = msg
1668 .get("level")
1669 .or_else(|| msg.get("thinkingLevel"))
1670 .ok_or("set_thinking_level requires `level`")?;
1671 let level: rpi_ai::types::ThinkingLevel =
1672 serde_json::from_value(value.clone()).map_err(|_| {
1673 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
1674 })?;
1675 lane.set_thinking_level(level)
1676 .await
1677 .map_err(|e| e.to_string())?;
1678 Ok(serde_json::json!({"thinkingLevel": level}))
1679}
1680
1681async fn rpc_set_active_tools(
1682 lane: &Arc<dyn AgentLane>,
1683 msg: &serde_json::Value,
1684) -> Result<serde_json::Value, String> {
1685 let tools = msg
1686 .get("tools")
1687 .and_then(|v| v.as_array())
1688 .map(|arr| {
1689 arr.iter()
1690 .filter_map(|v| v.as_str().map(str::to_owned))
1691 .collect::<Vec<_>>()
1692 })
1693 .ok_or("set_active_tools requires a `tools` array")?;
1694 lane.set_active_tools(tools.clone())
1695 .await
1696 .map_err(|e| e.to_string())?;
1697 Ok(serde_json::json!({"activeTools": tools}))
1698}
1699
1700fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
1705 crate::rpc::events::project(event)
1706}
1707
1708fn emit_json_event(event: &HarnessEvent) {
1712 let line = match event {
1713 HarnessEvent::CompactionProgress(e) => {
1714 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
1715 }
1716 HarnessEvent::RunStart(e) => serde_json::json!({
1717 "type": "run_start",
1718 "lane": e.lane,
1719 "runId": e.run_id,
1720 }),
1721 HarnessEvent::RunEnd(e) => serde_json::json!({
1722 "type": "run_end",
1723 "lane": e.lane,
1724 "runId": e.run_id,
1725 "outcome": run_end_outcome_str(e.outcome),
1726 "leafId": e.leaf_id,
1727 }),
1728 };
1729 println!("{line}");
1730}
1731
1732fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
1733 match o {
1734 RunEndOutcome::Completed => "completed",
1735 RunEndOutcome::Aborted => "aborted",
1736 RunEndOutcome::Failed => "failed",
1737 }
1738}
1739
1740pub async fn interactive(
1749 harness: &AgentHarness,
1750 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
1751 args: &Args,
1752 model_catalog: Vec<rpi_ai::Model>,
1753 initial: Option<String>,
1754 extra_messages: &[String],
1755 initial_images: Vec<ImageContent>,
1756 theme: Option<&str>,
1757 no_themes: bool,
1758 reload_context: &crate::session::ReloadContext,
1759) -> i32 {
1760 if let Some(reason) = crate::session::dispatch_session_event_async(
1767 reload_context,
1768 rpi_plugin_sdk::EventTag::BeforeTuiStart,
1769 )
1770 .await
1771 {
1772 eprintln!("[rpi] startup vetoed by extension: {reason}");
1773 return crate::app::EXIT_VETOED;
1774 }
1775
1776 let force_tui = std::env::var("RPI_FORCE_TUI")
1778 .map(|v| v == "1")
1779 .unwrap_or(false);
1780 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
1781 crate::interactive_tui::interactive_tui(
1783 harness,
1784 event_rx,
1785 args,
1786 model_catalog,
1787 initial,
1788 extra_messages,
1789 initial_images,
1790 theme,
1791 no_themes,
1792 reload_context,
1793 )
1794 .await
1795 } else {
1796 interactive_repl(harness, args, initial, extra_messages, initial_images).await
1798 };
1799
1800 if let Some(reason) = crate::session::dispatch_session_event_async(
1806 reload_context,
1807 rpi_plugin_sdk::EventTag::SessionShutdown,
1808 )
1809 .await
1810 {
1811 tracing::warn!(
1812 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
1813 );
1814 }
1815
1816 code
1817}
1818
1819pub async fn interactive_repl(
1821 harness: &AgentHarness,
1822 #[allow(unused_variables)] args: &Args,
1823 initial: Option<String>,
1824 extra_messages: &[String],
1825 initial_images: Vec<ImageContent>,
1826) -> i32 {
1827 let lane: Arc<dyn AgentLane> = harness.lane("main");
1829 let stdin = std::io::stdin();
1830 let is_tty = stdin.is_terminal();
1831
1832 if is_tty {
1833 println!(
1834 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
1835 );
1836 }
1837
1838 let mut prompts: Vec<String> = Vec::new();
1840 if let Some(init) = initial {
1841 prompts.push(init);
1842 }
1843 for m in extra_messages {
1844 prompts.push(m.clone());
1845 }
1846 let mut images = initial_images;
1847 for prompt in prompts {
1848 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
1849 return code;
1850 }
1851 }
1852
1853 let mut line = String::new();
1855 loop {
1856 if is_tty {
1857 print!("> ");
1858 let _ = std::io::stdout().flush();
1859 }
1860 line.clear();
1861 match stdin.lock().read_line(&mut line) {
1862 Ok(0) => break, Ok(_) => {}
1864 Err(_) => break,
1865 }
1866 let trimmed = line.trim();
1867 if trimmed.is_empty() {
1868 continue;
1869 }
1870 if trimmed == "/exit" || trimmed == "/quit" {
1871 break;
1872 }
1873 if trimmed == "/abort" {
1874 let _ = lane.abort().await;
1875 eprintln!("(aborted)");
1876 continue;
1877 }
1878 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
1879 return code;
1880 }
1881 }
1882 0
1883}
1884
1885async fn run_one(
1889 lane: &Arc<dyn AgentLane>,
1890 prompt: &str,
1891 images: Vec<ImageContent>,
1892) -> Result<(), i32> {
1893 match lane.prompt_text(prompt, images).await {
1894 Ok(result) => {
1895 match &result.outcome {
1896 HarnessRunOutcome::Completed { final_message, .. }
1897 | HarnessRunOutcome::Aborted { final_message, .. } => {
1898 let text = assistant_text(final_message);
1899 if !text.is_empty() {
1900 println!("{text}");
1901 }
1902 }
1903 HarnessRunOutcome::Failed {
1904 error,
1905 final_message,
1906 ..
1907 } => {
1908 if let Some(m) = final_message {
1909 if let Some(em) = &m.error_message {
1910 eprintln!("error: {em}");
1911 }
1912 }
1913 eprintln!("run failed: {error:?}");
1914 }
1915 HarnessRunOutcome::Suspended { .. } => {
1916 eprintln!("run suspended (deferred) — resume not supported in v1");
1917 }
1918 }
1919 Ok(())
1920 }
1921 Err(e) => {
1922 eprintln!("prompt rejected: {e}");
1923 Err(1)
1924 }
1925 }
1926}
1927
1928#[cfg(test)]
1929mod tests {
1930 use super::*;
1931 use rpi_agent::events::AgentEvent;
1932 use rpi_agent::types::AgentToolResult;
1933 use rpi_ai::types::{
1934 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
1935 };
1936 use rpi_harness::session::types::OperationError;
1937
1938 #[test]
1939 fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
1940 let _guard = crate::config::test_support::env_lock().lock().unwrap();
1941 struct RestoreConfig(Option<std::ffi::OsString>);
1942 impl Drop for RestoreConfig {
1943 fn drop(&mut self) {
1944 match self.0.take() {
1945 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
1946 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
1947 }
1948 }
1949 }
1950 let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
1951 let directory = tempfile::tempdir().unwrap();
1952 std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
1953 std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
1954 "header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
1955 "expired-account": {"api":"openai-completions", "models":[]}
1956 }}).to_string()).unwrap();
1957 std::fs::write(directory.path().join("auth.json"), serde_json::json!({
1958 "expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
1959 }).to_string()).unwrap();
1960 let status = rpc_get_auth_status(&[], None);
1961 let providers = status["providers"].as_array().unwrap();
1962 let header = providers
1963 .iter()
1964 .find(|row| row["provider"] == "header-gateway")
1965 .unwrap();
1966 assert_eq!(header["ready"], true);
1967 assert_eq!(header["source"], "headers");
1968 assert_eq!(header["status"], "ready");
1969 let expired = providers
1970 .iter()
1971 .find(|row| row["provider"] == "expired-account")
1972 .unwrap();
1973 assert_eq!(expired["ready"], false);
1974 assert_eq!(expired["status"], "expired");
1975 assert!(!status.to_string().contains("secret"));
1976 }
1977
1978 #[test]
1979 fn oauth_display_drops_token_like_fields() {
1980 let display = oauth_display(&serde_json::json!({
1981 "display": {
1982 "title": "Sign in",
1983 "url": "https://example.test/authorize",
1984 "instructions": ["Paste the code"],
1985 "access_token": "secret-access",
1986 "refresh_token": "secret-refresh",
1987 }
1988 }));
1989 assert_eq!(display["title"], "Sign in");
1990 assert!(display.get("access_token").is_none());
1991 assert!(display.get("refresh_token").is_none());
1992 }
1993
1994 #[test]
1995 fn rpc_message_text_joins_content_blocks_with_real_newlines() {
1996 let text = rpc_message_text(&serde_json::json!({
1997 "content": [
1998 {"type": "text", "text": "first"},
1999 {"type": "text", "text": "second"}
2000 ]
2001 }));
2002 assert_eq!(text, "first\nsecond");
2003 assert!(!text.contains("\\n"));
2004 }
2005
2006 #[derive(Clone, Default)]
2007 struct RpcOutput(Arc<Mutex<Vec<u8>>>);
2008
2009 impl Write for RpcOutput {
2010 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2011 self.0.lock().unwrap().extend_from_slice(bytes);
2012 Ok(bytes.len())
2013 }
2014 fn flush(&mut self) -> std::io::Result<()> {
2015 Ok(())
2016 }
2017 }
2018
2019 impl RpcOutput {
2020 fn messages(&self) -> Vec<serde_json::Value> {
2021 String::from_utf8(self.0.lock().unwrap().clone())
2022 .unwrap()
2023 .lines()
2024 .map(|line| serde_json::from_str(line).unwrap())
2025 .collect()
2026 }
2027
2028 async fn wait_for(
2029 &self,
2030 predicate: impl Fn(&serde_json::Value) -> bool,
2031 ) -> serde_json::Value {
2032 tokio::time::timeout(std::time::Duration::from_secs(3), async {
2033 loop {
2034 if let Some(message) = self.messages().into_iter().find(&predicate) {
2035 return message;
2036 }
2037 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
2038 }
2039 })
2040 .await
2041 .expect("RPC message not received")
2042 }
2043 }
2044
2045 async fn rpc_test_harness(
2046 text: &str,
2047 tokens_per_second: f64,
2048 ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
2049 let provider = rpi_ai::providers::faux::FauxProvider::new(
2050 rpi_ai::providers::faux::FauxScript::new()
2051 .with_text(text)
2052 .with_tokens_per_second(tokens_per_second),
2053 );
2054 let options = rpi_harness::types::AgentHarnessOptions {
2055 model: provider.default_model().clone(),
2056 models: vec![provider.clone()],
2057 ..Default::default()
2058 };
2059 (AgentHarness::create(options).await.unwrap(), provider)
2060 }
2061
2062 fn start_test_rpc(
2063 harness: AgentHarness,
2064 ui: rpi_extensions::UiDialogMailbox,
2065 ) -> (
2066 tokio::io::DuplexStream,
2067 RpcOutput,
2068 tokio::task::JoinHandle<i32>,
2069 ) {
2070 let (client, input) = tokio::io::duplex(4096);
2071 let output = RpcOutput::default();
2072 let captured = output.clone();
2073 let task = tokio::spawn(async move {
2074 rpc_with_io(
2075 &harness,
2076 None,
2077 vec![],
2078 ui,
2079 None,
2080 tokio::io::BufReader::new(input),
2081 captured,
2082 )
2083 .await
2084 });
2085 (client, output, task)
2086 }
2087
2088 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2089 async fn rpc_eof_waits_for_accepted_prompt() {
2090 use tokio::io::AsyncWriteExt;
2091 let (harness, _) = rpc_test_harness("completed", 100.0).await;
2092 let ui = rpi_extensions::UiDialogMailbox::new();
2093 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2094 client
2095 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2096 .await
2097 .unwrap();
2098 client.shutdown().await.unwrap();
2099 assert_eq!(
2100 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2101 .await
2102 .unwrap()
2103 .unwrap(),
2104 0
2105 );
2106 let response = output
2107 .messages()
2108 .into_iter()
2109 .find(|m| m["id"] == 1)
2110 .unwrap();
2111 assert_eq!(response["command"], "prompt");
2112 assert_eq!(response["data"]["accepted"], true);
2113 assert!(output.messages().iter().any(|message| {
2114 message["type"] == "agent_settled" && message["outcome"] == "completed"
2115 }));
2116 assert!(!ui.is_attached());
2117 }
2118
2119 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2120 async fn rpc_compaction_settlement_identifies_its_durable_operation() {
2121 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2122 let output = RpcOutput::default();
2123 let code = rpc_with_io(
2124 &harness,
2125 None,
2126 vec![],
2127 rpi_extensions::UiDialogMailbox::new(),
2128 None,
2129 tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
2130 output.clone(),
2131 )
2132 .await;
2133 assert_eq!(code, 0);
2134 let messages = output.messages();
2135 let settled = messages
2136 .iter()
2137 .find(|message| message["type"] == "agent_settled")
2138 .unwrap();
2139 assert_eq!(settled["outcome"], "declined");
2140 let run_id = settled["runId"]
2141 .as_str()
2142 .expect("compaction settlement needs its durable run id");
2143 let records = harness
2144 .session()
2145 .find_records(&Default::default())
2146 .await
2147 .unwrap();
2148 assert!(records.iter().any(|record| matches!(record,
2149 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2150 if finished.run_id == run_id)));
2151 }
2152
2153 #[tokio::test]
2154 async fn rpc_eof_drains_queued_final_events() {
2155 let harness = AgentHarness::create(Default::default()).await.unwrap();
2156 let (tx, rx) = tokio::sync::broadcast::channel(256);
2157 for _ in 0..100 {
2158 tx.send(AgentEvent::AgentStart).unwrap();
2159 }
2160 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
2161 let output = RpcOutput::default();
2162 let code = rpc_with_io(
2163 &harness,
2164 Some(rx),
2165 vec![],
2166 rpi_extensions::UiDialogMailbox::new(),
2167 None,
2168 tokio::io::BufReader::new(&b""[..]),
2169 output.clone(),
2170 )
2171 .await;
2172 assert_eq!(code, 0);
2173 let messages = output.messages();
2174 assert_eq!(
2175 messages.len(),
2176 102,
2177 "ready plus every queued event must be flushed"
2178 );
2179 assert_eq!(messages.last().unwrap()["type"], "agent_end");
2180 }
2181
2182 struct BackpressuredOutput {
2183 entered: Arc<std::sync::atomic::AtomicBool>,
2184 release: Arc<std::sync::atomic::AtomicBool>,
2185 output: RpcOutput,
2186 }
2187
2188 impl Write for BackpressuredOutput {
2189 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2190 self.entered
2191 .store(true, std::sync::atomic::Ordering::SeqCst);
2192 while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
2193 std::thread::sleep(std::time::Duration::from_millis(1));
2194 }
2195 self.output.write(bytes)
2196 }
2197 fn flush(&mut self) -> std::io::Result<()> {
2198 Ok(())
2199 }
2200 }
2201
2202 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2203 async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
2204 use std::sync::atomic::{AtomicBool, Ordering};
2205 let release = Arc::new(AtomicBool::new(false));
2206 struct ReleaseOnDrop(Arc<AtomicBool>);
2207 impl Drop for ReleaseOnDrop {
2208 fn drop(&mut self) {
2209 self.0.store(true, Ordering::SeqCst);
2210 }
2211 }
2212 let _release = ReleaseOnDrop(release.clone());
2213 let entered = Arc::new(AtomicBool::new(false));
2214 let output = RpcOutput::default();
2215 let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
2216 entered: entered.clone(),
2217 release: release.clone(),
2218 output: output.clone(),
2219 });
2220 write_line(&writer, &serde_json::json!({"id": 0})).await;
2221 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2222 while !entered.load(Ordering::SeqCst) {
2223 tokio::task::yield_now().await;
2224 }
2225 })
2226 .await
2227 .unwrap();
2228 let producer_writer = writer.clone();
2229 let producer = tokio::spawn(async move {
2230 for id in 1..=100 {
2231 write_line(&producer_writer, &serde_json::json!({"id": id})).await;
2232 }
2233 });
2234 tokio::time::timeout(
2235 std::time::Duration::from_millis(150),
2236 tokio::time::sleep(std::time::Duration::from_millis(20)),
2237 )
2238 .await
2239 .unwrap();
2240 assert!(
2241 !producer.is_finished(),
2242 "the bounded queue must apply backpressure"
2243 );
2244 release.store(true, Ordering::SeqCst);
2245 producer.await.unwrap();
2246 drop(writer);
2247 task.await.unwrap().unwrap();
2248 let messages = output.messages();
2249 assert_eq!(messages.len(), 101);
2250 for (index, message) in messages.iter().enumerate() {
2251 assert_eq!(message["id"], index);
2252 }
2253 }
2254
2255 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2256 async fn rpc_completion_response_follows_final_event_under_backpressure() {
2257 use std::sync::atomic::{AtomicBool, Ordering};
2258 let (harness, _) = rpc_test_harness("completed", 0.0).await;
2259 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
2260 harness
2261 .set_agent_emitter(Some(Arc::new(emitter)))
2262 .await
2263 .unwrap();
2264 let output = RpcOutput::default();
2265 let release = Arc::new(AtomicBool::new(false));
2266 let release_writer = release.clone();
2267 let release_thread = std::thread::spawn(move || {
2268 std::thread::sleep(std::time::Duration::from_millis(50));
2269 release_writer.store(true, Ordering::SeqCst);
2270 });
2271 let code = rpc_with_io(
2272 &harness,
2273 Some(events),
2274 vec![],
2275 rpi_extensions::UiDialogMailbox::new(),
2276 None,
2277 tokio::io::BufReader::new(
2278 &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
2279 ),
2280 BackpressuredOutput {
2281 entered: Arc::new(AtomicBool::new(false)),
2282 release,
2283 output: output.clone(),
2284 },
2285 )
2286 .await;
2287 release_thread.join().unwrap();
2288 assert_eq!(code, 0);
2289 let messages = output.messages();
2290 let end = messages
2291 .iter()
2292 .position(|message| message["type"] == "agent_end")
2293 .unwrap();
2294 let settled = messages
2295 .iter()
2296 .position(|message| message["type"] == "agent_settled")
2297 .unwrap();
2298 assert!(end < settled);
2299 let run_id = messages[settled]["runId"].as_str().unwrap();
2300 assert!(!run_id.is_empty(), "settlement identifies its durable run");
2301 let records = harness
2302 .session()
2303 .find_records(&Default::default())
2304 .await
2305 .unwrap();
2306 assert!(records.iter().any(|record| matches!(record,
2307 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2308 if finished.run_id == run_id)));
2309 assert!(messages.iter().any(|message| {
2310 message["type"] == "response" && message["data"]["accepted"] == true
2311 }));
2312 assert!(messages.iter().any(|message| {
2313 message["type"] == "agent_settled" && message["outcome"] == "completed"
2314 }));
2315 }
2316
2317 #[tokio::test]
2318 async fn rpc_output_failure_closes_without_waiting_for_more_input() {
2319 struct BrokenOutput;
2320 impl Write for BrokenOutput {
2321 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
2322 Err(std::io::Error::new(
2323 std::io::ErrorKind::BrokenPipe,
2324 "closed client output",
2325 ))
2326 }
2327 fn flush(&mut self) -> std::io::Result<()> {
2328 Ok(())
2329 }
2330 }
2331 let harness = AgentHarness::create(Default::default()).await.unwrap();
2332 let (_client, input) = tokio::io::duplex(4096);
2333 let ui = rpi_extensions::UiDialogMailbox::new();
2334 let code = tokio::time::timeout(
2335 std::time::Duration::from_secs(2),
2336 rpc_with_io(
2337 &harness,
2338 None,
2339 vec![],
2340 ui.clone(),
2341 None,
2342 tokio::io::BufReader::new(input),
2343 BrokenOutput,
2344 ),
2345 )
2346 .await
2347 .unwrap();
2348 assert_eq!(code, 1);
2349 assert!(!ui.is_attached());
2350 }
2351
2352 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2353 async fn rpc_stop_aborts_and_joins_active_prompt() {
2354 use tokio::io::AsyncWriteExt;
2355 let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
2356 let lane = harness.lane("main");
2357 let (mut client, output, task) =
2358 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2359 client
2360 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2361 .await
2362 .unwrap();
2363 tokio::time::timeout(std::time::Duration::from_secs(3), async {
2364 while provider
2365 .state()
2366 .call_count
2367 .load(std::sync::atomic::Ordering::SeqCst)
2368 == 0
2369 {
2370 tokio::task::yield_now().await;
2371 }
2372 })
2373 .await
2374 .unwrap();
2375 client
2376 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
2377 .await
2378 .unwrap();
2379 assert_eq!(
2380 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2381 .await
2382 .unwrap()
2383 .unwrap(),
2384 0
2385 );
2386 let messages = output.messages();
2387 assert!(messages.iter().any(|message| {
2388 message["type"] == "agent_settled"
2389 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2390 }));
2391 assert_eq!(
2392 messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
2393 true
2394 );
2395 assert!(
2396 lane.abort().await.is_err(),
2397 "RPC returned with an active lane"
2398 );
2399 }
2400
2401 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2402 async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
2403 use tokio::io::AsyncWriteExt;
2404 let (harness, _) = rpc_test_harness("not completed", 20.0).await;
2405 let lane = harness.lane("main");
2406 let (mut client, output, task) =
2407 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2408 client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
2409 assert_eq!(
2410 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2411 .await
2412 .unwrap()
2413 .unwrap(),
2414 0
2415 );
2416 let messages = output.messages();
2417 assert!(messages.iter().any(|message| {
2418 message["type"] == "agent_settled"
2419 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
2420 }));
2421 assert!(lane.abort().await.is_err());
2422 }
2423
2424 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2425 async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
2426 use tokio::io::AsyncWriteExt;
2427 let (harness, _) =
2428 rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
2429 let ui = rpi_extensions::UiDialogMailbox::new();
2430 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2431 output.wait_for(|m| m["type"] == "ready").await;
2432 client
2433 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2434 .await
2435 .unwrap();
2436 ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
2437 .unwrap();
2438 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2439 client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
2440 output.wait_for(|m| m["id"] == 2).await;
2441 assert_eq!(ui.poll("approval").unwrap()["answer"], true);
2442 output
2443 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
2444 .await;
2445 client.shutdown().await.unwrap();
2446 assert_eq!(
2447 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2448 .await
2449 .unwrap()
2450 .unwrap(),
2451 0
2452 );
2453 assert!(output.messages().iter().any(|message| {
2454 message["type"] == "agent_settled" && message["outcome"] == "completed"
2455 }));
2456 }
2457
2458 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2459 async fn rpc_notifies_client_when_an_approval_is_cancelled() {
2460 use tokio::io::AsyncWriteExt;
2461 let harness = AgentHarness::create(Default::default()).await.unwrap();
2462 let ui = rpi_extensions::UiDialogMailbox::new();
2463 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2464 output.wait_for(|m| m["type"] == "ready").await;
2465 ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
2466 .unwrap();
2467 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
2468 ui.cancel_tool_call("c");
2469 output
2470 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
2471 .await;
2472 assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
2474 client.shutdown().await.unwrap();
2475 assert_eq!(task.await.unwrap(), 0);
2476 }
2477
2478 #[tokio::test]
2479 async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
2480 use crate::remote::protocol::RemoteCommand;
2481 use tokio::io::AsyncWriteExt;
2482 let harness = AgentHarness::create(Default::default()).await.unwrap();
2483 let ui = rpi_extensions::UiDialogMailbox::new();
2484 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2485 output.wait_for(|m| m["type"] == "ready").await;
2486 for (request_id, answer, response_id) in [
2487 (
2488 "answered",
2489 Some(serde_json::json!({"values":["No"],"kind":"selector"})),
2490 5,
2491 ),
2492 ("cancelled", None, 6),
2493 ] {
2494 ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
2495 .unwrap();
2496 output
2497 .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
2498 .await;
2499 let command = RemoteCommand::UiDialogResponse {
2500 request_id: request_id.into(),
2501 cancelled: answer.is_none(),
2502 answer,
2503 }
2504 .with_id(response_id);
2505 client
2506 .write_all(format!("{command}\n").as_bytes())
2507 .await
2508 .unwrap();
2509 let ack = output.wait_for(|m| m["id"] == response_id).await;
2510 assert_eq!(ack["data"]["accepted"], true);
2511 let result = ui.poll(request_id).unwrap();
2512 if request_id == "answered" {
2513 assert_eq!(result["answer"]["values"][0], "No");
2514 } else {
2515 assert_eq!(result["status"], "cancelled");
2516 }
2517 }
2518 client.shutdown().await.unwrap();
2519 assert_eq!(task.await.unwrap(), 0);
2520 }
2521
2522 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
2523 AssistantMessage {
2524 role: rpi_ai::types::AssistantRole,
2525 content: vec![Content::Text(TextContent {
2526 kind: TextContentType,
2527 text: text.into(),
2528 text_signature: None,
2529 })],
2530 api: rpi_ai::Api::AnthropicMessages,
2531 provider: "anthropic".into(),
2532 model: "claude-sonnet-5".into(),
2533 response_model: None,
2534 response_id: None,
2535 usage: Usage::zero(),
2536 stop_reason: stop,
2537 deferred: None,
2538 error_message: None,
2539 raw_stop_reason: None,
2540 end_turn: None,
2541 timestamp: 0,
2542 }
2543 }
2544
2545 #[test]
2546 fn assistant_text_concatenates_text_blocks() {
2547 let m = assistant("hello", StopReason::Stop);
2548 assert_eq!(assistant_text(&m), "hello");
2549 }
2550
2551 #[test]
2552 fn outcome_exit_code_maps_failed_aborted_to_1() {
2553 let failed = HarnessRunOutcome::Failed {
2554 leaf_id: "l".into(),
2555 error: OperationError {
2556 code: "boom".into(),
2557 message: "boom".into(),
2558 },
2559 final_entry_id: None,
2560 final_message: None,
2561 };
2562 assert_eq!(outcome_exit_code(&failed), 1);
2563 let completed = HarnessRunOutcome::Completed {
2564 leaf_id: "l".into(),
2565 final_entry_id: "e".into(),
2566 final_message: assistant("ok", StopReason::Stop),
2567 };
2568 assert_eq!(outcome_exit_code(&completed), 0);
2569 }
2570
2571 #[test]
2572 fn run_end_outcome_str_roundtrip() {
2573 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
2574 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
2575 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
2576 }
2577
2578 #[test]
2579 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
2580 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
2581 assert_eq!(end["type"], "agent_end");
2582 assert_eq!(end["messages"], serde_json::json!([]));
2583
2584 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
2585 tool_call_id: "call-1".into(),
2586 tool_name: "read".into(),
2587 result: AgentToolResult::text("hello"),
2588 is_error: false,
2589 });
2590 assert_eq!(tool["type"], "tool_execution_end");
2591 assert_eq!(tool["result"]["content"][0]["text"], "hello");
2592 assert_eq!(tool["result"]["terminate"], false);
2593
2594 let retry = agent_event_json(&AgentEvent::RetryScheduled {
2595 attempt: 3,
2596 max_retries: 10,
2597 delay_ms: 8_000,
2598 error: "503 service unavailable".into(),
2599 });
2600 assert_eq!(retry["type"], "retry_scheduled");
2601 assert_eq!(retry["attempt"], 3);
2602 assert_eq!(retry["maxRetries"], 10);
2603 assert_eq!(retry["delayMs"], 8_000);
2604 assert_eq!(retry["error"], "503 service unavailable");
2605 }
2606}
2607
2608#[cfg(test)]
2609mod page_window_tests {
2610 use super::page_window;
2611
2612 #[test]
2613 fn tail_is_kept_without_before_id() {
2614 assert_eq!(page_window(12, 5, None), (7..12, true));
2616 assert_eq!(page_window(3, 5, None), (0..3, false));
2618 }
2619
2620 #[test]
2621 fn before_id_opens_the_window_immediately_before_it() {
2622 assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
2624 assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
2626 }
2627
2628 #[test]
2629 fn limit_larger_than_the_branch_never_underflows() {
2630 assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
2631 assert_eq!(page_window(5, 100, None), (0..5, false));
2632 }
2633}