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