1use std::cmp::Reverse;
19use std::collections::HashSet;
20use std::io::{BufRead, IsTerminal, Write};
21use std::sync::{Arc, Mutex};
22
23use rpi_agent::events::AgentEvent;
24use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason, UserMessage};
25use rpi_harness::agent_harness::{
26 AgentHarness, AgentLane, CompactionOutcome, CompactionResult, HarnessRunOutcome,
27};
28use rpi_harness::events::{HarnessEvent, RunEndOutcome};
29use rpi_harness::session::session::SessionEntryCommit;
30use rpi_harness::session::types::{
31 BranchBounds, Entry, EntryCursor, EntryOrder, EntryQuery, LaneRecord, RecordQuery,
32};
33
34use crate::args::Args;
35use crate::rpc::protocol as rpc_protocol;
36use crate::rpc::writer::{start_rpc_writer, write_agent_event, write_line, RpcWriter};
37
38pub fn assistant_text(msg: &AssistantMessage) -> String {
41 msg.content
42 .iter()
43 .filter_map(|c| match c {
44 Content::Text(t) => Some(t.text.clone()),
45 _ => None,
46 })
47 .collect()
48}
49
50pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
53 match outcome {
54 HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
55 _ => 0,
56 }
57}
58
59pub async fn print(
63 harness: &AgentHarness,
64 args: &Args,
65 initial: Option<String>,
66 extra_messages: &[String],
67 initial_images: Vec<ImageContent>,
68) -> i32 {
69 let lane: Arc<dyn AgentLane> = harness.lane("main");
70
71 let mut last_exit = 0;
72 let mut persistence_failed = false;
73 let mut last_msg: Option<AssistantMessage> = None;
74
75 let mut prompts: Vec<String> = Vec::new();
78 if let Some(init) = initial {
79 prompts.push(init);
80 }
81 for m in extra_messages {
82 prompts.push(m.clone());
83 }
84
85 if prompts.is_empty() {
86 return 0;
88 }
89
90 let mut images = initial_images;
91 for prompt in prompts {
92 let outcome = lane.prompt_text(&prompt, std::mem::take(&mut images)).await;
93 for failure in lane.take_write_failures() {
97 persistence_failed |= args.experimental_native_store.is_some();
98 eprintln!(
99 "session not saved: could not persist {} ({})",
100 failure.what, failure.message
101 );
102 }
103 match outcome {
104 Ok(result) => {
105 last_exit = outcome_exit_code(&result.outcome);
106 match &result.outcome {
107 HarnessRunOutcome::Completed { final_message, .. }
108 | HarnessRunOutcome::Aborted { final_message, .. } => {
109 last_msg = Some(final_message.clone());
110 }
111 HarnessRunOutcome::Failed {
112 error,
113 final_message,
114 ..
115 } => {
116 if let Some(m) = final_message {
117 if m.stop_reason == StopReason::Error {
118 if let Some(em) = &m.error_message {
119 eprintln!("{em}");
120 }
121 }
122 }
123 if error.code == "SessionStorage" {
124 eprintln!("{}", session_storage_notice(&error.message));
125 } else {
126 eprintln!("run failed: {error:?}");
127 }
128 }
129 HarnessRunOutcome::Suspended { .. } => {
130 eprintln!("run suspended (deferred) — resume is not supported in v1");
131 last_exit = 1;
132 }
133 }
134 }
135 Err(e) => {
136 eprintln!("prompt rejected: {e}");
137 return 1;
138 }
139 }
140 }
141
142 if let Some(m) = &last_msg {
144 match m.stop_reason {
145 StopReason::Error => {
146 if let Some(em) = &m.error_message {
147 eprintln!("{em}");
148 }
149 last_exit = 1;
150 }
151 StopReason::Aborted => {
152 eprintln!("request aborted");
153 last_exit = 1;
154 }
155 _ => {
156 let text = assistant_text(m);
157 let mut out = std::io::stdout();
158 let _ = out.write_all(text.as_bytes());
159 if !text.ends_with('\n') {
160 let _ = out.write_all(b"\n");
161 }
162 let _ = out.flush();
163 }
164 }
165 }
166
167 if persistence_failed {
168 1
169 } else {
170 last_exit
171 }
172}
173
174pub async fn json(
178 harness: &AgentHarness,
179 args: &Args,
180 initial: Option<String>,
181 extra_messages: &[String],
182 initial_images: Vec<ImageContent>,
183 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
184) -> i32 {
185 let lane: Arc<dyn AgentLane> = harness.lane("main");
186 let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
187 let collected_for_watch = collected.clone();
188
189 let mut watch = harness.events().watch(|| ());
192 watch.start(Arc::new(move |event: &HarnessEvent| {
193 emit_json_event(event);
195 collected_for_watch.lock().unwrap().push(event.clone());
196 }));
197 std::mem::forget(watch);
200
201 let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
204 let agent_event_task = agent_events.take().map(|mut rx| {
205 tokio::spawn(async move {
206 let done_tx = agent_done_tx;
207 loop {
208 match rx.recv().await {
209 Ok(event) => {
210 let terminal = event.is_terminal();
211 emit_agent_event(&event);
212 if terminal {
213 let _ = done_tx.send(());
214 }
215 }
216 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
217 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
218 }
219 }
220 })
221 });
222
223 let mut prompts: Vec<String> = Vec::new();
224 if let Some(init) = initial {
225 prompts.push(init);
226 }
227 for m in extra_messages {
228 prompts.push(m.clone());
229 }
230
231 let mut last_exit = 0;
232 let mut persistence_failed = false;
233 let mut final_outcome: Option<HarnessRunOutcome> = None;
234
235 let mut images = initial_images;
236 for prompt in prompts {
237 match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
238 Ok(result) => {
239 let _ = tokio::time::timeout(
245 std::time::Duration::from_millis(250),
246 agent_done_rx.recv(),
247 )
248 .await;
249 last_exit = outcome_exit_code(&result.outcome);
250 final_outcome = Some(result.outcome);
251 }
252 Err(e) => {
253 let line = serde_json::json!({
255 "type": "error",
256 "error": e.to_string(),
257 });
258 println!("{line}");
259 if args.experimental_native_store.is_some() {
260 for failure in lane.take_write_failures() {
261 persistence_failed = true;
262 println!(
263 "{}",
264 serde_json::json!({"type":"error","kind":"session_write_failed","error":failure.message,"what":failure.what})
265 );
266 }
267 last_exit = 1;
268 break;
269 }
270 if let Some(task) = agent_event_task {
271 task.abort();
272 }
273 return 1;
274 }
275 }
276 if args.experimental_native_store.is_some() {
277 for failure in lane.take_write_failures() {
278 persistence_failed = true;
279 println!(
280 "{}",
281 serde_json::json!({"type":"error","kind":"session_write_failed","error":failure.message,"what":failure.what})
282 );
283 }
284 }
285 }
286
287 let (outcome_str, final_text) = match final_outcome {
289 Some(HarnessRunOutcome::Completed { final_message, .. }) => {
290 ("completed", Some(assistant_text(&final_message)))
291 }
292 Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
293 ("aborted", Some(assistant_text(&final_message)))
294 }
295 Some(HarnessRunOutcome::Failed { final_message, .. }) => {
296 let t = final_message.as_ref().map(assistant_text);
297 ("failed", t)
298 }
299 Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
300 None if last_exit != 0 => ("failed", None),
301 None => ("idle", None),
302 };
303 let mut result_line = serde_json::json!({
304 "type": "result",
305 "outcome": if persistence_failed{"failed"}else{outcome_str},
306 "finalText": final_text,
307 });
308 if persistence_failed {
309 result_line["persistenceFailed"] = true.into();
310 last_exit = 1;
311 }
312 println!("{result_line}");
313 if let Some(task) = agent_event_task {
314 task.abort();
315 }
316 last_exit
317}
318
319fn emit_agent_event(event: &AgentEvent) {
320 println!("{}", agent_event_json(event));
321}
322
323pub async fn rpc(
342 harness: &AgentHarness,
343 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
344 model_catalog: Vec<rpi_ai::Model>,
345 ui_dialog: rpi_extensions::UiDialogMailbox,
346 oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
347 message_updates: crate::args::RpcMessageUpdates,
348) -> i32 {
349 rpc_with_io(
350 harness,
351 agent_events.take(),
352 model_catalog,
353 ui_dialog,
354 oauth_snapshot,
355 message_updates,
356 tokio::io::BufReader::new(tokio::io::stdin()),
357 std::io::stdout(),
358 )
359 .await
360}
361
362struct RpcPhaseTiming {
365 phase: &'static str,
366 started: Option<std::time::Instant>,
367}
368
369impl RpcPhaseTiming {
370 fn start(enabled: bool, phase: &'static str) -> Self {
371 let started = enabled.then(std::time::Instant::now);
372 if enabled {
373 eprintln!("RPI_RPC_DIAGNOSTICS phase={phase} event=begin");
374 }
375 Self { phase, started }
376 }
377
378 fn finish(self) {
379 if let Some(started) = self.started {
380 eprintln!(
381 "RPI_RPC_DIAGNOSTICS phase={} event=end elapsed_ns={}",
382 self.phase,
383 started.elapsed().as_nanos()
384 );
385 }
386 }
387}
388
389async fn rpc_with_io<R, W>(
390 harness: &AgentHarness,
391 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
392 model_catalog: Vec<rpi_ai::Model>,
393 ui_dialog: rpi_extensions::UiDialogMailbox,
394 oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
395 message_updates: crate::args::RpcMessageUpdates,
396 mut input: R,
397 output: W,
398) -> i32
399where
400 R: tokio::io::AsyncBufRead + Unpin,
401 W: Write + Send + 'static,
402{
403 let phase_diagnostics =
404 std::env::var_os("RPI_RPC_DIAGNOSTICS").is_some_and(|value| value != "0");
405 let setup_timing = RpcPhaseTiming::start(phase_diagnostics, "setup_to_ready_enqueue");
406 let mut command_tasks: Vec<RetainedRpcTask<()>> = Vec::new();
407 let lane: Arc<dyn AgentLane> = harness.lane("main");
408 let (stdout, writer_task, writer_failed) = start_rpc_writer(output, message_updates);
411 let writer_task = RetainedRpcTask::new(writer_task, writer_failed.clone());
412 let pump_stop = tokio_util::sync::CancellationToken::new();
413 let external_tools =
414 crate::rpc::external_tools::ExternalTools::new(stdout.clone(), Arc::downgrade(&lane));
415 let mut external_names = Vec::new();
416 let file_io_router = crate::session::file_io_router(harness);
417 let mut owner = RpcOwner {
418 harness,
419 file_io: None,
420 backend: external_tools.clone(),
421 run_cancel: tokio_util::sync::CancellationToken::new(),
422 pump_stop: pump_stop.clone(),
423 failed: writer_failed.clone(),
424 mailbox: ui_dialog.clone(),
425 armed: true,
426 };
427
428 let (commit_tx, mut commit_rx) = tokio::sync::broadcast::channel::<SessionEntryCommit>(256);
432 harness
433 .session()
434 .set_entry_commit_publisher(Some(commit_tx.clone()));
435 let commit_task = {
436 let out = stdout.clone();
437 tokio::spawn(async move {
438 while let Some(event) =
439 crate::rpc::tool_program_queries::commit_stream_event(commit_rx.recv().await)
440 {
441 write_line(&out, &event).await;
442 }
443 })
444 };
445
446 let commit_task = RetainedRpcTask::new(commit_task, pump_stop.clone());
447
448 let (event_flush_tx, mut event_flush_rx) =
451 tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
452 let event_task = agent_events.take().map(|mut rx| {
454 let out = stdout.clone();
455 let stop = pump_stop.clone();
456 tokio::spawn(async move {
457 loop {
458 let received = tokio::select! {
459 _ = stop.cancelled() => {
460 drain_rpc_events(&out, &mut rx).await;
461 break;
462 }
463 Some(done) = event_flush_rx.recv() => {
464 drain_rpc_events(&out, &mut rx).await;
465 let _ = done.send(());
466 continue;
467 }
468 event = rx.recv() => event,
469 };
470 match received {
471 Ok(event) => write_agent_event(&out, &event).await,
472 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
473 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
474 }
475 }
476 })
477 });
478
479 let event_task = event_task.map(|task| RetainedRpcTask::new(task, pump_stop.clone()));
480 let event_flush = event_task.as_ref().map(|_| event_flush_tx);
481
482 ui_dialog.attach();
488 let dialog_task = {
489 let mailbox = ui_dialog.clone();
490 let out = stdout.clone();
491 let stop = pump_stop.clone();
492 tokio::spawn(async move {
493 let mut shown = std::collections::HashSet::new();
494 while !stop.is_cancelled() {
495 mailbox.set_external_takeover(true);
498 while let Some(request) =
502 mailbox.take_pending_filtered(|r| !mailbox.is_reserved(&r.request_id))
503 {
504 let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
505 request_id: request.request_id.clone(),
506 tool_call_id: request.tool_call_id,
507 ui: request.ui,
508 };
509 write_line(&out, &serde_json::to_value(event).unwrap()).await;
510 shown.insert(request.request_id);
511 }
512 let closed: Vec<String> = shown
513 .iter()
514 .filter(|id| !mailbox.is_pending(id))
515 .cloned()
516 .collect();
517 for request_id in closed {
518 shown.remove(&request_id);
519 let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
520 write_line(&out, &serde_json::to_value(event).unwrap()).await;
521 }
522 tokio::select! {
523 _ = stop.cancelled() => break,
524 _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
525 }
526 }
527 })
528 };
529
530 let dialog_task = RetainedRpcTask::new(dialog_task, pump_stop.clone());
531
532 let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
536 let mut run_task: Option<OwnedRpcRun> = None;
537 let mut run_cancel = tokio_util::sync::CancellationToken::new();
538 let mut cancel_on_exit = false;
539
540 let binding = match rpi_harness::session::SessionValues::load(harness.session()).await {
541 Ok(values) => crate::resource_selection::binding_state(&values),
542 Err(error) => {
543 eprintln!("resource binding read failed: {error}");
544 return 1;
545 }
546 };
547 let mut ready = rpc_protocol::ready(message_updates);
548 ready["resourceState"] = serde_json::json!({
549 "version": 1, "enabled": harness.has_exact_resources(), "command": "get_resource_state",
550 "maxSnapshotBytes": crate::resource_selection::MAX_STATE_BYTES,
551 "changes": "restart-required", "setActiveTools": if harness.has_exact_resources() { "unsupported" } else { "legacy" },
552 "configureExternalTools": if harness.has_exact_resources() { "unsupported" } else { "supported" },
553 "automaticPersistenceRecovery": false, "hotApply": false,
554 "packageScopes": ["project", "global"], "defaultPackageScope": "project",
555 "transition": crate::resource_selection::transition_capability(),
556 "binding": binding
557 });
558 if harness.has_exact_resources() {
559 if let Ok(state) = crate::resource_selection::get_resource_state(harness).await {
560 for key in [
561 "hostId",
562 "host",
563 "sessionId",
564 "selection",
565 "binding",
566 "transition",
567 ] {
568 ready["resourceState"][key] = state[key].clone();
569 }
570 } else {
571 ready["resourceState"]["enabled"] = serde_json::json!(false);
572 }
573 }
574 write_line(&stdout, &ready).await;
575 setup_timing.finish();
576
577 loop {
578 let incoming = tokio::select! {
579 _ = writer_failed.cancelled() => {
580 cancel_on_exit = true;
581 break;
582 }
583 line = crate::rpc::framing::read_frame(&mut input) => line,
584 };
585 let line = match incoming {
586 Ok(Some(line)) => line,
587 Ok(None) => break, Err(error) => {
589 eprintln!("[rpi] rpc stdin error: {error}");
590 run_cancel.cancel();
591 let _ = lane.abort().await;
592 external_tools.shutdown();
593 writer_failed.cancel();
594 cancel_on_exit = true;
595 break;
596 }
597 };
598 let line = match std::str::from_utf8(&line) {
599 Ok(line) => line.trim(),
600 Err(_) => {
601 run_cancel.cancel();
602 let _ = lane.abort().await;
603 external_tools.shutdown();
604 writer_failed.cancel();
605 cancel_on_exit = true;
606 break;
607 }
608 };
609 if line.is_empty() {
610 continue;
611 }
612 let msg: serde_json::Value = match serde_json::from_str(line) {
613 Ok(value) => value,
614 Err(error) => {
615 write_line(
616 &stdout,
617 &rpc_protocol::response(
618 None,
619 "invalid_request",
620 Err(format!("invalid json: {error}")),
621 ),
622 )
623 .await;
624 continue;
625 }
626 };
627 let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
628 let kind = msg
629 .get("type")
630 .and_then(|v| v.as_str())
631 .unwrap_or("")
632 .to_string();
633
634 if kind == "get_resource_state" && serde_json::to_vec(&id).map_or(true, |v| v.len() > 4096)
635 {
636 write_line(
637 &stdout,
638 &rpc_protocol::response(
639 None,
640 &kind,
641 Err("resource state request id exceeds 4096 bytes".into()),
642 ),
643 )
644 .await;
645 continue;
646 }
647 if kind != "prompt"
648 && rpc_protocol::SUPPORTED_COMMANDS.contains(&kind.as_str())
649 && serde_json::from_value::<rpc_protocol::Request>(msg.clone()).is_err()
650 {
651 write_line(
652 &stdout,
653 &rpc_protocol::response(
654 Some(id.clone()),
655 &kind,
656 Err("invalid command payload".to_string()),
657 ),
658 )
659 .await;
660 continue;
661 }
662
663 match kind.as_str() {
664 "ui_dialog_response" => {
665 let request_id = msg
666 .get("requestId")
667 .and_then(|v| v.as_str())
668 .unwrap_or("")
669 .to_string();
670 let result = if msg
671 .get("cancelled")
672 .and_then(|v| v.as_bool())
673 .unwrap_or(false)
674 {
675 ui_dialog.cancel(&request_id)
676 } else {
677 let answer = msg
678 .get("answer")
679 .cloned()
680 .unwrap_or(serde_json::Value::Null);
681 ui_dialog.respond(&request_id, answer)
682 };
683 if msg.get("id").is_some() {
684 let response = match result {
685 Ok(()) => rpc_protocol::response(
686 Some(id),
687 "ui_dialog_response",
688 Ok(Some(
689 serde_json::json!({"accepted":true,"requestId":request_id}),
690 )),
691 ),
692 Err(error) => {
693 rpc_protocol::response(Some(id), "ui_dialog_response", Err(error))
694 }
695 };
696 write_line(&stdout, &response).await;
697 } else if let Err(error) = result {
698 eprintln!("[rpi] ui_dialog_response: {error}");
699 }
700 continue;
701 }
702 "execute_command" => {
703 command_tasks.retain(|task| !task.is_finished());
704 let snapshot = oauth_snapshot.clone();
705 let request = msg.clone();
706 let out = stdout.clone();
707 command_tasks.push(RetainedRpcTask::new(
708 tokio::spawn(async move {
709 let result = tokio::task::spawn_blocking(move || {
710 rpc_execute_command(snapshot.as_deref(), &request)
711 })
712 .await
713 .unwrap_or_else(|_| Err("command task failed".into()));
714 write_line(
715 &out,
716 &rpc_protocol::response(Some(id), "execute_command", result.map(Some)),
717 )
718 .await;
719 }),
720 writer_failed.clone(),
721 ));
722 continue;
723 }
724 "stop" => {
725 cancel_on_exit = true;
726 run_cancel.cancel();
727 let _ = lane.abort().await;
728 external_tools.shutdown();
729 write_line(
730 &stdout,
731 &rpc_protocol::response(
732 Some(id.clone()),
733 "stop",
734 Ok(Some(serde_json::json!({"stopped": true}))),
735 ),
736 )
737 .await;
738 break;
739 }
740 "prompt" | "resume_pending" | "compact" => {
741 let resume = kind == "resume_pending";
742 let compact = kind == "compact";
743 if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
744 write_line(
745 &stdout,
746 &rpc_protocol::response(
747 Some(id.clone()),
748 &kind,
749 Err("a run is already active".to_string()),
750 ),
751 )
752 .await;
753 continue;
754 }
755 if let Some(task) = run_task.take() {
756 let _ = task.await;
757 }
758 run_cancel = tokio_util::sync::CancellationToken::new();
759 owner.run_cancel = run_cancel.clone();
760 let cancel_task = run_cancel.clone();
761 if resume && !lane.has_pending_resume().await {
764 run_active.store(false, std::sync::atomic::Ordering::Release);
765 write_line(
766 &stdout,
767 &rpc_protocol::response(
768 Some(id),
769 "resume_pending",
770 Ok(Some(serde_json::json!({"resumed": false}))),
771 ),
772 )
773 .await;
774 continue;
775 }
776 let lane_task = Arc::clone(&lane);
777 let out_task = stdout.clone();
778 let active_task = Arc::clone(&run_active);
779 let mailbox_task = ui_dialog.clone();
780 let flush_events = event_flush.clone();
781 let instructions = msg
782 .get("customInstructions")
783 .and_then(|v| v.as_str())
784 .map(str::to_owned);
785 run_task = Some(OwnedRpcRun::new(
790 tokio::spawn(async move {
791 #[cfg(test)]
792 let _finished =
793 crate::rpc::prompt_content::validation_test_hook::for_command(&msg)
794 .map(crate::rpc::prompt_content::validation_test_hook::RunFinished);
795 let validation_timing =
796 RpcPhaseTiming::start(phase_diagnostics, "validation");
797 let content = if resume || compact {
798 Ok(crate::rpc::prompt_content::ParsedPrompt::Legacy(
799 String::new(),
800 ))
801 } else {
802 rpc_validate_prompt(&lane_task, msg, &cancel_task).await
803 };
804 validation_timing.finish();
805 let content = match content {
806 Ok(content) => content,
807 Err(error) => {
808 write_line(
809 &out_task,
810 &rpc_protocol::response(Some(id), &kind, Err(error)),
811 )
812 .await;
813 active_task.store(false, std::sync::atomic::Ordering::Release);
814 return;
815 }
816 };
817 write_line(
818 &out_task,
819 &rpc_protocol::response(
820 Some(id),
821 &kind,
822 Ok(Some(serde_json::json!({"accepted": true}))),
823 ),
824 )
825 .await;
826 let outcome_cancel = cancel_task.clone();
827 let execute_timing = RpcPhaseTiming::start(phase_diagnostics, "execute");
828 let result = if compact {
829 rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
830 } else if resume {
831 rpc_resume_cancellable(&lane_task, cancel_task).await
832 } else {
833 rpc_prompt_cancellable(&lane_task, content, cancel_task).await
834 };
835 execute_timing.finish();
836 let flush_timing = RpcPhaseTiming::start(phase_diagnostics, "event_flush");
837 if let Some(flush) = flush_events {
838 let (done, drained) = tokio::sync::oneshot::channel();
839 if flush.send(done).is_ok() {
840 let _ = drained.await;
841 }
842 }
843 flush_timing.finish();
844 let (outcome, aborted, error) = match &result {
845 Ok(value) => {
846 let outcome = value
847 .get("outcome")
848 .and_then(|v| v.as_str())
849 .unwrap_or("completed")
850 .to_string();
851 let error = value
852 .get("error")
853 .and_then(|v| v.as_str())
854 .map(str::to_owned);
855 (outcome.clone(), outcome == "aborted", error)
856 }
857 Err(error) if outcome_cancel.is_cancelled() => {
858 ("aborted".to_string(), true, Some(error.clone()))
859 }
860 Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
861 };
862 let resume_timing =
865 RpcPhaseTiming::start(phase_diagnostics, "pending_resume");
866 let pending_resume = lane_task.has_pending_resume().await;
867 resume_timing.finish();
868 let summary = result
869 .as_ref()
870 .ok()
871 .and_then(|value| value.get("summary"))
872 .cloned();
873 let settled = serde_json::json!({
874 "type": "agent_settled",
875 "runId": result.as_ref().ok().and_then(|value| value.get("runId")),
876 "outcome": outcome,
877 "aborted": aborted,
878 "error": error,
879 "summary": summary,
880 "pendingResume": pending_resume,
881 "reclaimed": result
885 .as_ref()
886 .ok()
887 .and_then(|value| value.get("reclaimed"))
888 .cloned(),
889 });
890 let enqueue_timing =
891 RpcPhaseTiming::start(phase_diagnostics, "settled_enqueue");
892 write_line(&out_task, &settled).await;
893 enqueue_timing.finish();
894 mailbox_task.cancel_all();
895 active_task.store(false, std::sync::atomic::Ordering::Release);
896 }),
897 run_cancel.clone(),
898 ));
899 continue;
900 }
901 _ => {}
902 }
903
904 let result = match kind.as_str() {
905 "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
906 run_cancel.cancel();
909 let result = lane.abort().await;
910 external_tools.abort();
911 abort_result_reply(result)
915 }
916 "abort" => {
917 let result = lane.abort().await;
918 external_tools.abort();
919 abort_result_reply(result)
920 }
921 "configure_external_tools" => {
922 rpc_configure_external_tools(
923 &lane,
924 harness,
925 run_active.load(std::sync::atomic::Ordering::Acquire),
926 &external_tools,
927 &mut external_names,
928 &msg,
929 )
930 .await
931 }
932 "external_tool_response" => external_tools
933 .respond(&msg)
934 .map(|accepted| serde_json::json!({"accepted": accepted})),
935 "configure_file_io" => {
936 rpc_configure_file_io(
937 &lane,
938 run_active.load(std::sync::atomic::Ordering::Acquire),
939 file_io_router.as_ref(),
940 &mut owner.file_io,
941 &stdout,
942 &msg,
943 )
944 .await
945 }
946 "file_io_response" => {
948 let mut reply = msg;
952 if let Some(object) = reply.as_object_mut() {
953 object.remove("id");
954 }
955 Ok(serde_json::json!({"accepted": owner.file_io.as_ref()
956 .is_some_and(|client| client.resolve_response(&reply))}))
957 }
958 "get_resource_state" => {
959 if msg.as_object().is_none_or(|o| {
960 o.keys()
961 .any(|k| !["type", "id", "version"].contains(&k.as_str()))
962 }) || msg.get("version").is_some_and(|v| v.as_u64() != Some(1))
963 {
964 Err("unsupported resource state request/schema (version 1 only)".into())
965 } else {
966 crate::resource_selection::get_resource_state(harness).await
967 }
968 }
969 "get_state" => Ok(rpc_get_state(&lane, harness).await),
970 "get_commands" => Ok(rpc_get_commands(oauth_snapshot.as_deref())),
971 "get_messages" => rpc_get_messages(&lane, harness, &msg).await,
972 "get_tool_program_branch" => {
973 crate::rpc::tool_program_queries::branch(harness, &msg).await
974 }
975 "get_message_summaries" => crate::rpc::message_queries::summaries(harness, &msg).await,
976 "get_message_window" => crate::rpc::message_queries::window(harness, &msg).await,
977 "get_tool_program_children" => {
978 crate::rpc::tool_program_queries::children(harness, &msg).await
979 }
980 "get_tool_program_child_phases" => {
981 crate::rpc::tool_program_queries::phases(harness, &msg).await
982 }
983 "get_tool_program_child_body" => {
984 crate::rpc::tool_program_queries::body(harness, &msg).await
985 }
986 "get_history" => rpc_get_history(&lane, harness, &msg).await,
987 "get_session_branch" => rpc_get_session_branch(harness, &msg).await,
988 "append_entry" => rpc_append_entry(harness, &msg).await,
989 "get_stats" => rpc_get_stats(&lane, harness).await,
990 "get_queue" => rpc_get_queue(&lane, harness, &msg).await,
991 "queue_message" => rpc_queue_message(&lane, &msg).await,
992 "cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
993 "clear_queue" => rpc_clear_queue(&lane).await,
994 "navigate" => rpc_navigate(&lane, &msg).await,
995 "get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
996 "get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
997 "get_provider_models" => rpc_get_provider_models(&msg).await,
998 "test_provider_config" => rpc_test_provider_config(&msg).await,
999 "oauth" => rpc_oauth(&oauth_snapshot, &msg),
1000 "set_api_key" => rpc_set_api_key(&msg).await,
1001 "set_provider_config" => rpc_set_provider_config(&msg),
1002 "update_provider_config" => rpc_update_provider_config(&msg),
1003 "delete_provider_config" => rpc_delete_provider_config(&msg),
1004 "set_model" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
1005 Err("cannot set model while a run or prompt validation is active".into())
1006 }
1007 "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
1008 "get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
1009 "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
1010 "get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
1011 "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
1012 "ping" => Ok(serde_json::json!({"pong": true})),
1013 other => Err(format!("unknown command type: {other}")),
1014 };
1015
1016 let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
1017 write_line(&stdout, &response).await;
1018 }
1019
1020 ui_dialog.detach();
1024 if cancel_on_exit {
1025 run_cancel.cancel();
1026 let _ = lane.abort().await;
1027 }
1028 external_tools.shutdown();
1031 if let Some(client) = owner.file_io.take() {
1032 client.close();
1033 }
1034 for task in command_tasks {
1035 let _ = task.await;
1036 }
1037 let mut exit_code = 0;
1038 if let Some(mut task) = run_task {
1040 let result = tokio::select! {
1041 biased;
1042 result = &mut task => result,
1043 _ = writer_failed.cancelled() => {
1044 run_cancel.cancel();
1045 let _ = lane.abort().await;
1046 external_tools.shutdown();
1047 task.await
1048 }
1049 };
1050 if let Err(error) = result {
1051 eprintln!("[rpi] rpc prompt task failed: {error}");
1052 exit_code = 1;
1053 }
1054 }
1055 pump_stop.cancel();
1056 harness.session().set_entry_commit_publisher(None);
1057 drop(commit_tx);
1060 if let Err(error) = commit_task.await {
1061 eprintln!("[rpi] rpc commit event task failed: {error}");
1062 exit_code = 1;
1063 }
1064 let _ = dialog_task.await;
1065 if let Some(task) = event_task {
1066 let _ = task.await;
1067 }
1068 drop(stdout);
1070 match writer_task.await {
1071 Ok(Ok(())) => {}
1072 error => {
1073 eprintln!("[rpi] rpc output failed: {error:?}");
1074 exit_code = 1;
1075 }
1076 }
1077 owner.armed = false;
1078 exit_code
1079}
1080
1081type OwnedRpcRun = RetainedRpcTask<()>;
1087struct RetainedRpcTask<T: Send + 'static> {
1088 task: Option<tokio::task::JoinHandle<T>>,
1089 cancel: tokio_util::sync::CancellationToken,
1090}
1091impl<T: Send + 'static> RetainedRpcTask<T> {
1092 fn new(task: tokio::task::JoinHandle<T>, cancel: tokio_util::sync::CancellationToken) -> Self {
1093 Self {
1094 task: Some(task),
1095 cancel,
1096 }
1097 }
1098 fn is_finished(&self) -> bool {
1099 self.task.as_ref().is_none_or(|task| task.is_finished())
1100 }
1101}
1102impl<T: Send + 'static> std::future::Future for RetainedRpcTask<T> {
1103 type Output = Result<T, tokio::task::JoinError>;
1104 fn poll(
1105 mut self: std::pin::Pin<&mut Self>,
1106 cx: &mut std::task::Context<'_>,
1107 ) -> std::task::Poll<Self::Output> {
1108 let result = std::future::Future::poll(
1109 std::pin::Pin::new(self.task.as_mut().expect("RPC task already joined")),
1110 cx,
1111 );
1112 if result.is_ready() {
1113 self.task.take();
1114 }
1115 result
1116 }
1117}
1118impl<T: Send + 'static> Drop for RetainedRpcTask<T> {
1119 fn drop(&mut self) {
1120 if let Some(task) = self.task.take() {
1121 if !task.is_finished() {
1123 self.cancel.cancel();
1124 }
1125 tokio::spawn(async move {
1126 if let Err(error) = task.await {
1127 eprintln!("[rpi] cancelled RPC task cleanup failed: {error}");
1128 }
1129 });
1130 }
1131 }
1132}
1133
1134struct RpcOwner<'a> {
1137 harness: &'a AgentHarness,
1138 file_io: Option<crate::rpc::file_io::FileIOClient>,
1139 backend: Arc<crate::rpc::external_tools::ExternalTools>,
1140 run_cancel: tokio_util::sync::CancellationToken,
1141 pump_stop: tokio_util::sync::CancellationToken,
1142 failed: tokio_util::sync::CancellationToken,
1143 mailbox: rpi_extensions::UiDialogMailbox,
1144 armed: bool,
1145}
1146impl Drop for RpcOwner<'_> {
1147 fn drop(&mut self) {
1148 if self.armed {
1149 self.run_cancel.cancel();
1150 if let Some(client) = self.file_io.take() {
1151 client.close();
1152 }
1153 self.backend.shutdown();
1154 self.failed.cancel();
1155 self.pump_stop.cancel();
1156 self.mailbox.detach();
1157 self.harness.session().set_entry_commit_publisher(None);
1158 }
1159 }
1160}
1161
1162fn rpc_get_commands(snapshot: Option<&rpi_extensions::RegistrySnapshot>) -> serde_json::Value {
1163 let commands: Vec<_> = snapshot
1164 .into_iter()
1165 .flat_map(|s| s.commands())
1166 .map(
1167 |command| serde_json::json!({"name": command.name, "description": command.description}),
1168 )
1169 .collect();
1170 serde_json::json!({"commands": commands})
1171}
1172
1173fn rpc_execute_command(
1174 snapshot: Option<&rpi_extensions::RegistrySnapshot>,
1175 request: &serde_json::Value,
1176) -> Result<serde_json::Value, String> {
1177 let name = request
1178 .get("command")
1179 .and_then(|v| v.as_str())
1180 .filter(|name| !name.trim_start_matches('/').is_empty())
1181 .ok_or("command must be a non-empty string")?;
1182 let args = match request.get("args") {
1183 None => "",
1184 Some(value) => value.as_str().ok_or("args must be a string")?,
1185 };
1186 let command = snapshot
1187 .into_iter()
1188 .flat_map(|s| s.commands())
1189 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
1190 .ok_or_else(|| format!("unknown command: {name}"))?;
1191 let input = serde_json::json!({"command": name, "args": args}).to_string();
1192 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1193 let mut out = rpi_plugin_sdk::StbString::empty();
1194 let rc = (command.handler)(
1195 rpi_plugin_sdk::StbStringRef::from_str(&input),
1196 &mut out,
1197 command.user_data,
1198 );
1199 let out = unsafe {
1202 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
1203 out,
1204 Some(rpi_extensions::host_free_string),
1205 )
1206 };
1207 if rc != 0 {
1208 return Err(format!("command failed: {name} (code {rc})"));
1209 }
1210 serde_json::from_str(&out.to_string_lossy())
1211 .map_err(|_| format!("command returned invalid JSON: {name}"))
1212 }))
1213 .unwrap_or_else(|_| Err(format!("command panicked: {name}")))
1214}
1215
1216async fn drain_rpc_events(
1217 out: &RpcWriter,
1218 events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
1219) {
1220 loop {
1221 match events.try_recv() {
1222 Ok(event) => write_agent_event(out, &event).await,
1223 Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
1224 Err(_) => break,
1225 }
1226 }
1227}
1228
1229async fn rpc_validate_prompt(
1232 lane: &Arc<dyn AgentLane>,
1233 command: serde_json::Value,
1234 cancel: &tokio_util::sync::CancellationToken,
1235) -> Result<crate::rpc::prompt_content::ParsedPrompt, String> {
1236 use crate::rpc::prompt_content::parse_prompt_content;
1237 if cancel.is_cancelled() {
1238 return Err("Operation aborted during prompt validation".into());
1239 }
1240 let model = lane.get_model().await.map_err(|e| e.to_string())?;
1244 let mut worker = tokio::task::spawn_blocking(move || parse_prompt_content(&command));
1245 let result = tokio::select! {
1246 biased;
1247 _ = cancel.cancelled() => {
1248 let _ = worker.await;
1251 return Err("Operation aborted during prompt validation".into());
1252 }
1253 result = &mut worker => result.map_err(|_| "prompt validation worker failed".to_string())?,
1254 }?;
1255 if cancel.is_cancelled() {
1256 return Err("Operation aborted during prompt validation".into());
1257 }
1258 if result.has_images() {
1259 let current = lane.get_model().await.map_err(|e| e.to_string())?;
1260 if current.id != model.id || current.provider != model.provider || current.api != model.api
1261 {
1262 return Err("model changed during image prompt validation; retry while idle".into());
1263 }
1264 if !model.input.contains(&rpi_ai::types::InputModality::Image)
1265 || !current.input.contains(&rpi_ai::types::InputModality::Image)
1266 {
1267 return Err("selected model does not support image input".into());
1268 }
1269 }
1270 Ok(result)
1271}
1272
1273async fn rpc_configure_file_io(
1274 lane: &Arc<dyn AgentLane>,
1275 run_active: bool,
1276 router: Option<&Arc<rpi_tools::file_io::FileIORouter>>,
1277 owner: &mut Option<crate::rpc::file_io::FileIOClient>,
1278 writer: &RpcWriter,
1279 command: &serde_json::Value,
1280) -> Result<serde_json::Value, String> {
1281 use rpi_tools::file_io::FileIOCapabilities;
1282 if run_active
1283 || lane
1284 .snapshot()
1285 .await
1286 .map_err(|e| e.to_string())?
1287 .active
1288 .is_some()
1289 {
1290 return Err("cannot configure file I/O while a run or prompt validation is active".into());
1291 }
1292 let read_text = command
1293 .get("readText")
1294 .and_then(serde_json::Value::as_bool)
1295 .ok_or("readText must be a boolean")?;
1296 let write_text = command
1297 .get("writeText")
1298 .and_then(serde_json::Value::as_bool)
1299 .ok_or("writeText must be a boolean")?;
1300 let router = router.ok_or("this embedded harness has no attached built-in file I/O router")?;
1301 if let Some(old) = owner.take() {
1304 old.close();
1305 }
1306 let next =
1307 (read_text || write_text).then(|| crate::rpc::file_io::FileIOClient::new(writer.clone()));
1308 router
1309 .configure(
1310 FileIOCapabilities {
1311 read_text,
1312 write_text,
1313 },
1314 next.as_ref().map(|c| c.backend()),
1315 )
1316 .await
1317 .map_err(|e| e.to_string())?;
1318 *owner = next;
1319 Ok(serde_json::json!({"readText": read_text, "writeText": write_text}))
1320}
1321
1322async fn rpc_prompt_cancellable(
1325 lane: &Arc<dyn AgentLane>,
1326 content: crate::rpc::prompt_content::ParsedPrompt,
1327 cancel: tokio_util::sync::CancellationToken,
1328) -> Result<serde_json::Value, String> {
1329 if cancel.is_cancelled() {
1330 return Err("Operation aborted".into());
1331 }
1332 let prompt = rpc_prompt_content(lane, content);
1333 tokio::pin!(prompt);
1334 tokio::select! {
1335 biased;
1336 _ = cancel.cancelled() => {
1337 match lane.abort().await {
1341 Ok(reclaimed) => prompt.await.map(|mut value| {
1342 if let Some(object) = value.as_object_mut() {
1343 object.insert(
1344 "reclaimed".to_string(),
1345 reclaimed_payload(&reclaimed),
1346 );
1347 }
1348 value
1349 }),
1350 Err(_) => Err("Operation aborted".to_string()),
1351 }
1352 }
1353 result = &mut prompt => result,
1354 }
1355}
1356
1357fn reclaimed_payload(result: &rpi_harness::agent_harness::AbortResult) -> serde_json::Value {
1359 serde_json::json!({
1360 "steer": crate::extensions_actions::queued_message_payloads(&result.steer),
1361 "followUp": crate::extensions_actions::queued_message_payloads(&result.follow_up),
1362 })
1363}
1364
1365async fn rpc_compact_cancellable(
1371 lane: &Arc<dyn AgentLane>,
1372 instructions: Option<String>,
1373 cancel: tokio_util::sync::CancellationToken,
1374) -> Result<serde_json::Value, String> {
1375 let compact = async {
1376 match lane.compact(instructions.as_deref()).await {
1377 Ok(result) => Ok(compaction_json(&result)),
1378 Err(error) => Err(error.to_string()),
1379 }
1380 };
1381 tokio::pin!(compact);
1382 tokio::select! {
1383 biased;
1384 _ = cancel.cancelled() => {
1385 let _ = lane.abort().await;
1386 compact.await
1387 }
1388 result = &mut compact => result,
1389 }
1390}
1391
1392fn compaction_json(result: &CompactionResult) -> serde_json::Value {
1395 let mut value = match &result.outcome {
1396 CompactionOutcome::Completed { leaf_id, entry } => {
1397 let (tokens_before, retained) = match entry {
1398 rpi_harness::session::types::Entry::Compaction(compaction) => {
1399 (compaction.tokens_before, compaction.retained_tail.len())
1400 }
1401 _ => (0, 0),
1402 };
1403 serde_json::json!({
1404 "outcome": "compacted",
1405 "leafId": leaf_id,
1406 "tokensBefore": tokens_before,
1407 "retainedMessages": retained,
1408 "summary": format!(
1409 "Conversation compacted · {} tokens before, {} messages kept.",
1410 tokens_before, retained
1411 ),
1412 })
1413 }
1414 CompactionOutcome::Declined { leaf_id } => serde_json::json!({
1415 "outcome": "declined",
1416 "leafId": leaf_id,
1417 "summary": "Nothing to compact yet.",
1418 }),
1419 CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
1420 "outcome": "aborted",
1421 "leafId": leaf_id,
1422 "summary": "Compaction aborted.",
1423 }),
1424 CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
1425 "outcome": "failed",
1426 "leafId": leaf_id,
1427 "error": error.message,
1428 "summary": format!("Compaction failed: {}", error.message),
1429 }),
1430 };
1431 value["runId"] = serde_json::json!(result.run_id);
1432 value
1433}
1434
1435async fn rpc_resume_cancellable(
1440 lane: &Arc<dyn AgentLane>,
1441 cancel: tokio_util::sync::CancellationToken,
1442) -> Result<serde_json::Value, String> {
1443 let resume = async {
1444 match lane.resume_pending().await {
1445 Ok(Some(result)) => {
1446 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
1447 Ok(serde_json::json!({
1448 "runId": result.run_id,
1449 "outcome": outcome,
1450 "finalText": final_text,
1451 "error": error,
1452 "resumed": true,
1453 }))
1454 }
1455 Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
1456 Err(error) => Err(error.to_string()),
1457 }
1458 };
1459 tokio::pin!(resume);
1460 tokio::select! {
1461 biased;
1462 _ = cancel.cancelled() => {
1463 let _ = lane.abort().await;
1464 resume.await
1465 }
1466 result = &mut resume => result,
1467 }
1468}
1469
1470fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
1471 match outcome {
1472 HarnessRunOutcome::Completed { final_message, .. } => {
1473 ("completed", Some(assistant_text(final_message)), None)
1474 }
1475 HarnessRunOutcome::Aborted { final_message, .. } => {
1476 ("aborted", Some(assistant_text(final_message)), None)
1477 }
1478 HarnessRunOutcome::Failed {
1481 final_message,
1482 error,
1483 ..
1484 } => (
1485 "failed",
1486 final_message.as_ref().map(assistant_text),
1487 Some(error.message.clone()),
1488 ),
1489 HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
1490 }
1491}
1492
1493async fn rpc_prompt_content(
1494 lane: &Arc<dyn AgentLane>,
1495 content: crate::rpc::prompt_content::ParsedPrompt,
1496) -> Result<serde_json::Value, String> {
1497 use crate::rpc::prompt_content::ParsedPrompt;
1498 let result = match content {
1499 ParsedPrompt::Legacy(text) => lane.prompt_text(&text, Vec::new()).await,
1500 ParsedPrompt::Blocks(blocks) => {
1501 lane.prompt_message(rpi_agent::AgentMessage::User(UserMessage::new(
1502 rpi_ai::types::UserContent::Blocks(blocks),
1503 rpc_now_ms(),
1504 )))
1505 .await
1506 }
1507 }
1508 .map_err(|e| e.to_string())?;
1509 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
1510 Ok(
1511 serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
1512 )
1513}
1514
1515async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1516 let model = lane.get_model().await.ok();
1517 let thinking = lane
1518 .get_thinking_level()
1519 .await
1520 .ok()
1521 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
1522 let active_tools = if harness.has_exact_resources() {
1523 harness
1524 .execution_context()
1525 .ok()
1526 .and_then(|v| v.get("tools").cloned())
1527 .unwrap_or_else(|| serde_json::json!([]))
1528 } else {
1529 serde_json::json!(lane.get_active_tools().await.unwrap_or_default())
1530 };
1531 let leaf_id = lane.get_leaf_id().await.ok().flatten();
1532 let context = rpc_context_usage(lane, harness).await;
1533 let active_operation = lane.active_operation().await.ok().flatten().map(|active| {
1534 serde_json::json!({
1535 "runId": active.run_id,
1536 "lane": active.lane,
1537 "kind": format!("{:?}", active.kind).to_lowercase(),
1538 })
1539 });
1540 let queued = lane.queued_messages().await.unwrap_or_default();
1541 let stats = rpc_get_stats(lane, harness).await.ok();
1542 serde_json::json!({
1543 "modelId": model.as_ref().map(|model| model.id.clone()),
1544 "model": model.as_ref().map(|model| model.id.clone()),
1545 "provider": model.as_ref().map(|model| model.provider.clone()),
1546 "thinkingLevel": thinking,
1547 "activeTools": active_tools,
1548 "leafId": leaf_id,
1549 "context": context,
1550 "sessionId": harness.session().storage().metadata().id,
1551 "activeOperation": active_operation,
1552 "queue": {
1553 "steering": queued
1561 .steering
1562 .iter()
1563 .map(|m| m.text.as_str())
1564 .collect::<Vec<_>>(),
1565 "followUp": queued.follow_up.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(),
1566 "steeringCount": queued.steering.len(),
1567 "followUpCount": queued.follow_up.len(),
1568 },
1569 "stats": stats,
1570 })
1571}
1572
1573fn rpc_now_ms() -> i64 {
1574 std::time::SystemTime::now()
1575 .duration_since(std::time::UNIX_EPOCH)
1576 .map(|duration| duration.as_millis() as i64)
1577 .unwrap_or_default()
1578}
1579
1580fn rpc_message_text(value: &serde_json::Value) -> String {
1581 let Some(content) = value.get("content") else {
1582 return String::new();
1583 };
1584 if let Some(text) = content.as_str() {
1585 return text.to_string();
1586 }
1587 content
1588 .as_array()
1589 .into_iter()
1590 .flatten()
1591 .filter_map(|block| block.get("text").and_then(|text| text.as_str()))
1592 .collect::<Vec<_>>()
1593 .join("\n")
1594}
1595
1596async fn rpc_get_history(
1597 lane: &Arc<dyn AgentLane>,
1598 harness: &AgentHarness,
1599 msg: &serde_json::Value,
1600) -> Result<serde_json::Value, String> {
1601 let limit = msg
1602 .get("limit")
1603 .and_then(serde_json::Value::as_u64)
1604 .unwrap_or(100)
1605 .clamp(1, 500) as usize;
1606 let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
1607 let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
1608 let page = harness
1609 .session()
1610 .record_page(&rpi_harness::session::types::RecordPageQuery {
1611 lane: lane.name().to_owned(),
1612 after_seq,
1613 before_seq,
1614 limit,
1615 from_end: before_seq.is_some() || after_seq.is_none(),
1616 })
1617 .await
1618 .map_err(|error| error.to_string())?;
1619 let records = page.records;
1620 let total = page.total;
1621 let has_more = total > records.len();
1622 let first_seq = records.first().map(LaneRecord::seq);
1623 let last_seq = records.last().map(LaneRecord::seq);
1624 let open_operations = harness
1625 .session()
1626 .find_open_operations(lane.name(), Some(50))
1627 .await
1628 .map_err(|error| error.to_string())?;
1629 Ok(serde_json::json!({
1630 "records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1631 "total": total,
1632 "limit": limit,
1633 "afterSeq": after_seq,
1634 "beforeSeq": before_seq,
1635 "firstSeq": first_seq,
1636 "lastSeq": last_seq,
1637 "hasMore": has_more,
1638 "leafId": lane.get_leaf_id().await.ok().flatten(),
1639 "openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1640 }))
1641}
1642
1643async fn rpc_get_session_branch(
1644 harness: &AgentHarness,
1645 msg: &serde_json::Value,
1646) -> Result<serde_json::Value, String> {
1647 let mut args = msg.clone();
1650 if let Some(args) = args.as_object_mut() {
1651 args.remove("type");
1652 args.remove("id");
1653 }
1654 let request = branch_entry_query(&args)?;
1655 let (entries, session_id, leaf_id) = branch_snapshot(harness, &request).await?;
1656 let entries = entries
1657 .iter()
1658 .map(|entry| serde_json::to_value(entry).map_err(|error| error.to_string()))
1659 .collect::<Result<Vec<_>, _>>()?;
1660 Ok(serde_json::json!({
1661 "entries": entries,
1662 "count": entries.len(),
1663 "filtered": true,
1664 "sessionId": session_id,
1665 "leafId": leaf_id,
1666 "scopedAppend": true,
1667 }))
1668}
1669
1670#[derive(Debug, Clone, PartialEq, Eq)]
1671pub(crate) struct BranchEntryQuery {
1672 pub custom_types: Vec<String>,
1673 pub order: EntryOrder,
1674 pub limit: Option<usize>,
1675 pub after_seq: Option<u64>,
1676}
1677
1678impl BranchEntryQuery {
1679 fn entry_queries(&self) -> Vec<EntryQuery> {
1680 let per_query_limit = self.limit;
1683 self.custom_types
1684 .iter()
1685 .map(|custom_type| EntryQuery {
1686 entry_type: Some("custom"),
1687 custom_type: Some(custom_type.clone()),
1688 order: Some(self.order),
1689 limit: per_query_limit,
1690 cursor: self.after_seq.map(|after_seq| EntryCursor { after_seq }),
1691 ..Default::default()
1692 })
1693 .collect()
1694 }
1695}
1696
1697pub(crate) async fn branch_snapshot(
1698 harness: &AgentHarness,
1699 request: &BranchEntryQuery,
1700) -> Result<(Vec<Entry>, String, Option<String>), String> {
1701 let storage = harness.session().storage();
1705 let leaf_id = storage
1706 .get_lanes()
1707 .await
1708 .map_err(|error| error.to_string())?
1709 .into_iter()
1710 .find(|pointer| pointer.lane == "main")
1711 .ok_or_else(|| "Lane not found: main".to_string())?
1712 .leaf_id;
1713 let session_id = storage.metadata().id;
1714 let Some(start) = leaf_id.clone() else {
1715 return Ok((Vec::new(), session_id, None));
1716 };
1717 let bounds = BranchBounds {
1718 start: Some(start.clone()),
1719 ..Default::default()
1720 };
1721 let mut entries = Vec::new();
1722 for query in request.entry_queries() {
1723 entries.extend(
1724 storage
1725 .find_entries_on_branch(&query, &bounds, &start)
1726 .await
1727 .map_err(|error| error.to_string())?,
1728 );
1729 }
1730 if request.custom_types.len() > 1 {
1731 let mut seen = HashSet::with_capacity(entries.len());
1732 entries.retain(|entry| seen.insert(entry.id().to_owned()));
1733 match request.order {
1734 EntryOrder::OldestFirst => entries.sort_by_key(Entry::seq),
1735 EntryOrder::NewestFirst => entries.sort_by_key(|entry| Reverse(entry.seq())),
1736 }
1737 if let Some(limit) = request.limit {
1738 entries.truncate(limit);
1739 }
1740 }
1741 Ok((entries, session_id, leaf_id))
1742}
1743
1744pub(crate) fn branch_entry_query(msg: &serde_json::Value) -> Result<BranchEntryQuery, String> {
1747 let args = msg
1748 .as_object()
1749 .ok_or("get_session_branch: query must be an object")?;
1750 for key in args.keys() {
1751 if !matches!(key.as_str(), "customTypes" | "afterSeq" | "limit" | "order") {
1752 return Err(format!(
1753 "get_session_branch: unknown query argument `{key}`"
1754 ));
1755 }
1756 }
1757 let values = args
1758 .get("customTypes")
1759 .and_then(serde_json::Value::as_array)
1760 .filter(|values| !values.is_empty())
1761 .ok_or(
1762 "get_session_branch: `customTypes` is required and must be a non-empty string array",
1763 )?;
1764 let mut custom_types = Vec::with_capacity(values.len());
1765 for value in values {
1766 let custom_type = value
1767 .as_str()
1768 .filter(|value| !value.trim().is_empty())
1769 .ok_or("get_session_branch: every `customTypes` item must be a non-empty string")?;
1770 if !custom_types.iter().any(|existing| existing == custom_type) {
1771 custom_types.push(custom_type.to_owned());
1772 }
1773 }
1774 let limit = match msg.get("limit") {
1775 None | Some(serde_json::Value::Null) => None,
1776 Some(value) => {
1777 let limit = value
1778 .as_u64()
1779 .filter(|limit| *limit > 0)
1780 .ok_or("get_session_branch: `limit` must be a positive integer")?;
1781 Some(usize::try_from(limit).map_err(|_| "get_session_branch: `limit` is too large")?)
1782 }
1783 };
1784 let order = match msg.get("order") {
1785 None => EntryOrder::OldestFirst,
1786 Some(serde_json::Value::String(value)) if value == "oldestFirst" => EntryOrder::OldestFirst,
1787 Some(serde_json::Value::String(value)) if value == "newestFirst" => EntryOrder::NewestFirst,
1788 Some(_) => {
1789 return Err("get_session_branch: `order` must be oldestFirst or newestFirst".into())
1790 }
1791 };
1792 let after_seq = match msg.get("afterSeq") {
1793 None | Some(serde_json::Value::Null) => None,
1794 Some(value) => Some(
1795 value
1796 .as_u64()
1797 .ok_or("get_session_branch: `afterSeq` must be a non-negative integer")?,
1798 ),
1799 };
1800 Ok(BranchEntryQuery {
1801 custom_types,
1802 order,
1803 limit,
1804 after_seq,
1805 })
1806}
1807
1808async fn rpc_append_entry(
1809 harness: &AgentHarness,
1810 msg: &serde_json::Value,
1811) -> Result<serde_json::Value, String> {
1812 let custom_type = msg
1813 .get("customType")
1814 .and_then(serde_json::Value::as_str)
1815 .filter(|value| !value.trim().is_empty())
1816 .ok_or_else(|| "append_entry requires a non-empty string `customType`".to_string())?;
1817 crate::resource_selection::validate_public_entry(custom_type, msg.get("data"))?;
1818 let entry_id = if let Some(expected) = msg.get("expected") {
1819 let guard = serde_json::from_value(expected.clone())
1820 .map_err(|error| format!("invalid expected guard: {error}"))?;
1821 harness
1822 .append_scoped_entry(
1823 &guard,
1824 rpi_harness::agent_harness::ScopedEntry::Custom {
1825 custom_type: custom_type.to_owned(),
1826 data: msg.get("data").cloned(),
1827 },
1828 )
1829 .await
1830 } else {
1831 harness
1832 .append_custom_entry(custom_type, msg.get("data").cloned())
1833 .await
1834 }
1835 .map_err(|error| error.to_string())?;
1836 let mut response = serde_json::json!({"entryId": entry_id, "customType": custom_type});
1837 if msg.get("expected").is_some() {
1838 response["scoped"] = serde_json::json!(true);
1839 }
1840 Ok(response)
1841}
1842
1843async fn rpc_get_stats(
1844 lane: &Arc<dyn AgentLane>,
1845 harness: &AgentHarness,
1846) -> Result<serde_json::Value, String> {
1847 let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
1848 let mut records = Vec::new();
1850 for record_type in [
1851 "operation_started",
1852 "operation_finished",
1853 "retry_pending",
1854 "usage",
1855 ] {
1856 records.extend(
1857 harness
1858 .session()
1859 .find_records(&RecordQuery {
1860 lane: Some(lane.name().to_string()),
1861 record_type: Some(record_type),
1862 order: Some(EntryOrder::OldestFirst),
1863 ..Default::default()
1864 })
1865 .await
1866 .map_err(|error| error.to_string())?,
1867 );
1868 }
1869 records.sort_unstable_by_key(LaneRecord::seq);
1870 let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
1871 for record in &records {
1872 let kind = match record {
1873 LaneRecord::OperationStarted(started) => {
1874 let key = started.intent.kind();
1875 *operation_counts.entry(key.to_string()).or_default() += 1;
1876 if matches!(
1877 &started.intent,
1878 rpi_harness::session::types::OperationIntent::Run {
1879 resume_data: Some(_),
1880 ..
1881 }
1882 ) {
1883 *operation_counts.entry("resume".to_string()).or_default() += 1;
1884 }
1885 continue;
1886 }
1887 LaneRecord::OperationFinished(_) => "operation_finished",
1888 LaneRecord::RetryPending(_) => "retry_pending",
1889 LaneRecord::Usage(_) => "usage",
1890 _ => continue,
1891 };
1892 *operation_counts.entry(kind.to_string()).or_default() += 1;
1893 }
1894 let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
1895 let model = lane.get_model().await.ok();
1896 let input = stats.cached_tokens + stats.uncached_tokens;
1897 Ok(serde_json::json!({
1898 "messageCount": stats.message_count,
1899 "operationCount": count("operation_finished"),
1900 "retryCount": count("retry_pending"),
1901 "compactionCount": count("compaction"),
1902 "resumeCount": count("resume"),
1903 "operationCounts": operation_counts,
1904 "provider": model.as_ref().map(|value| value.provider.clone()),
1905 "modelId": model.as_ref().map(|value| value.id.clone()),
1906 "cachedTokens": stats.cached_tokens,
1907 "uncachedTokens": stats.uncached_tokens,
1908 "totalTokens": stats.total_tokens,
1909 "costTotal": stats.cost_total,
1910 "cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
1911 }))
1912}
1913
1914async fn rpc_get_queue(
1915 lane: &Arc<dyn AgentLane>,
1916 harness: &AgentHarness,
1917 msg: &serde_json::Value,
1918) -> Result<serde_json::Value, String> {
1919 let include_completed = msg
1920 .get("includeCompleted")
1921 .and_then(serde_json::Value::as_bool)
1922 .unwrap_or(false);
1923 let mut records = Vec::new();
1925 for record_type in ["queue_enqueued", "queue_cancelled"] {
1926 records.extend(
1927 harness
1928 .session()
1929 .find_records(&RecordQuery {
1930 lane: Some(lane.name().to_string()),
1931 record_type: Some(record_type),
1932 order: Some(EntryOrder::OldestFirst),
1933 ..Default::default()
1934 })
1935 .await
1936 .map_err(|error| error.to_string())?,
1937 );
1938 }
1939 records.sort_unstable_by_key(LaneRecord::seq);
1940 let mut cancelled = std::collections::HashMap::<String, u64>::new();
1941 for record in &records {
1942 if let LaneRecord::QueueCancelled(record) = record {
1943 cancelled.insert(record.entry_id.clone(), record.base.seq);
1944 }
1945 }
1946 let mut items = Vec::new();
1947 for record in records {
1948 let LaneRecord::QueueEnqueued(record) = record else {
1949 continue;
1950 };
1951 let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
1952 continue;
1953 };
1954 let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
1955 ("cancelled", Some(*seq))
1956 } else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
1957 ("consumed", Some(entry.base().seq))
1958 } else {
1959 ("queued", None)
1960 };
1961 if status != "queued" && !include_completed {
1962 continue;
1963 }
1964 let message = record
1965 .target
1966 .get("message")
1967 .cloned()
1968 .unwrap_or(serde_json::Value::Null);
1969 items.push(serde_json::json!({
1970 "entryId": entry_id,
1971 "queue": record.queue,
1972 "status": status,
1973 "text": rpc_message_text(&message),
1974 "message": message,
1975 "runId": record.run_id,
1976 "seq": record.base.seq,
1977 "completedSeq": completed_seq,
1978 }));
1979 }
1980 Ok(
1981 serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
1982 )
1983}
1984
1985async fn rpc_queue_message(
1986 lane: &Arc<dyn AgentLane>,
1987 msg: &serde_json::Value,
1988) -> Result<serde_json::Value, String> {
1989 let text = msg
1990 .get("message")
1991 .and_then(serde_json::Value::as_str)
1992 .filter(|text| !text.trim().is_empty())
1993 .ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
1994 let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
1995 let queue = msg
1996 .get("deliverAs")
1997 .and_then(serde_json::Value::as_str)
1998 .unwrap_or("followUp");
1999 let result = match queue {
2000 "steer" => lane.steer(message).await,
2001 "followUp" | "follow_up" => lane.follow_up(message).await,
2002 "nextTurn" | "next_run" => lane.next_run(message).await,
2003 other => return Err(format!("unknown queue kind: {other}")),
2004 }
2005 .map_err(|error| error.to_string())?;
2006 Ok(serde_json::json!({
2007 "accepted": true,
2008 "entryId": result.entry_id,
2009 "queue": queue,
2010 }))
2011}
2012
2013async fn rpc_cancel_queued(
2014 lane: &Arc<dyn AgentLane>,
2015 msg: &serde_json::Value,
2016) -> Result<serde_json::Value, String> {
2017 let entry_id = msg
2018 .get("entryId")
2019 .and_then(serde_json::Value::as_str)
2020 .filter(|value| !value.is_empty())
2021 .ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
2022 let result = lane
2023 .cancel_queued(entry_id)
2024 .await
2025 .map_err(|error| error.to_string())?;
2026 Ok(serde_json::json!({
2027 "entryId": entry_id,
2028 "outcome": format!("{:?}", result.outcome).to_lowercase(),
2029 }))
2030}
2031
2032fn abort_response(result: &rpi_harness::agent_harness::AbortResult) -> serde_json::Value {
2035 serde_json::json!({
2036 "aborted": true,
2037 "steer": crate::extensions_actions::queued_message_payloads(&result.steer),
2038 "followUp": crate::extensions_actions::queued_message_payloads(&result.follow_up),
2039 })
2040}
2041
2042fn abort_result_reply(
2048 result: Result<rpi_harness::agent_harness::AbortResult, rpi_harness::result::HarnessError>,
2049) -> Result<serde_json::Value, String> {
2050 match result {
2051 Ok(result) => Ok(abort_response(&result)),
2052 Err(rpi_harness::result::HarnessError::NoActiveRun { .. }) => {
2053 Ok(abort_response(&rpi_harness::agent_harness::AbortResult {
2054 run_id: String::new(),
2055 steer: Vec::new(),
2056 follow_up: Vec::new(),
2057 }))
2058 }
2059 Err(error) => Err(error.to_string()),
2060 }
2061}
2062
2063async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
2064 let result = lane
2065 .clear_queue()
2066 .await
2067 .map_err(|error| error.to_string())?;
2068 Ok(serde_json::json!({
2069 "cleared": result.steering.len() + result.follow_up.len(),
2070 "steering": result
2073 .steering
2074 .iter()
2075 .map(|m| m.text.as_str())
2076 .collect::<Vec<_>>(),
2077 "followUp": result.follow_up.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(),
2078 }))
2079}
2080
2081async fn rpc_navigate(
2082 lane: &Arc<dyn AgentLane>,
2083 msg: &serde_json::Value,
2084) -> Result<serde_json::Value, String> {
2085 let target_id = msg
2086 .get("targetId")
2087 .and_then(serde_json::Value::as_str)
2088 .filter(|value| !value.is_empty());
2089 if lane
2090 .snapshot()
2091 .await
2092 .map_err(|error| error.to_string())?
2093 .active
2094 .is_some()
2095 {
2096 return Err("cannot navigate while an operation is active".to_string());
2097 }
2098 let result = lane
2099 .navigate_tree(target_id, false, None, None)
2100 .await
2101 .map_err(|error| error.to_string())?;
2102 let (outcome, leaf_id, error) = match result.outcome {
2103 rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
2104 ("completed", new_leaf_id, None)
2105 }
2106 rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
2107 ("declined", leaf_id, None)
2108 }
2109 rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
2110 ("aborted", leaf_id, None)
2111 }
2112 rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
2113 ("failed", leaf_id, Some(error.message))
2114 }
2115 };
2116 Ok(serde_json::json!({
2117 "runId": result.run_id,
2118 "outcome": outcome,
2119 "leafId": leaf_id,
2120 "error": error,
2121 }))
2122}
2123
2124async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
2130 use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
2131 use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
2132
2133 let estimate = match harness.context_usage().await {
2134 Ok(Some(estimate)) => estimate,
2135 Ok(None) => {
2136 let entries = harness
2138 .find_entries_on_branch(
2139 &EntryQuery {
2140 order: Some(EntryOrder::OldestFirst),
2141 ..Default::default()
2142 },
2143 &BranchBounds::default(),
2144 )
2145 .await
2146 .unwrap_or_default();
2147 estimate_context_tokens(&build_session_context(&entries).messages)
2148 }
2149 Err(_) => estimate_context_tokens(&[]),
2153 };
2154 let window = lane
2155 .get_model()
2156 .await
2157 .ok()
2158 .map(|model| model.context_window)
2159 .unwrap_or(0);
2160 let settings = harness.compaction_settings();
2161 let window = window as i64;
2162 let threshold = window - settings.reserve_tokens;
2163 let percent = if window > 0 {
2164 (estimate.tokens as f64) * 100.0 / (window as f64)
2165 } else {
2166 0.0
2167 };
2168 serde_json::json!({
2169 "tokens": estimate.tokens,
2170 "window": window,
2171 "threshold": threshold,
2172 "percent": (percent * 10.0).round() / 10.0,
2173 "reserveTokens": settings.reserve_tokens,
2174 "keepRecentTokens": settings.keep_recent_tokens,
2175 "compactionEnabled": settings.enabled,
2176 "estimated": estimate.last_usage_index.is_none(),
2179 "trailingTokens": estimate.trailing_tokens,
2180 })
2181}
2182
2183fn page_window(
2186 total: usize,
2187 limit: usize,
2188 before: Option<usize>,
2189) -> (std::ops::Range<usize>, bool) {
2190 match before {
2191 Some(index) => {
2192 let start = index.saturating_sub(limit);
2193 (start..index, start > 0)
2194 }
2195 None => {
2196 let skip = total.saturating_sub(limit);
2197 (skip..total, skip > 0)
2198 }
2199 }
2200}
2201
2202async fn rpc_get_messages(
2206 lane: &Arc<dyn AgentLane>,
2207 harness: &AgentHarness,
2208 msg: &serde_json::Value,
2209) -> Result<serde_json::Value, String> {
2210 use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
2211
2212 const DEFAULT_LIMIT: usize = 400;
2213 let limit = msg
2214 .get("limit")
2215 .and_then(|value| value.as_u64())
2216 .map(|value| value as usize)
2217 .unwrap_or(DEFAULT_LIMIT)
2218 .max(1);
2219 let before_id = msg
2223 .get("beforeId")
2224 .and_then(|value| value.as_str())
2225 .filter(|value| !value.is_empty())
2226 .map(str::to_owned);
2227 let selected = harness
2228 .transcript_page(&rpi_harness::session::types::TranscriptPageQuery {
2229 limit,
2230 before_id: before_id.clone(),
2231 })
2232 .await
2233 .map_err(|error| error.to_string())?;
2234 let (window, total, has_more, leaf_id) = if let Some(page) = selected {
2235 (
2238 rpc_project_transcript(page.entries)?,
2239 page.total,
2240 page.has_more,
2241 page.leaf_id,
2242 )
2243 } else {
2244 let entries = harness
2246 .find_entries_on_branch(
2247 &EntryQuery {
2248 order: Some(EntryOrder::OldestFirst),
2249 ..Default::default()
2250 },
2251 &BranchBounds::default(),
2252 )
2253 .await
2254 .map_err(|error| error.to_string())?;
2255 let mut items = rpc_project_transcript(entries)?;
2256 let total = items.len();
2257 let before_index = match &before_id {
2258 Some(before) => Some(
2259 items
2260 .iter()
2261 .position(|(id, _)| id == before)
2262 .ok_or_else(|| format!("beforeId not found: {before}"))?,
2263 ),
2264 None => None,
2265 };
2266 let (range, has_more) = page_window(total, limit, before_index);
2267 let window = items.drain(range).collect::<Vec<_>>();
2268 (
2269 window,
2270 total,
2271 has_more,
2272 lane.get_leaf_id().await.ok().flatten(),
2273 )
2274 };
2275 let first_id = window.first().map(|(id, _)| id.clone());
2276 let last_id = window.last().map(|(id, _)| id.clone());
2277 let messages = window
2278 .into_iter()
2279 .map(|(_, value)| value)
2280 .collect::<Vec<_>>();
2281 let pending_resume = lane.has_pending_resume().await;
2282 Ok(serde_json::json!({
2283 "messages": messages,
2284 "total": total,
2285 "truncated": has_more,
2286 "limit": limit,
2287 "firstId": first_id,
2288 "lastId": last_id,
2289 "hasMore": has_more,
2290 "beforeId": before_id,
2291 "leafId": leaf_id,
2292 "pendingResume": pending_resume,
2293 }))
2294}
2295
2296fn rpc_project_transcript(
2297 entries: Vec<rpi_harness::session::types::Entry>,
2298) -> Result<Vec<(String, serde_json::Value)>, String> {
2299 use rpi_harness::session::types::Entry;
2300 let mut items = Vec::new();
2301 for entry in entries {
2302 match entry {
2303 Entry::Message(entry) => {
2304 let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
2305 value["entryId"] = serde_json::json!(entry.base.id);
2307 items.push((entry.base.id, value));
2308 }
2309 Entry::Compaction(compaction) => items.push((
2310 compaction.base.id.clone(),
2311 serde_json::json!({
2312 "role": "compaction",
2313 "entryId": compaction.base.id,
2314 "tokensBefore": compaction.tokens_before,
2315 "retainedMessages": compaction.retained_tail.len(),
2316 "timestamp": compaction.base.timestamp,
2317 }),
2318 )),
2319 _ => {}
2320 }
2321 }
2322 Ok(items)
2323}
2324
2325fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
2326 let mut providers =
2327 std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
2328 providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
2329 if let Ok(models) = crate::config::load_models_config() {
2330 providers.extend(models.providers.keys().cloned());
2331 }
2332 providers
2333}
2334
2335fn rpc_get_auth_status(
2336 model_catalog: &[rpi_ai::Model],
2337 oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
2338) -> serde_json::Value {
2339 let store = crate::config::read_auth().unwrap_or_default();
2340 let models = crate::config::load_models_config().unwrap_or_default();
2341 let manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
2342 snapshot
2343 .providers()
2344 .iter()
2345 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
2346 .collect()
2347 });
2348 let auth_states = crate::credentials::statuses(&models, &store, &manifests);
2349 let providers = rpc_auth_provider_ids(model_catalog)
2350 .into_iter()
2351 .map(|provider| {
2352 let credential = store.get(&provider);
2353 let state = auth_states.get(&provider).cloned().unwrap_or(
2354 rpi_runtime::credentials::AuthState {
2355 status: rpi_runtime::credentials::AuthStatus::Missing,
2356 source: crate::credentials::CredentialSource::Missing,
2357 },
2358 );
2359 let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
2360 let oauth_available =
2361 oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
2362 serde_json::json!({
2363 "provider": provider,
2364 "ready": state.is_ready(),
2365 "source": state.source.as_json_str(),
2366 "status": state.status,
2367 "oauth": oauth,
2368 "oauthAvailable": oauth_available,
2369 })
2370 })
2371 .collect::<Vec<_>>();
2372 serde_json::json!({"providers": providers})
2373}
2374
2375fn rpc_oauth(
2376 snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
2377 msg: &serde_json::Value,
2378) -> Result<serde_json::Value, String> {
2379 let snapshot = snapshot
2380 .as_ref()
2381 .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
2382 let provider = msg
2383 .get("provider")
2384 .and_then(serde_json::Value::as_str)
2385 .map(str::trim)
2386 .filter(|value| !value.is_empty())
2387 .ok_or_else(|| "oauth requires a provider".to_string())?;
2388 let action = msg
2389 .get("action")
2390 .and_then(serde_json::Value::as_str)
2391 .map(str::trim)
2392 .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
2393 .ok_or_else(|| "oauth requires a valid action".to_string())?;
2394 let input = msg
2395 .get("input")
2396 .cloned()
2397 .unwrap_or_else(|| serde_json::json!({}));
2398 let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
2399 .map_err(|_| "OAuth provider request failed".to_string())?;
2400 let response = rpi_extensions::OAuthActionResponse::parse(raw)
2401 .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
2402 match response.kind {
2403 rpi_extensions::OAuthResponseKind::Credential => {
2404 let credential = response
2405 .credential()
2406 .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
2407 .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
2408 crate::config::upsert_oauth_token(
2409 provider,
2410 credential.access,
2411 credential.refresh,
2412 credential.expires_at,
2413 )
2414 .map_err(|_| "OAuth credentials could not be saved".to_string())?;
2415 Ok(serde_json::json!({
2416 "provider": provider,
2417 "status": "authenticated"
2418 }))
2419 }
2420 rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
2421 "provider": provider,
2422 "status": "interaction",
2423 "display": oauth_display(&response.payload)
2424 })),
2425 rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
2426 "provider": provider,
2427 "status": "success"
2428 })),
2429 rpi_extensions::OAuthResponseKind::Error => {
2430 Err("OAuth provider rejected the request".to_string())
2431 }
2432 }
2433}
2434
2435fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
2439 let display = payload.get("display").unwrap_or(payload);
2440 let Some(object) = display.as_object() else {
2441 return serde_json::json!({});
2442 };
2443 let mut safe = serde_json::Map::new();
2444 for key in [
2445 "title",
2446 "url",
2447 "instructions",
2448 "user_code",
2449 "verification_uri",
2450 ] {
2451 if let Some(value) = object.get(key) {
2452 safe.insert(key.to_string(), value.clone());
2453 }
2454 }
2455 serde_json::Value::Object(safe)
2456}
2457
2458async fn rpc_get_provider_models(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2459 let required = |name: &str| {
2460 msg.get(name)
2461 .and_then(serde_json::Value::as_str)
2462 .map(str::trim)
2463 .filter(|value| !value.is_empty())
2464 .ok_or_else(|| format!("get_provider_models requires {name}"))
2465 };
2466 let api = required("api")?;
2467 let base_url = required("baseUrl")?;
2468 let api_key = required("apiKey")?;
2469 let models = crate::provider_probe::get_models(api, base_url, api_key).await?;
2470 Ok(serde_json::json!({"models": models}))
2471}
2472
2473async fn rpc_test_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2474 let required = |name: &str| {
2475 msg.get(name)
2476 .and_then(serde_json::Value::as_str)
2477 .map(str::trim)
2478 .filter(|value| !value.is_empty())
2479 .ok_or_else(|| format!("test_provider_config requires {name}"))
2480 };
2481 let result = crate::provider_probe::test_connection(
2482 required("api")?,
2483 required("baseUrl")?,
2484 required("apiKey")?,
2485 )
2486 .await?;
2487 Ok(serde_json::to_value(result).map_err(|error| error.to_string())?)
2488}
2489
2490fn rpc_set_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2491 let required = |name: &str| {
2492 msg.get(name)
2493 .and_then(serde_json::Value::as_str)
2494 .map(str::trim)
2495 .filter(|value| !value.is_empty())
2496 .ok_or_else(|| format!("set_provider_config requires {name}"))
2497 };
2498 let provider = required("provider")?;
2499 let api = required("api")?;
2500 let base_url = required("baseUrl")?;
2501 let api_key = required("apiKey")?;
2502 let model_id = required("modelId")?;
2503 let model_name = msg.get("modelName").and_then(serde_json::Value::as_str);
2504 let positive_integer = |name: &str| match msg.get(name) {
2505 None | Some(serde_json::Value::Null) => Ok(None),
2506 Some(value) => value
2507 .as_u64()
2508 .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
2509 .ok_or_else(|| format!("{name} must be a positive integer"))
2510 .map(Some),
2511 };
2512 let context_window = positive_integer("contextWindow")?;
2513 let max_tokens = positive_integer("maxOutputTokens")?;
2514 let supports_images = msg
2515 .get("supportsImages")
2516 .and_then(serde_json::Value::as_bool);
2517 crate::config::add_provider(
2518 provider,
2519 api,
2520 base_url,
2521 api_key,
2522 model_id,
2523 model_name,
2524 context_window,
2525 max_tokens,
2526 supports_images,
2527 )?;
2528 Ok(serde_json::json!({
2529 "provider": provider,
2530 "modelId": model_id,
2531 "api": api,
2532 "requiresRestart": true,
2533 }))
2534}
2535
2536fn rpc_update_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2537 let required = |name: &str| {
2538 msg.get(name)
2539 .and_then(serde_json::Value::as_str)
2540 .map(str::trim)
2541 .filter(|value| !value.is_empty())
2542 .ok_or_else(|| format!("update_provider_config requires {name}"))
2543 };
2544 let positive_integer = |name: &str| match msg.get(name) {
2545 None | Some(serde_json::Value::Null) => Ok(None),
2546 Some(value) => value
2547 .as_u64()
2548 .or_else(|| value.as_str().and_then(|text| text.trim().parse().ok()))
2549 .ok_or_else(|| format!("{name} must be a positive integer"))
2550 .map(Some),
2551 };
2552 let provider = required("provider")?;
2553 let api = required("api")?;
2554 let base_url = required("baseUrl")?;
2555 let model_id = required("modelId")?;
2556 crate::config::update_provider(
2557 provider,
2558 api,
2559 base_url,
2560 msg.get("apiKey").and_then(serde_json::Value::as_str),
2561 model_id,
2562 msg.get("modelName").and_then(serde_json::Value::as_str),
2563 positive_integer("contextWindow")?,
2564 positive_integer("maxOutputTokens")?,
2565 msg.get("supportsImages")
2566 .and_then(serde_json::Value::as_bool)
2567 .unwrap_or(false),
2568 )?;
2569 Ok(serde_json::json!({
2570 "provider": provider,
2571 "modelId": model_id,
2572 "requiresRestart": true,
2573 }))
2574}
2575
2576fn rpc_delete_provider_config(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2577 let provider = msg
2578 .get("provider")
2579 .and_then(serde_json::Value::as_str)
2580 .map(str::trim)
2581 .filter(|value| !value.is_empty())
2582 .ok_or_else(|| "delete_provider_config requires provider".to_string())?;
2583 crate::config::delete_provider(provider)?;
2584 Ok(serde_json::json!({
2585 "provider": provider,
2586 "requiresRestart": true,
2587 }))
2588}
2589
2590async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
2591 let provider = msg
2592 .get("provider")
2593 .and_then(serde_json::Value::as_str)
2594 .map(str::trim)
2595 .filter(|value| !value.is_empty())
2596 .ok_or_else(|| "set_api_key requires a provider".to_string())?;
2597 let api_key = msg
2598 .get("apiKey")
2599 .and_then(serde_json::Value::as_str)
2600 .map(str::trim)
2601 .filter(|value| !value.is_empty())
2602 .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
2603 crate::config::upsert_credential(
2604 provider,
2605 crate::config::Credential::ApiKey {
2606 key: Some(api_key.to_string()),
2607 env: None,
2608 },
2609 )
2610 .map_err(|error| error.to_string())?;
2611 Ok(serde_json::json!({
2612 "provider": provider,
2613 "ready": true,
2614 "source": "auth.json",
2615 "requiresRestart": true,
2616 }))
2617}
2618
2619fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
2620 let models = catalog
2621 .iter()
2622 .map(crate::rpc::protocol::model_descriptor)
2623 .collect::<Vec<_>>();
2624 serde_json::json!({"models": models})
2625}
2626
2627async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
2628 let levels = lane
2629 .get_model()
2630 .await
2631 .map(|model| model.supported_thinking_levels())
2632 .unwrap_or_default();
2633 serde_json::json!({"levels": levels})
2634}
2635
2636async fn rpc_configure_external_tools(
2637 lane: &Arc<dyn AgentLane>,
2638 harness: &AgentHarness,
2639 run_active: bool,
2640 backend: &Arc<crate::rpc::external_tools::ExternalTools>,
2641 old_names: &mut Vec<String>,
2642 msg: &serde_json::Value,
2643) -> Result<serde_json::Value, String> {
2644 use rpi_harness::types::HarnessTool;
2645 use rpi_tools::tools::external::{
2646 create_external_tool, validate_external_tool_definitions, ExternalToolDefinition,
2647 };
2648 if run_active
2649 || lane
2650 .snapshot()
2651 .await
2652 .map_err(|e| e.to_string())?
2653 .active
2654 .is_some()
2655 {
2656 return Err("cannot configure external tools while a run is active".into());
2657 }
2658 let definitions: Vec<ExternalToolDefinition> =
2659 serde_json::from_value(msg["tools"].clone()).map_err(|e| e.to_string())?;
2660 let mut registry = harness.get_tools().await.map_err(|e| e.to_string())?;
2661 registry.retain(|tool| !old_names.contains(&tool.tool.schema().name));
2662 let mut reserved = [
2664 "read",
2665 "write",
2666 "edit",
2667 "bash",
2668 "powershell",
2669 "docs",
2670 "grep",
2671 "find",
2672 "ls",
2673 "image",
2674 "permissions",
2675 "permission",
2676 "ask_user",
2677 "plan_mode_start",
2678 "plan_mode_complete",
2679 ]
2680 .iter()
2681 .map(|name| name.to_string())
2682 .collect::<Vec<_>>();
2683 reserved.extend(registry.iter().map(|tool| tool.tool.schema().name.clone()));
2684 validate_external_tool_definitions(&definitions, &reserved).map_err(|e| e.to_string())?;
2685 let names = definitions
2686 .iter()
2687 .map(|def| def.name.clone())
2688 .collect::<Vec<_>>();
2689 let mut active = lane.get_active_tools().await.map_err(|e| e.to_string())?;
2690 active.retain(|name| !old_names.contains(name));
2691 active.extend(names.clone());
2692 registry.extend(
2693 definitions
2694 .into_iter()
2695 .map(|def| HarnessTool::new(create_external_tool(def, backend.clone()))),
2696 );
2697 harness
2698 .set_tools(registry, Some(active.clone()))
2699 .await
2700 .map_err(|e| e.to_string())?;
2701 *old_names = names.clone();
2702 Ok(serde_json::json!({"tools": names, "activeTools": active}))
2703}
2704
2705async fn rpc_get_available_tools(
2706 lane: &Arc<dyn AgentLane>,
2707 harness: &AgentHarness,
2708) -> serde_json::Value {
2709 let active: Vec<String> = if harness.has_exact_resources() {
2710 harness
2711 .execution_context()
2712 .ok()
2713 .and_then(|v| serde_json::from_value(v["tools"].clone()).ok())
2714 .unwrap_or_default()
2715 } else {
2716 lane.get_active_tools().await.unwrap_or_default()
2717 };
2718 let tools = harness
2719 .get_tools()
2720 .await
2721 .map(|tools| {
2722 tools
2723 .iter()
2724 .map(|tool| tool.tool.schema().name.clone())
2725 .collect::<Vec<_>>()
2726 })
2727 .unwrap_or_else(|_| active.clone());
2728 serde_json::json!({"tools": tools, "activeTools": active})
2729}
2730
2731async fn rpc_set_model(
2732 lane: &Arc<dyn AgentLane>,
2733 catalog: &[rpi_ai::Model],
2734 msg: &serde_json::Value,
2735) -> Result<serde_json::Value, String> {
2736 let id = msg
2737 .get("modelId")
2738 .or_else(|| msg.get("model"))
2739 .and_then(|v| v.as_str())
2740 .ok_or("set_model requires `modelId`")?;
2741 let provider = msg.get("provider").and_then(|v| v.as_str());
2742 let model = catalog
2743 .iter()
2744 .find(|m| {
2745 m.id.eq_ignore_ascii_case(id)
2746 && provider.map_or(true, |provider| m.provider.eq_ignore_ascii_case(provider))
2747 })
2748 .cloned()
2749 .ok_or_else(|| format!("unknown model: {id}"))?;
2750 lane.set_model(model).await.map_err(|e| e.to_string())?;
2751 Ok(serde_json::json!({"modelId": id, "provider": provider}))
2752}
2753
2754async fn rpc_set_thinking_level(
2755 lane: &Arc<dyn AgentLane>,
2756 msg: &serde_json::Value,
2757) -> Result<serde_json::Value, String> {
2758 let value = msg
2759 .get("level")
2760 .or_else(|| msg.get("thinkingLevel"))
2761 .ok_or("set_thinking_level requires `level`")?;
2762 let level: rpi_ai::types::ThinkingLevel =
2763 serde_json::from_value(value.clone()).map_err(|_| {
2764 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
2765 })?;
2766 lane.set_thinking_level(level)
2767 .await
2768 .map_err(|e| e.to_string())?;
2769 Ok(serde_json::json!({"thinkingLevel": level}))
2770}
2771
2772async fn rpc_set_active_tools(
2773 lane: &Arc<dyn AgentLane>,
2774 msg: &serde_json::Value,
2775) -> Result<serde_json::Value, String> {
2776 let tools = msg
2777 .get("tools")
2778 .and_then(|v| v.as_array())
2779 .map(|arr| {
2780 arr.iter()
2781 .map(|v| {
2782 v.as_str()
2783 .map(str::to_owned)
2784 .ok_or("tool names must be strings")
2785 })
2786 .collect::<Result<Vec<_>, _>>()
2787 })
2788 .ok_or("set_active_tools requires a `tools` array")??;
2789 lane.set_active_tools(tools.clone())
2790 .await
2791 .map_err(|e| e.to_string())?;
2792 Ok(serde_json::json!({"activeTools": tools}))
2793}
2794
2795fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
2800 crate::rpc::events::project(event)
2801}
2802
2803fn emit_json_event(event: &HarnessEvent) {
2807 let line = match event {
2808 HarnessEvent::CompactionProgress(e) => {
2809 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
2810 }
2811 HarnessEvent::RunStart(e) => serde_json::json!({
2812 "type": "run_start",
2813 "lane": e.lane,
2814 "runId": e.run_id,
2815 }),
2816 HarnessEvent::RunEnd(e) => serde_json::json!({
2817 "type": "run_end",
2818 "lane": e.lane,
2819 "runId": e.run_id,
2820 "outcome": run_end_outcome_str(e.outcome),
2821 "leafId": e.leaf_id,
2822 }),
2823 };
2824 println!("{line}");
2825}
2826
2827fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
2828 match o {
2829 RunEndOutcome::Completed => "completed",
2830 RunEndOutcome::Aborted => "aborted",
2831 RunEndOutcome::Failed => "failed",
2832 }
2833}
2834
2835pub async fn interactive(
2844 harness: &AgentHarness,
2845 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
2846 args: &Args,
2847 model_catalog: Vec<rpi_ai::Model>,
2848 initial: Option<String>,
2849 extra_messages: &[String],
2850 initial_images: Vec<ImageContent>,
2851 theme: Option<&str>,
2852 no_themes: bool,
2853 reload_context: &crate::session::ReloadContext,
2854) -> i32 {
2855 if let Some(reason) = crate::session::dispatch_session_event_async(
2862 reload_context,
2863 rpi_plugin_sdk::EventTag::BeforeTuiStart,
2864 )
2865 .await
2866 {
2867 eprintln!("[rpi] startup vetoed by extension: {reason}");
2868 return crate::app::EXIT_VETOED;
2869 }
2870
2871 let force_tui = std::env::var("RPI_FORCE_TUI")
2873 .map(|v| v == "1")
2874 .unwrap_or(false);
2875 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
2876 crate::interactive_tui::interactive_tui(
2878 harness,
2879 event_rx,
2880 args,
2881 model_catalog,
2882 initial,
2883 extra_messages,
2884 initial_images,
2885 theme,
2886 no_themes,
2887 reload_context,
2888 )
2889 .await
2890 } else {
2891 for notice in &reload_context.startup_notices {
2894 eprintln!("note: {notice}");
2895 }
2896 interactive_repl(harness, args, initial, extra_messages, initial_images).await
2897 };
2898
2899 if let Some(reason) = crate::session::dispatch_session_event_async(
2905 reload_context,
2906 rpi_plugin_sdk::EventTag::SessionShutdown,
2907 )
2908 .await
2909 {
2910 tracing::warn!(
2911 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
2912 );
2913 }
2914
2915 code
2916}
2917
2918pub async fn interactive_repl(
2920 harness: &AgentHarness,
2921 #[allow(unused_variables)] args: &Args,
2922 initial: Option<String>,
2923 extra_messages: &[String],
2924 initial_images: Vec<ImageContent>,
2925) -> i32 {
2926 let lane: Arc<dyn AgentLane> = harness.lane("main");
2928 let stdin = std::io::stdin();
2929 let is_tty = stdin.is_terminal();
2930
2931 if is_tty {
2932 println!(
2933 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
2934 );
2935 }
2936
2937 let mut prompts: Vec<String> = Vec::new();
2939 if let Some(init) = initial {
2940 prompts.push(init);
2941 }
2942 for m in extra_messages {
2943 prompts.push(m.clone());
2944 }
2945 let mut images = initial_images;
2946 for prompt in prompts {
2947 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
2948 return code;
2949 }
2950 }
2951
2952 let mut line = String::new();
2954 loop {
2955 if is_tty {
2956 print!("> ");
2957 let _ = std::io::stdout().flush();
2958 }
2959 line.clear();
2960 match stdin.lock().read_line(&mut line) {
2961 Ok(0) => break, Ok(_) => {}
2963 Err(_) => break,
2964 }
2965 let trimmed = line.trim();
2966 if trimmed.is_empty() {
2967 continue;
2968 }
2969 if trimmed == "/exit" || trimmed == "/quit" {
2970 break;
2971 }
2972 if trimmed == "/abort" {
2973 let _ = lane.abort().await;
2974 eprintln!("(aborted)");
2975 continue;
2976 }
2977 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
2978 return code;
2979 }
2980 }
2981 0
2982}
2983
2984async fn run_one(
2988 lane: &Arc<dyn AgentLane>,
2989 prompt: &str,
2990 images: Vec<ImageContent>,
2991) -> Result<(), i32> {
2992 match lane.prompt_text(prompt, images).await {
2993 Ok(result) => {
2994 match &result.outcome {
2995 HarnessRunOutcome::Completed { final_message, .. }
2996 | HarnessRunOutcome::Aborted { final_message, .. } => {
2997 let text = assistant_text(final_message);
2998 if !text.is_empty() {
2999 println!("{text}");
3000 }
3001 }
3002 HarnessRunOutcome::Failed {
3003 error,
3004 final_message,
3005 ..
3006 } => {
3007 if let Some(m) = final_message {
3008 if let Some(em) = &m.error_message {
3009 eprintln!("error: {em}");
3010 }
3011 }
3012 eprintln!("run failed: {error:?}");
3013 }
3014 HarnessRunOutcome::Suspended { .. } => {
3015 eprintln!("run suspended (deferred) — resume not supported in v1");
3016 }
3017 }
3018 Ok(())
3019 }
3020 Err(e) => {
3021 eprintln!("prompt rejected: {e}");
3022 Err(1)
3023 }
3024 }
3025}
3026
3027fn session_storage_notice(message: &str) -> String {
3034 format!(
3035 "conversation not saved: {message} — the session log is missing this turn \
3036 and print mode exits without writing it"
3037 )
3038}
3039
3040#[cfg(test)]
3041#[path = "rpc/native_dispatch_tests.rs"]
3042mod native_dispatch_tests;
3043#[cfg(all(test, feature = "experimental-native-store"))]
3044#[path = "modes/native_persistence_tests.rs"]
3045mod native_persistence_tests;
3046#[cfg(test)]
3047#[path = "modes/transcript_page_tests.rs"]
3048mod transcript_page_tests;
3049
3050#[cfg(test)]
3051mod tests {
3052 use super::*;
3053 use rpi_agent::events::AgentEvent;
3054 use rpi_agent::types::AgentToolResult;
3055 use rpi_ai::types::{
3056 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
3057 };
3058 use rpi_harness::session::types::OperationError;
3059
3060 #[test]
3063 fn abort_response_hands_back_the_reclaimed_queues() {
3064 let user = |text: &str| {
3065 rpi_agent::AgentMessage::User(rpi_ai::types::UserMessage::new(
3066 rpi_ai::types::UserContent::Text(text.to_string()),
3067 0,
3068 ))
3069 };
3070 let result = rpi_harness::agent_harness::AbortResult {
3071 run_id: "run-1".to_string(),
3072 steer: vec![user("S")],
3073 follow_up: vec![user("F")],
3074 };
3075
3076 let reply = abort_response(&result);
3077 assert_eq!(reply["aborted"], serde_json::json!(true));
3078 assert_eq!(reply["steer"][0]["text"], serde_json::json!("S"));
3079 assert_eq!(reply["followUp"][0]["text"], serde_json::json!("F"));
3080 assert_eq!(reply["steer"].as_array().unwrap().len(), 1);
3082
3083 assert_eq!(
3085 reclaimed_payload(&result),
3086 serde_json::json!({
3087 "steer": reply["steer"].clone(),
3088 "followUp": reply["followUp"].clone(),
3089 })
3090 );
3091 }
3092
3093 include!("rpc/external_tools_configuration_tests.rs");
3094 include!("rpc/external_tools_lifecycle_tests.rs");
3095 include!("rpc/external_tools_abort_outcome_tests.rs");
3096
3097 #[test]
3098 fn session_branch_query_requires_canonical_arguments() {
3099 let query = branch_entry_query(&serde_json::json!({
3100 "customTypes": ["alpha", "beta", "alpha"], "afterSeq": 42,
3101 }))
3102 .unwrap();
3103 assert_eq!(query.custom_types, ["alpha", "beta"]);
3104 assert_eq!(query.order, EntryOrder::OldestFirst);
3105 assert_eq!(query.after_seq, Some(42));
3106 assert!(query.entry_queries().iter().all(|q| q.limit.is_none()));
3107 for invalid in [
3108 serde_json::json!({}),
3109 serde_json::json!(null),
3110 serde_json::json!([]),
3111 serde_json::json!({"customTypes": []}),
3112 serde_json::json!({"customTypes": "alpha"}),
3113 serde_json::json!({"customTypes": [""]}),
3114 serde_json::json!({"customTypes": [" \t "]}),
3115 serde_json::json!({"customTypes": ["alpha", 1]}),
3116 serde_json::json!({"customTypes": ["alpha"], "limit": 0}),
3117 serde_json::json!({"customTypes": ["alpha"], "limit": 1.5}),
3118 serde_json::json!({"customTypes": ["alpha"], "order": "sideways"}),
3119 serde_json::json!({"customTypes": ["alpha"], "order": 1}),
3120 serde_json::json!({"customTypes": ["alpha"], "afterSeq": -1}),
3121 ] {
3122 assert!(branch_entry_query(&invalid).is_err(), "{invalid}");
3123 }
3124 }
3125
3126 #[test]
3127 fn session_branch_multi_type_limit_is_pushed_to_each_storage_query() {
3128 for order in ["oldestFirst", "newestFirst"] {
3129 let request = branch_entry_query(&serde_json::json!({
3130 "customTypes": ["alpha", "beta", "alpha"],
3131 "limit": 2, "order": order, "afterSeq": 42,
3132 }))
3133 .unwrap();
3134 let queries = request.entry_queries();
3135 assert_eq!(queries.len(), 2);
3136 for query in queries {
3137 assert_eq!(query.limit, Some(2));
3138 assert_eq!(query.order, Some(request.order));
3139 assert_eq!(query.cursor, Some(EntryCursor { after_seq: 42 }));
3140 }
3141 }
3142 }
3143
3144 #[tokio::test]
3145 async fn resource_selection_binding_is_protected_for_scoped_and_unscoped_public_appends() {
3146 use rpi_extensions::RuntimeActionHost;
3147 let harness = AgentHarness::create(Default::default()).await.unwrap();
3148 let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3149 vec![],
3150 std::env::temp_dir(),
3151 tokio::runtime::Handle::current(),
3152 Default::default(),
3153 rpi_extensions::ToolCallContext::default(),
3154 );
3155 crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3156 let (_, session_id, leaf_id) = branch_snapshot(
3157 &harness,
3158 &branch_entry_query(&serde_json::json!({"customTypes":["rpi.value"]})).unwrap(),
3159 )
3160 .await
3161 .unwrap();
3162 let guard = serde_json::json!({
3163 "sessionId": session_id, "leafId": leaf_id,
3164 "customType": "rpi.value", "entryId": null
3165 });
3166 for key in ["rpi.resource-selection.v1", "rpi.resource-inventory.v1"] {
3167 for value in [serde_json::Value::Null, serde_json::json!({"forged":true})] {
3168 for scoped in [false, true] {
3169 let mut request = serde_json::json!({
3170 "customType":"rpi.value", "data":{"key":key,"value":value}
3171 });
3172 if scoped {
3173 request["expected"] = guard.clone();
3174 }
3175 assert!(rpc_append_entry(&harness, &request)
3176 .await
3177 .unwrap_err()
3178 .contains("host-owned"));
3179 assert!(host
3180 .append_entry(request)
3181 .await
3182 .unwrap_err()
3183 .contains("host-owned"));
3184 }
3185 }
3186 }
3187 let request = serde_json::json!({
3188 "customType":"rpi.value", "data":{"key":"user.preference","value":1},
3189 "expected":guard
3190 });
3191 assert_eq!(host.append_entry(request).await.unwrap()["scoped"], true);
3192 }
3193
3194 #[tokio::test]
3195 async fn branch_snapshot_guards_and_outbox_acknowledgements_cross_the_runtime_action_boundary()
3196 {
3197 use rpi_extensions::RuntimeActionHost;
3198 let harness = AgentHarness::create(Default::default()).await.unwrap();
3199 let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3200 vec![],
3201 std::env::temp_dir(),
3202 tokio::runtime::Handle::current(),
3203 Default::default(),
3204 rpi_extensions::ToolCallContext::default(),
3205 );
3206 crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3207 let before = harness
3209 .session()
3210 .find_entries(&EntryQuery::default())
3211 .await
3212 .unwrap();
3213 let values = host
3214 .get_session_branch(serde_json::json!({"customTypes":["rpi.value"]}))
3215 .await
3216 .unwrap();
3217 for key in ["rpi.resource-selection.v1", "rpi.resource-inventory.v1"] {
3218 let forged = serde_json::json!({
3219 "customType":"rpi.value", "data":{"key":key,"value":{}},
3220 "expected":{"sessionId":values["sessionId"],"leafId":values["leafId"],"customType":"rpi.value","entryId":null}
3221 });
3222 assert!(host
3223 .append_entry(forged.clone())
3224 .await
3225 .unwrap_err()
3226 .contains("host-owned"));
3227 assert!(rpc_append_entry(&harness, &forged)
3228 .await
3229 .unwrap_err()
3230 .contains("host-owned"));
3231 }
3232 assert_eq!(
3233 harness
3234 .session()
3235 .find_entries(&EntryQuery::default())
3236 .await
3237 .unwrap(),
3238 before
3239 );
3240 let snapshot = host
3241 .get_session_branch(
3242 serde_json::json!({"customTypes":["goal-state"],"limit":1,"order":"newestFirst"}),
3243 )
3244 .await
3245 .unwrap();
3246 let expected = serde_json::json!({"sessionId":snapshot["sessionId"],"leafId":snapshot["leafId"],"customType":"goal-state","entryId":null});
3247 let mutation =
3248 serde_json::json!({"customType":"goal-state","data":{"goal":"A"},"expected":expected});
3249 let ack = host.append_entry(mutation.clone()).await.unwrap();
3250 assert_eq!(ack["scoped"], true);
3251 assert!(host.append_entry(mutation.clone()).await.is_err());
3252 assert!(rpc_append_entry(&harness, &mutation).await.is_err());
3253 for _ in 0..2 {
3254 let snapshot = host.get_session_branch(serde_json::json!({"customTypes":["goal-state"],"limit":1,"order":"newestFirst"})).await.unwrap();
3255 let expected = serde_json::json!({"sessionId":snapshot["sessionId"],"leafId":snapshot["leafId"],"customType":"goal-state","entryId":ack["entryId"]});
3256 let notice = host.append_entry(serde_json::json!({"expected":expected,"outboxKey":"r1","message":{
3257 "kind":"custom","role":"custom","content":[{"type":"text","text":"Goal A"}],"data":{"customType":"goal"},"timestamp":1
3258 }})).await.unwrap();
3259 assert_eq!(notice["scoped"], true);
3260 }
3261 let entries = harness
3262 .session()
3263 .find_entries(&EntryQuery {
3264 outbox_key: Some("r1".into()),
3265 ..Default::default()
3266 })
3267 .await
3268 .unwrap();
3269 assert_eq!(entries.len(), 1);
3270 let snapshot = host
3271 .get_session_branch(serde_json::json!({"customTypes":["goal-state"],"limit":1}))
3272 .await
3273 .unwrap();
3274 let expected = serde_json::json!({"sessionId":snapshot["sessionId"],"leafId":snapshot["leafId"],"customType":"goal-state","entryId":ack["entryId"]});
3275 let rpc_ack = rpc_append_entry(
3276 &harness,
3277 &serde_json::json!({"customType":"goal-state","data":{"goal":"B"},"expected":expected}),
3278 )
3279 .await
3280 .unwrap();
3281 assert_eq!(rpc_ack["scoped"], true);
3282 assert!(rpc_ack["entryId"].as_str().is_some_and(|id| !id.is_empty()));
3283 }
3284
3285 #[tokio::test]
3286 async fn session_branch_exact_type_identities_round_trip_rpc_and_actions() {
3287 use rpi_extensions::RuntimeActionHost;
3288 let harness = AgentHarness::create(Default::default()).await.unwrap();
3289 let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3290 vec![],
3291 std::env::temp_dir(),
3292 tokio::runtime::Handle::current(),
3293 Default::default(),
3294 rpi_extensions::ToolCallContext::default(),
3295 );
3296 crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3297 let names = ["alpha", " alpha ", "beta", " beta "];
3298 for (index, name) in names.iter().enumerate() {
3299 let args = serde_json::json!({"customType": name, "data": {"n": index}});
3300 if index % 2 == 0 {
3301 host.append_entry(args).await.unwrap();
3302 } else {
3303 rpc_append_entry(&harness, &args).await.unwrap();
3304 }
3305 }
3306 let request = branch_entry_query(&serde_json::json!({
3307 "customTypes": ["alpha", " alpha ", "beta", " beta ", "alpha"],
3308 }))
3309 .unwrap();
3310 assert_eq!(request.custom_types, names);
3311 let mut input = String::new();
3312 let mut expected = Vec::new();
3313 for (index, name) in names.iter().enumerate() {
3314 let args = serde_json::json!({"customTypes": [name]});
3315 let result = host.get_session_branch(args.clone()).await.unwrap();
3316 assert_eq!(result["count"], 1);
3317 assert_eq!(result["entries"][0]["customType"], *name);
3318 assert_eq!(result["entries"][0]["data"]["n"], index);
3319 let mut envelope = args;
3320 envelope["type"] = serde_json::json!("get_session_branch");
3321 envelope["id"] = serde_json::json!(index);
3322 input.push_str(&format!("{envelope}\n"));
3323 expected.push(result);
3324 }
3325 let output = RpcOutput::default();
3326 assert_eq!(
3327 rpc_with_io(
3328 &harness,
3329 None,
3330 vec![],
3331 rpi_extensions::UiDialogMailbox::new(),
3332 None,
3333 crate::args::RpcMessageUpdates::default(),
3334 tokio::io::BufReader::new(input.as_bytes()),
3335 output.clone(),
3336 )
3337 .await,
3338 0
3339 );
3340 let messages = output.messages();
3341 for (index, expected) in expected.into_iter().enumerate() {
3342 let response = messages
3343 .iter()
3344 .find(|message| message["id"] == index)
3345 .unwrap();
3346 assert_eq!(response["data"], expected);
3347 }
3348 }
3349
3350 #[tokio::test]
3351 async fn session_branch_filters_merge_and_respect_fork_bounds() {
3352 use rpi_harness::session::types::{ForkOptions, ForkPosition, SessionRepo};
3353 use rpi_harness::session::{CounterIdGenerator, FakeClock, InMemorySessionRepo, Session};
3354 let repo = InMemorySessionRepo::new(
3355 Arc::new(FakeClock::new()),
3356 Arc::new(CounterIdGenerator::new()),
3357 );
3358 let storage = repo.create(&Default::default()).await.unwrap();
3359 let session = Session::new(Arc::new(storage), None);
3360 let harness = AgentHarness::create(rpi_harness::types::AgentHarnessOptions {
3361 session,
3362 ..Default::default()
3363 })
3364 .await
3365 .unwrap();
3366 let session = harness.session();
3367 let empty = rpc_get_session_branch(
3368 &harness,
3369 &serde_json::json!({"customTypes":["alpha","beta"]}),
3370 )
3371 .await
3372 .unwrap();
3373 assert_eq!(empty["entries"], serde_json::json!([]));
3374 assert_eq!(empty["filtered"], true);
3375 let mut ids = Vec::new();
3376 let mut fork_target = None;
3377 for (index, kind) in ["alpha", "noise", "beta", "alpha", "noise", "beta"]
3378 .into_iter()
3379 .enumerate()
3380 {
3381 if index == 2 {
3382 fork_target = Some(
3383 session
3384 .append_message(rpi_agent::AgentMessage::User(UserMessage::new(
3385 "fork target",
3386 0,
3387 )))
3388 .await
3389 .unwrap(),
3390 );
3391 }
3392 ids.push(session.append_custom_entry(kind, None).await.unwrap());
3393 }
3394 session
3396 .append_message(rpi_agent::AgentMessage::User(UserMessage::new("noise", 0)))
3397 .await
3398 .unwrap();
3399 session
3400 .append_record(
3401 serde_json::from_value(serde_json::json!({
3402 "type":"usage", "id":"usage-noise", "seq":0, "timestamp":0,
3403 "lane":"main", "usage":Usage::zero(), "cause":"adjustment"
3404 }))
3405 .unwrap(),
3406 )
3407 .await
3408 .unwrap();
3409 let run = |args| {
3410 let harness = &harness;
3411 async move { rpc_get_session_branch(harness, &args).await.unwrap() }
3412 };
3413 let all = run(serde_json::json!({"customTypes":["alpha","beta","alpha"]})).await;
3414 let entry_ids = |value: &serde_json::Value| -> Vec<String> {
3415 value["entries"]
3416 .as_array()
3417 .unwrap()
3418 .iter()
3419 .map(|entry| entry["id"].as_str().unwrap().to_owned())
3420 .collect()
3421 };
3422 assert_eq!(
3423 entry_ids(&all),
3424 vec![
3425 ids[0].clone(),
3426 ids[2].clone(),
3427 ids[3].clone(),
3428 ids[5].clone()
3429 ]
3430 );
3431 assert_eq!(all["count"], 4);
3432 assert_eq!(all["filtered"], true);
3433 let cursor = all["entries"][1]["seq"].as_u64().unwrap();
3434 for (order, expected) in [
3435 (
3436 "oldestFirst",
3437 vec![ids[0].clone(), ids[2].clone(), ids[3].clone()],
3438 ),
3439 (
3440 "newestFirst",
3441 vec![ids[5].clone(), ids[3].clone(), ids[2].clone()],
3442 ),
3443 ] {
3444 let result =
3445 run(serde_json::json!({"customTypes":["alpha","beta"], "order":order, "limit":3}))
3446 .await;
3447 assert_eq!(entry_ids(&result), expected);
3448 let result = run(serde_json::json!({"customTypes":["alpha","beta"], "order":order, "afterSeq":cursor, "limit":1})).await;
3449 assert_eq!(
3450 entry_ids(&result),
3451 vec![if order == "oldestFirst" {
3452 ids[3].clone()
3453 } else {
3454 ids[0].clone()
3455 }]
3456 );
3457 }
3458 let single = run(serde_json::json!({"customTypes":["beta"], "afterSeq":all["entries"][3]["seq"], "order":"newestFirst", "limit":1})).await;
3459 assert_eq!(entry_ids(&single), vec![ids[2].clone()]);
3460
3461 session.move_lane("main", Some(&ids[2])).await.unwrap();
3463 let branch_tip = session.append_custom_entry("alpha", None).await.unwrap();
3464 session.create_lane("other", Some(&ids[5])).await.unwrap();
3465 let branch = run(serde_json::json!({"customTypes":["alpha","beta"]})).await;
3466 assert_eq!(
3467 entry_ids(&branch),
3468 vec![ids[0].clone(), ids[2].clone(), branch_tip]
3469 );
3470 let empty =
3471 run(serde_json::json!({"customTypes":["alpha","beta"], "afterSeq":u64::MAX - 1})).await;
3472 assert!(entry_ids(&empty).is_empty());
3473
3474 let fork = repo
3476 .fork(
3477 &session.storage().metadata(),
3478 &Default::default(),
3479 &ForkOptions::Branch {
3480 entry_id: fork_target,
3481 position: Some(ForkPosition::Before),
3482 },
3483 )
3484 .await
3485 .unwrap();
3486 harness
3487 .set_session(Session::new(Arc::new(fork), None))
3488 .await
3489 .unwrap();
3490 let forked = run(
3491 serde_json::json!({"customTypes":["alpha","beta"], "order":"newestFirst", "limit":2}),
3492 )
3493 .await;
3494 assert_eq!(entry_ids(&forked), vec![ids[0].clone()]);
3495 }
3496
3497 #[tokio::test]
3498 async fn session_branch_rpc_and_action_share_strict_query_behavior() {
3499 use rpi_extensions::RuntimeActionHost;
3500 let harness = AgentHarness::create(Default::default()).await.unwrap();
3501 let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
3502 vec![],
3503 std::env::temp_dir(),
3504 tokio::runtime::Handle::current(),
3505 Default::default(),
3506 rpi_extensions::ToolCallContext::default(),
3507 );
3508 crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
3509 host.append_entry(serde_json::json!({"customType":"alpha", "data":{"n":1}}))
3511 .await
3512 .unwrap();
3513 rpc_append_entry(
3514 &harness,
3515 &serde_json::json!({"customType":"beta", "data":{"n":2}}),
3516 )
3517 .await
3518 .unwrap();
3519 let valid =
3520 serde_json::json!({"customTypes":["alpha","beta"], "limit":1, "order":"newestFirst"});
3521 let expected = host.get_session_branch(valid.clone()).await.unwrap();
3522 assert_eq!(expected["entries"][0]["customType"], "beta");
3523 let mut requests = vec![serde_json::json!({})];
3524 for key in [
3525 "customType",
3526 "custom_type",
3527 "custom_types",
3528 "after_seq",
3529 "unknown",
3530 ] {
3531 let value = if key == "after_seq" {
3532 serde_json::json!(0)
3533 } else {
3534 serde_json::json!("alpha")
3535 };
3536 requests.push(serde_json::json!({key:value.clone()}));
3537 let mut mixed = valid.clone();
3538 mixed[key] = value;
3539 requests.push(mixed);
3540 }
3541 let mut input = String::new();
3542 let mut envelope = valid;
3543 envelope["type"] = serde_json::json!("get_session_branch");
3544 envelope["id"] = serde_json::json!("modern");
3545 input.push_str(&format!("{envelope}\n"));
3546 for (index, args) in requests.iter().enumerate() {
3547 let error = host.get_session_branch(args.clone()).await.unwrap_err();
3548 assert!(error.contains("get_session_branch"), "{error}");
3549 let mut envelope = args.clone();
3550 envelope["type"] = serde_json::json!("get_session_branch");
3551 envelope["id"] = serde_json::json!(index);
3552 assert_eq!(
3553 rpc_get_session_branch(&harness, &envelope)
3554 .await
3555 .unwrap_err(),
3556 error
3557 );
3558 input.push_str(&format!("{envelope}\n"));
3559 }
3560 let output = RpcOutput::default();
3561 assert_eq!(
3562 rpc_with_io(
3563 &harness,
3564 None,
3565 vec![],
3566 rpi_extensions::UiDialogMailbox::new(),
3567 None,
3568 crate::args::RpcMessageUpdates::default(),
3569 tokio::io::BufReader::new(input.as_bytes()),
3570 output.clone(),
3571 )
3572 .await,
3573 0
3574 );
3575 let messages = output.messages();
3576 assert_eq!(
3577 messages.iter().find(|m| m["id"] == "modern").unwrap()["data"],
3578 expected
3579 );
3580 for index in 0..requests.len() {
3581 let response = messages.iter().find(|m| m["id"] == index).unwrap();
3582 assert_eq!(response["success"], false, "{response}");
3583 }
3584 }
3585
3586 #[tokio::test]
3587 async fn rpc_commands_list_and_execute_registered_handler() {
3588 extern "C" fn echo(
3589 input: rpi_plugin_sdk::StbStringRef,
3590 out: *mut rpi_plugin_sdk::StbString,
3591 _: *mut std::ffi::c_void,
3592 ) -> i32 {
3593 unsafe {
3595 *out = rpi_plugin_sdk::StbString::from_string(input.as_str().to_owned());
3596 }
3597 0
3598 }
3599 let mut registry = rpi_extensions::ExtensionRegistry::new();
3600 registry.register_command(
3601 "echo".into(),
3602 "Echo arguments".into(),
3603 echo,
3604 std::ptr::null_mut(),
3605 );
3606 let harness = AgentHarness::create(Default::default()).await.unwrap();
3607 let output = RpcOutput::default();
3608 let input = concat!(
3609 "{\"id\":1,\"type\":\"get_commands\"}\n",
3610 "{\"id\":2,\"type\":\"execute_command\",\"command\":\"/echo\",\"args\":\"hello world\"}\n",
3611 "{\"id\":3,\"type\":\"execute_command\",\"command\":\"missing\"}\n",
3612 "{\"id\":4,\"type\":\"execute_command\",\"command\":\"echo\",\"args\":42}\n"
3613 );
3614 assert_eq!(
3615 rpc_with_io(
3616 &harness,
3617 None,
3618 vec![],
3619 rpi_extensions::UiDialogMailbox::new(),
3620 Some(Arc::new(registry.snapshot())),
3621 crate::args::RpcMessageUpdates::default(),
3622 tokio::io::BufReader::new(input.as_bytes()),
3623 output.clone()
3624 )
3625 .await,
3626 0
3627 );
3628 let messages = output.messages();
3629 let response = |id| messages.iter().find(|m| m["id"] == id).unwrap();
3630 assert_eq!(response(1)["data"]["commands"][0]["name"], "echo");
3631 assert_eq!(
3632 response(2)["data"],
3633 serde_json::json!({"command":"/echo", "args":"hello world"})
3634 );
3635 assert_eq!(response(3)["success"], false);
3636 assert_eq!(response(4)["success"], false);
3637 assert_eq!(rpc_get_commands(None), serde_json::json!({"commands":[]}));
3638 }
3639
3640 #[test]
3641 fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
3642 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3643 struct RestoreConfig(Option<std::ffi::OsString>);
3644 impl Drop for RestoreConfig {
3645 fn drop(&mut self) {
3646 match self.0.take() {
3647 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3648 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3649 }
3650 }
3651 }
3652 let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3653 let directory = tempfile::tempdir().unwrap();
3654 std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
3655 std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
3656 "header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
3657 "expired-account": {"api":"openai-completions", "models":[]}
3658 }}).to_string()).unwrap();
3659 std::fs::write(directory.path().join("auth.json"), serde_json::json!({
3660 "expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
3661 }).to_string()).unwrap();
3662 let status = rpc_get_auth_status(&[], None);
3663 let providers = status["providers"].as_array().unwrap();
3664 let header = providers
3665 .iter()
3666 .find(|row| row["provider"] == "header-gateway")
3667 .unwrap();
3668 assert_eq!(header["ready"], true);
3669 assert_eq!(header["source"], "headers");
3670 assert_eq!(header["status"], "ready");
3671 let expired = providers
3672 .iter()
3673 .find(|row| row["provider"] == "expired-account")
3674 .unwrap();
3675 assert_eq!(expired["ready"], false);
3676 assert_eq!(expired["status"], "expired");
3677 assert!(!status.to_string().contains("secret"));
3678 }
3679
3680 #[test]
3681 fn oauth_display_drops_token_like_fields() {
3682 let display = oauth_display(&serde_json::json!({
3683 "display": {
3684 "title": "Sign in",
3685 "url": "https://example.test/authorize",
3686 "instructions": ["Paste the code"],
3687 "access_token": "secret-access",
3688 "refresh_token": "secret-refresh",
3689 }
3690 }));
3691 assert_eq!(display["title"], "Sign in");
3692 assert!(display.get("access_token").is_none());
3693 assert!(display.get("refresh_token").is_none());
3694 }
3695
3696 #[test]
3697 fn rpc_message_text_joins_content_blocks_with_real_newlines() {
3698 let text = rpc_message_text(&serde_json::json!({
3699 "content": [
3700 {"type": "text", "text": "first"},
3701 {"type": "text", "text": "second"}
3702 ]
3703 }));
3704 assert_eq!(text, "first\nsecond");
3705 assert!(!text.contains("\\n"));
3706 }
3707
3708 #[derive(Clone, Default)]
3709 pub(super) struct RpcOutput(Arc<Mutex<Vec<u8>>>);
3710
3711 impl Write for RpcOutput {
3712 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
3713 self.0.lock().unwrap().extend_from_slice(bytes);
3714 Ok(bytes.len())
3715 }
3716 fn flush(&mut self) -> std::io::Result<()> {
3717 Ok(())
3718 }
3719 }
3720
3721 impl RpcOutput {
3722 pub(super) fn messages(&self) -> Vec<serde_json::Value> {
3723 String::from_utf8(self.0.lock().unwrap().clone())
3724 .unwrap()
3725 .lines()
3726 .map(|line| serde_json::from_str(line).unwrap())
3727 .collect()
3728 }
3729
3730 pub(super) async fn wait_for(
3731 &self,
3732 predicate: impl Fn(&serde_json::Value) -> bool,
3733 ) -> serde_json::Value {
3734 tokio::time::timeout(std::time::Duration::from_secs(3), async {
3735 loop {
3736 if let Some(message) = self.messages().into_iter().find(&predicate) {
3737 return message;
3738 }
3739 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
3740 }
3741 })
3742 .await
3743 .expect("RPC message not received")
3744 }
3745 }
3746
3747 pub(super) async fn rpc_test_harness(
3748 text: &str,
3749 tokens_per_second: f64,
3750 ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
3751 let provider = rpi_ai::providers::faux::FauxProvider::new(
3752 rpi_ai::providers::faux::FauxScript::new()
3753 .with_text(text)
3754 .with_tokens_per_second(tokens_per_second),
3755 );
3756 let options = rpi_harness::types::AgentHarnessOptions {
3757 model: provider.default_model().clone(),
3758 models: vec![provider.clone()],
3759 ..Default::default()
3760 };
3761 (AgentHarness::create(options).await.unwrap(), provider)
3762 }
3763
3764 pub(super) fn start_test_rpc(
3765 harness: AgentHarness,
3766 ui: rpi_extensions::UiDialogMailbox,
3767 ) -> (
3768 tokio::io::DuplexStream,
3769 RpcOutput,
3770 tokio::task::JoinHandle<i32>,
3771 ) {
3772 let (client, input) = tokio::io::duplex(4096);
3773 let output = RpcOutput::default();
3774 let captured = output.clone();
3775 let task = tokio::spawn(async move {
3776 rpc_with_io(
3777 &harness,
3778 None,
3779 vec![],
3780 ui,
3781 None,
3782 crate::args::RpcMessageUpdates::default(),
3783 tokio::io::BufReader::new(input),
3784 captured,
3785 )
3786 .await
3787 });
3788 (client, output, task)
3789 }
3790
3791 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3792 async fn rpc_paginated_user_summaries_and_context_windows_are_read_only() {
3793 use tokio::io::AsyncWriteExt;
3794 let (harness, _unused_provider) = rpc_test_harness("must not be called", 0.0).await;
3795 let view = harness.view();
3796 for index in 0..23 {
3797 view.append_message(
3798 rpi_ai::UserMessage::new(format!("Question {index}"), index).into(),
3799 )
3800 .await
3801 .unwrap();
3802 }
3803 let pinned = view.get_leaf_id().await.unwrap();
3804 let (mut client, output, task) =
3805 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3806 let ready = output.wait_for(|frame| frame["type"] == "ready").await;
3807 for command in ["get_message_summaries", "get_message_window"] {
3808 assert!(ready["supportedCommands"]
3809 .as_array()
3810 .unwrap()
3811 .contains(&serde_json::json!(command)));
3812 }
3813 client
3814 .write_all(b"{\"type\":\"get_message_summaries\",\"id\":1,\"limit\":10}\n")
3815 .await
3816 .unwrap();
3817 let first = output.wait_for(|frame| frame["id"] == 1).await;
3818 assert_eq!(first["success"], true);
3819 assert_eq!(first["data"]["messages"][0]["preview"], "Question 13");
3820 assert_eq!(first["data"]["messages"].as_array().unwrap().len(), 10);
3821 for (id, command, cursor, value) in [
3822 (
3823 2,
3824 "get_message_summaries",
3825 "beforeId",
3826 first["data"]["nextBeforeId"].clone(),
3827 ),
3828 (
3829 3,
3830 "get_message_window",
3831 "anchorId",
3832 first["data"]["firstId"].clone(),
3833 ),
3834 ] {
3835 let mut request = serde_json::json!({"id":id,"type":command,"limit":4,"leafId":pinned});
3836 request[cursor] = value;
3837 client
3838 .write_all(format!("{request}\n").as_bytes())
3839 .await
3840 .unwrap();
3841 let page = output.wait_for(|frame| frame["id"] == id).await;
3842 assert_eq!(page["success"], true);
3843 assert_eq!(page["data"]["messages"].as_array().unwrap().len(), 4);
3844 assert_eq!(page["data"]["leafId"], serde_json::json!(pinned));
3845 }
3846 let context = output.wait_for(|frame| frame["id"] == 3).await;
3847 for (id, cursor, key) in [
3848 (4, "beforeId", "nextBeforeId"),
3849 (5, "afterId", "nextAfterId"),
3850 ] {
3851 let mut request =
3852 serde_json::json!({"type":"get_message_window", "id":id,"leafId":pinned,"limit":4});
3853 request[cursor] = context["data"][key].clone();
3854 client
3855 .write_all(format!("{request}\n").as_bytes())
3856 .await
3857 .unwrap();
3858 let response = output.wait_for(|frame| frame["id"] == id).await;
3859 assert_eq!(response["success"], true);
3860 assert_eq!(response["data"]["messages"].as_array().unwrap().len(), 4);
3861 }
3862 client
3863 .write_all(b"{\"type\":\"stop\",\"id\":6}\n")
3864 .await
3865 .unwrap();
3866 assert_eq!(task.await.unwrap(), 0);
3867 assert_eq!(view.get_leaf_id().await.unwrap(), pinned);
3868 assert!(!output
3869 .messages()
3870 .iter()
3871 .any(|frame| frame["type"] == "agent_start"));
3872 }
3873
3874 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3875 async fn rpc_eof_waits_for_accepted_prompt() {
3876 use tokio::io::AsyncWriteExt;
3877 let (harness, _) = rpc_test_harness("completed", 100.0).await;
3878 let ui = rpi_extensions::UiDialogMailbox::new();
3879 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
3880 client
3881 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
3882 .await
3883 .unwrap();
3884 client.shutdown().await.unwrap();
3885 assert_eq!(
3886 tokio::time::timeout(std::time::Duration::from_secs(3), task)
3887 .await
3888 .unwrap()
3889 .unwrap(),
3890 0
3891 );
3892 let response = output
3893 .messages()
3894 .into_iter()
3895 .find(|m| m["id"] == 1)
3896 .unwrap();
3897 assert_eq!(response["command"], "prompt");
3898 assert_eq!(response["data"]["accepted"], true);
3899 assert!(output.messages().iter().any(|message| {
3900 message["type"] == "agent_settled" && message["outcome"] == "completed"
3901 }));
3902 assert!(!ui.is_attached());
3903 }
3904
3905 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3906 async fn rpc_append_entry_publishes_generic_commit_event() {
3907 use tokio::io::AsyncWriteExt;
3908 let (harness, _) = rpc_test_harness("unused", 0.0).await;
3909 let (mut client, output, task) =
3910 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3911 client
3912 .write_all(
3913 br#"{"type":"append_entry","id":1,"customType":"durable-state","data":{"revision":1}}
3914"#,
3915 )
3916 .await
3917 .unwrap();
3918
3919 let event = output
3920 .wait_for(|message| message["type"] == "session_entry_committed")
3921 .await;
3922 assert_eq!(event["customType"], "durable-state");
3923 assert_eq!(event["entryType"], "custom");
3924 assert_eq!(event["lane"], "main");
3925 assert!(event["entryId"].as_str().is_some());
3926
3927 client
3928 .write_all(
3929 br#"{"type":"stop","id":2}
3930"#,
3931 )
3932 .await
3933 .unwrap();
3934 assert_eq!(task.await.unwrap(), 0);
3935 }
3936
3937 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3938 async fn rpc_prompt_publishes_committed_message_event() {
3939 use tokio::io::AsyncWriteExt;
3940 let (harness, _) = rpc_test_harness("completed", 0.0).await;
3941 let (mut client, output, task) =
3942 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3943 client
3944 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
3945 .await
3946 .unwrap();
3947 let event = output
3948 .wait_for(|message| {
3949 message["type"] == "session_entry_committed" && message["entryType"] == "message"
3950 })
3951 .await;
3952 assert_eq!(event["lane"], "main");
3953 assert!(event["entryId"].as_str().is_some());
3954 output
3955 .wait_for(|message| message["type"] == "agent_settled")
3956 .await;
3957 client
3958 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
3959 .await
3960 .unwrap();
3961 assert_eq!(task.await.unwrap(), 0);
3962 }
3963
3964 #[tokio::test]
3965 async fn rpc_stop_flushes_all_accepted_entry_invalidations() {
3966 let (harness, _) = rpc_test_harness("unused", 0.0).await;
3967 let output = RpcOutput::default();
3968 let mut input = String::new();
3969 for id in 1..=4 {
3970 input.push_str(&format!(
3971 "{{\"type\":\"append_entry\",\"id\":{id},\"customType\":\"durable-state\"}}\n"
3972 ));
3973 }
3974 input.push_str("{\"type\":\"stop\",\"id\":5}\n");
3975 let result = rpc_with_io(
3976 &harness,
3977 None,
3978 vec![],
3979 rpi_extensions::UiDialogMailbox::new(),
3980 None,
3981 crate::args::RpcMessageUpdates::default(),
3982 tokio::io::BufReader::new(input.as_bytes()),
3983 output.clone(),
3984 )
3985 .await;
3986 assert_eq!(result, 0);
3987 let messages = output.messages();
3988 let commits: std::collections::HashSet<_> = messages
3989 .iter()
3990 .filter(|m| m["type"] == "session_entry_committed")
3991 .map(|m| m["entryId"].as_str().unwrap())
3992 .collect();
3993 assert_eq!(
3994 commits.len(),
3995 4,
3996 "stop must flush every committed entry: {messages:?}"
3997 );
3998 }
3999
4000 #[tokio::test]
4001 async fn rpc_eof_flushes_queued_entry_invalidations() {
4002 let (harness, _) = rpc_test_harness("unused", 0.0).await;
4003 let output = RpcOutput::default();
4004 let input = b"{\"type\":\"append_entry\",\"id\":1,\"customType\":\"durable-state\",\"data\":{\"revision\":1}}\n";
4005 let result = rpc_with_io(
4006 &harness,
4007 None,
4008 vec![],
4009 rpi_extensions::UiDialogMailbox::new(),
4010 None,
4011 crate::args::RpcMessageUpdates::default(),
4012 tokio::io::BufReader::new(&input[..]),
4013 output.clone(),
4014 )
4015 .await;
4016 assert_eq!(result, 0);
4017 let messages = output.messages();
4018 assert!(
4019 messages
4020 .iter()
4021 .any(|m| m["type"] == "response" && m["id"] == 1 && m["success"] == true),
4022 "append succeeded: {messages:?}"
4023 );
4024 assert!(
4025 messages
4026 .iter()
4027 .any(|m| m["type"] == "session_entry_committed"),
4028 "EOF must flush the queued commit event: {messages:?}"
4029 );
4030 }
4031
4032 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4033 async fn rpc_compaction_settlement_identifies_its_durable_operation() {
4034 let (harness, _) = rpc_test_harness("unused", 0.0).await;
4035 let output = RpcOutput::default();
4036 let code = rpc_with_io(
4037 &harness,
4038 None,
4039 vec![],
4040 rpi_extensions::UiDialogMailbox::new(),
4041 None,
4042 crate::args::RpcMessageUpdates::default(),
4043 tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
4044 output.clone(),
4045 )
4046 .await;
4047 assert_eq!(code, 0);
4048 let messages = output.messages();
4049 let settled = messages
4050 .iter()
4051 .find(|message| message["type"] == "agent_settled")
4052 .unwrap();
4053 assert_eq!(settled["outcome"], "declined");
4054 let run_id = settled["runId"]
4055 .as_str()
4056 .expect("compaction settlement needs its durable run id");
4057 let records = harness
4058 .session()
4059 .find_records(&Default::default())
4060 .await
4061 .unwrap();
4062 assert!(records.iter().any(|record| matches!(record,
4063 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
4064 if finished.run_id == run_id)));
4065 }
4066
4067 #[tokio::test]
4068 async fn rpc_eof_drains_queued_final_events() {
4069 let harness = AgentHarness::create(Default::default()).await.unwrap();
4070 let (tx, rx) = tokio::sync::broadcast::channel(256);
4071 for _ in 0..100 {
4072 tx.send(AgentEvent::AgentStart).unwrap();
4073 }
4074 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
4075 let output = RpcOutput::default();
4076 let code = rpc_with_io(
4077 &harness,
4078 Some(rx),
4079 vec![],
4080 rpi_extensions::UiDialogMailbox::new(),
4081 None,
4082 crate::args::RpcMessageUpdates::default(),
4083 tokio::io::BufReader::new(&b""[..]),
4084 output.clone(),
4085 )
4086 .await;
4087 assert_eq!(code, 0);
4088 let messages = output.messages();
4089 assert_eq!(
4090 messages.len(),
4091 102,
4092 "ready plus every queued event must be flushed"
4093 );
4094 assert_eq!(messages.last().unwrap()["type"], "agent_end");
4095 }
4096
4097 fn assert_rpc_update_mode(
4098 messages: &[serde_json::Value],
4099 mode: crate::args::RpcMessageUpdates,
4100 ) {
4101 let updates: Vec<_> = messages
4102 .iter()
4103 .filter(|m| {
4104 matches!(
4105 m["eventType"].as_str(),
4106 Some("text_delta" | "thinking_delta" | "toolcall_delta")
4107 )
4108 })
4109 .collect();
4110 assert!(
4111 !updates.is_empty(),
4112 "expected streaming deltas: {messages:?}"
4113 );
4114 for update in updates {
4115 let snapshot = mode == crate::args::RpcMessageUpdates::Snapshot;
4116 assert_eq!(update.get("message").is_some(), snapshot, "{update}");
4117 assert_eq!(
4118 update["assistantMessageEvent"].get("partial").is_some(),
4119 snapshot,
4120 "{update}"
4121 );
4122 assert_eq!(update["eventType"], update["assistantMessageEvent"]["type"]);
4123 assert_eq!(
4124 update["contentIndex"],
4125 update["assistantMessageEvent"]["contentIndex"]
4126 );
4127 assert_eq!(update["delta"], update["assistantMessageEvent"]["delta"]);
4128 }
4129 }
4130
4131 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4132 async fn rpc_selected_updates_survive_prompt_eof_and_stop() {
4133 use tokio::io::AsyncWriteExt;
4134 for mode in [
4135 crate::args::RpcMessageUpdates::CompactDeltas,
4136 crate::args::RpcMessageUpdates::Snapshot,
4137 ] {
4138 for stop in [false, true] {
4139 let (harness, _) =
4140 rpc_test_harness("one two three four five six seven eight", 100.0).await;
4141 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
4142 harness
4143 .set_agent_emitter(Some(Arc::new(emitter)))
4144 .await
4145 .unwrap();
4146 let (mut client, input) = tokio::io::duplex(4096);
4147 let output = RpcOutput::default();
4148 let captured = output.clone();
4149 let task = tokio::spawn(async move {
4150 rpc_with_io(
4151 &harness,
4152 Some(events),
4153 vec![],
4154 rpi_extensions::UiDialogMailbox::new(),
4155 None,
4156 mode,
4157 tokio::io::BufReader::new(input),
4158 captured,
4159 )
4160 .await
4161 });
4162 let ready = output.wait_for(|m| m["type"] == "ready").await;
4163 assert_eq!(ready["semantics"]["messageUpdates"], mode.as_str());
4164 client
4165 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
4166 .await
4167 .unwrap();
4168 if stop {
4169 output.wait_for(|m| m["eventType"] == "text_delta").await;
4170 client
4171 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
4172 .await
4173 .unwrap();
4174 } else {
4175 client.shutdown().await.unwrap();
4176 }
4177 assert_eq!(
4178 tokio::time::timeout(std::time::Duration::from_secs(3), task)
4179 .await
4180 .unwrap()
4181 .unwrap(),
4182 0
4183 );
4184 let messages = output.messages();
4185 assert_rpc_update_mode(&messages, mode);
4186 assert!(messages.iter().any(|m| m["type"] == "message_end"));
4187 assert!(messages.iter().any(|m| m["type"] == "agent_end"));
4188 assert!(messages.iter().any(|m| m["type"] == "agent_settled"));
4189 if !stop {
4190 assert!(messages
4191 .iter()
4192 .any(|m| m["type"] == "agent_settled" && m["outcome"] == "completed"));
4193 }
4194 }
4195 }
4196 }
4197
4198 #[tokio::test]
4199 async fn rpc_normal_and_explicit_drain_use_the_same_immutable_writer_mode() {
4200 for mode in [
4201 crate::args::RpcMessageUpdates::CompactDeltas,
4202 crate::args::RpcMessageUpdates::Snapshot,
4203 ] {
4204 let output = RpcOutput::default();
4205 let (writer, task, _) = start_rpc_writer(output.clone(), mode);
4206 let cloned_writer = writer.clone();
4207 let mut message = rpi_ai::AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "m", 42);
4208 message.content = vec![rpi_ai::Content::text("snapshot")];
4209 let message = Arc::new(message);
4210 let (tx, mut rx) = tokio::sync::broadcast::channel(16);
4211 for kind in 0..3 {
4212 let assistant_message_event = match kind {
4213 0 => rpi_ai::AssistantMessageEvent::TextDelta {
4214 content_index: 0,
4215 delta: "text".into(),
4216 partial: message.clone(),
4217 },
4218 1 => rpi_ai::AssistantMessageEvent::ThinkingDelta {
4219 content_index: 1,
4220 delta: "thought".into(),
4221 partial: message.clone(),
4222 },
4223 _ => rpi_ai::AssistantMessageEvent::ToolCallDelta {
4224 content_index: 2,
4225 delta: "{}".into(),
4226 partial: message.clone(),
4227 },
4228 };
4229 let event = AgentEvent::MessageUpdate {
4230 message_id: None,
4231 message: message.clone(),
4232 assistant_message_event,
4233 };
4234 write_agent_event(&writer, &event).await;
4235 tx.send(event).unwrap();
4236 }
4237 drain_rpc_events(&cloned_writer, &mut rx).await;
4238 drop(cloned_writer);
4239 drop(writer);
4240 task.await.unwrap().unwrap();
4241 let messages = output.messages();
4242 assert_eq!(messages.len(), 6);
4243 assert_eq!(messages[..3], messages[3..]);
4244 assert_rpc_update_mode(&messages, mode);
4245 }
4246 }
4247
4248 #[tokio::test]
4249 async fn rpc_queued_deltas_are_flushed_on_eof_and_stop_in_selected_mode() {
4250 for mode in [
4251 crate::args::RpcMessageUpdates::CompactDeltas,
4252 crate::args::RpcMessageUpdates::Snapshot,
4253 ] {
4254 for input in ["", "{\"type\":\"stop\",\"id\":1}\n"] {
4255 let harness = AgentHarness::create(Default::default()).await.unwrap();
4256 let (tx, rx) = tokio::sync::broadcast::channel(256);
4257 let mut message =
4258 rpi_ai::AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "m", 42);
4259 message.content = vec![rpi_ai::Content::text("snapshot")];
4260 let message = Arc::new(message);
4261 for _ in 0..100 {
4262 tx.send(AgentEvent::MessageUpdate {
4263 message_id: None,
4264 message: message.clone(),
4265 assistant_message_event: rpi_ai::AssistantMessageEvent::TextDelta {
4266 content_index: 0,
4267 delta: "x".into(),
4268 partial: message.clone(),
4269 },
4270 })
4271 .unwrap();
4272 }
4273 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
4274 let output = RpcOutput::default();
4275 assert_eq!(
4276 rpc_with_io(
4277 &harness,
4278 Some(rx),
4279 vec![],
4280 rpi_extensions::UiDialogMailbox::new(),
4281 None,
4282 mode,
4283 tokio::io::BufReader::new(input.as_bytes()),
4284 output.clone()
4285 )
4286 .await,
4287 0
4288 );
4289 let messages = output.messages();
4290 assert_eq!(
4291 messages
4292 .iter()
4293 .filter(|m| m["eventType"] == "text_delta")
4294 .count(),
4295 100
4296 );
4297 assert_eq!(
4298 messages.iter().find(|m| m["type"] == "ready").unwrap()["semantics"]
4299 ["messageUpdates"],
4300 mode.as_str()
4301 );
4302 assert_rpc_update_mode(&messages, mode);
4303 assert!(messages.iter().any(|m| m["type"] == "agent_end"));
4304 }
4305 }
4306 }
4307
4308 struct BackpressuredOutput {
4309 entered: Arc<std::sync::atomic::AtomicBool>,
4310 release: Arc<std::sync::atomic::AtomicBool>,
4311 output: RpcOutput,
4312 }
4313
4314 impl Write for BackpressuredOutput {
4315 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
4316 self.entered
4317 .store(true, std::sync::atomic::Ordering::SeqCst);
4318 while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
4319 std::thread::sleep(std::time::Duration::from_millis(1));
4320 }
4321 self.output.write(bytes)
4322 }
4323 fn flush(&mut self) -> std::io::Result<()> {
4324 Ok(())
4325 }
4326 }
4327
4328 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4329 async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
4330 use std::sync::atomic::{AtomicBool, Ordering};
4331 let release = Arc::new(AtomicBool::new(false));
4332 struct ReleaseOnDrop(Arc<AtomicBool>);
4333 impl Drop for ReleaseOnDrop {
4334 fn drop(&mut self) {
4335 self.0.store(true, Ordering::SeqCst);
4336 }
4337 }
4338 let _release = ReleaseOnDrop(release.clone());
4339 let entered = Arc::new(AtomicBool::new(false));
4340 let output = RpcOutput::default();
4341 let (writer, task, _) = start_rpc_writer(
4342 BackpressuredOutput {
4343 entered: entered.clone(),
4344 release: release.clone(),
4345 output: output.clone(),
4346 },
4347 crate::args::RpcMessageUpdates::default(),
4348 );
4349 write_line(&writer, &serde_json::json!({"id": 0})).await;
4350 tokio::time::timeout(std::time::Duration::from_secs(2), async {
4351 while !entered.load(Ordering::SeqCst) {
4352 tokio::task::yield_now().await;
4353 }
4354 })
4355 .await
4356 .unwrap();
4357 let producer_writer = writer.clone();
4358 let producer = tokio::spawn(async move {
4359 for id in 1..=100 {
4360 write_line(&producer_writer, &serde_json::json!({"id": id})).await;
4361 }
4362 });
4363 tokio::time::timeout(
4364 std::time::Duration::from_millis(150),
4365 tokio::time::sleep(std::time::Duration::from_millis(20)),
4366 )
4367 .await
4368 .unwrap();
4369 assert!(
4370 !producer.is_finished(),
4371 "the bounded queue must apply backpressure"
4372 );
4373 release.store(true, Ordering::SeqCst);
4374 producer.await.unwrap();
4375 drop(writer);
4376 task.await.unwrap().unwrap();
4377 let messages = output.messages();
4378 assert_eq!(messages.len(), 101);
4379 for (index, message) in messages.iter().enumerate() {
4380 assert_eq!(message["id"], index);
4381 }
4382 }
4383
4384 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4385 async fn rpc_completion_response_follows_final_event_under_backpressure() {
4386 use std::sync::atomic::{AtomicBool, Ordering};
4387 let (harness, _) = rpc_test_harness("completed", 0.0).await;
4388 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
4389 harness
4390 .set_agent_emitter(Some(Arc::new(emitter)))
4391 .await
4392 .unwrap();
4393 let output = RpcOutput::default();
4394 let release = Arc::new(AtomicBool::new(false));
4395 let release_writer = release.clone();
4396 let release_thread = std::thread::spawn(move || {
4397 std::thread::sleep(std::time::Duration::from_millis(50));
4398 release_writer.store(true, Ordering::SeqCst);
4399 });
4400 let code = rpc_with_io(
4401 &harness,
4402 Some(events),
4403 vec![],
4404 rpi_extensions::UiDialogMailbox::new(),
4405 None,
4406 crate::args::RpcMessageUpdates::default(),
4407 tokio::io::BufReader::new(
4408 &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
4409 ),
4410 BackpressuredOutput {
4411 entered: Arc::new(AtomicBool::new(false)),
4412 release,
4413 output: output.clone(),
4414 },
4415 )
4416 .await;
4417 release_thread.join().unwrap();
4418 assert_eq!(code, 0);
4419 let messages = output.messages();
4420 let end = messages
4421 .iter()
4422 .position(|message| message["type"] == "agent_end")
4423 .unwrap();
4424 let settled = messages
4425 .iter()
4426 .position(|message| message["type"] == "agent_settled")
4427 .unwrap();
4428 assert!(end < settled);
4429 let run_id = messages[settled]["runId"].as_str().unwrap();
4430 assert!(!run_id.is_empty(), "settlement identifies its durable run");
4431 let records = harness
4432 .session()
4433 .find_records(&Default::default())
4434 .await
4435 .unwrap();
4436 assert!(records.iter().any(|record| matches!(record,
4437 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
4438 if finished.run_id == run_id)));
4439 assert!(messages.iter().any(|message| {
4440 message["type"] == "response" && message["data"]["accepted"] == true
4441 }));
4442 assert!(messages.iter().any(|message| {
4443 message["type"] == "agent_settled" && message["outcome"] == "completed"
4444 }));
4445 }
4446
4447 #[tokio::test]
4448 async fn rpc_output_failure_closes_without_waiting_for_more_input() {
4449 struct BrokenOutput;
4450 impl Write for BrokenOutput {
4451 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
4452 Err(std::io::Error::new(
4453 std::io::ErrorKind::BrokenPipe,
4454 "closed client output",
4455 ))
4456 }
4457 fn flush(&mut self) -> std::io::Result<()> {
4458 Ok(())
4459 }
4460 }
4461 let harness = AgentHarness::create(Default::default()).await.unwrap();
4462 let (_client, input) = tokio::io::duplex(4096);
4463 let ui = rpi_extensions::UiDialogMailbox::new();
4464 let code = tokio::time::timeout(
4465 std::time::Duration::from_secs(2),
4466 rpc_with_io(
4467 &harness,
4468 None,
4469 vec![],
4470 ui.clone(),
4471 None,
4472 crate::args::RpcMessageUpdates::default(),
4473 tokio::io::BufReader::new(input),
4474 BrokenOutput,
4475 ),
4476 )
4477 .await
4478 .unwrap();
4479 assert_eq!(code, 1);
4480 assert!(!ui.is_attached());
4481 }
4482
4483 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4484 async fn rpc_stop_aborts_and_joins_active_prompt() {
4485 use tokio::io::AsyncWriteExt;
4486 let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
4487 let lane = harness.lane("main");
4488 let (mut client, output, task) =
4489 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
4490 client
4491 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
4492 .await
4493 .unwrap();
4494 tokio::time::timeout(std::time::Duration::from_secs(3), async {
4495 while provider
4496 .state()
4497 .call_count
4498 .load(std::sync::atomic::Ordering::SeqCst)
4499 == 0
4500 {
4501 tokio::task::yield_now().await;
4502 }
4503 })
4504 .await
4505 .unwrap();
4506 client
4507 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
4508 .await
4509 .unwrap();
4510 assert_eq!(
4511 tokio::time::timeout(std::time::Duration::from_secs(3), task)
4512 .await
4513 .unwrap()
4514 .unwrap(),
4515 0
4516 );
4517 let messages = output.messages();
4518 assert!(messages.iter().any(|message| {
4519 message["type"] == "agent_settled"
4520 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
4521 }));
4522 assert_eq!(
4523 messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
4524 true
4525 );
4526 assert!(
4527 lane.abort().await.is_err(),
4528 "RPC returned with an active lane"
4529 );
4530 }
4531
4532 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4533 async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
4534 use tokio::io::AsyncWriteExt;
4535 let (harness, _) = rpc_test_harness("not completed", 20.0).await;
4536 let lane = harness.lane("main");
4537 let (mut client, output, task) =
4538 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
4539 client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
4540 assert_eq!(
4541 tokio::time::timeout(std::time::Duration::from_secs(3), task)
4542 .await
4543 .unwrap()
4544 .unwrap(),
4545 0
4546 );
4547 let messages = output.messages();
4548 let response = messages
4549 .iter()
4550 .find(|message| message["type"] == "response" && message["id"] == 1)
4551 .expect("the prompt has one correlated admission outcome");
4552 assert_eq!(
4553 messages
4554 .iter()
4555 .filter(|message| { message["type"] == "response" && message["id"] == 1 })
4556 .count(),
4557 1
4558 );
4559 if response["success"] == true {
4560 assert_eq!(response["data"]["accepted"], true);
4561 assert!(messages.iter().any(|message| {
4562 message["type"] == "agent_settled"
4563 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
4564 }));
4565 } else {
4566 assert_eq!(
4569 response["error"].as_str(),
4570 Some("Operation aborted during prompt validation"),
4571 "{response}"
4572 );
4573 assert!(!messages
4574 .iter()
4575 .any(|message| message["type"] == "agent_start"));
4576 }
4577 assert!(messages.iter().any(|message| message["type"] == "response"
4578 && message["id"] == 2
4579 && message["success"] == true));
4580 assert!(lane.abort().await.is_err());
4581 }
4582
4583 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4584 async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
4585 use tokio::io::AsyncWriteExt;
4586 let (harness, _) =
4587 rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
4588 let ui = rpi_extensions::UiDialogMailbox::new();
4589 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
4590 output.wait_for(|m| m["type"] == "ready").await;
4591 client
4592 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
4593 .await
4594 .unwrap();
4595 ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
4596 .unwrap();
4597 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
4598 client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
4599 output.wait_for(|m| m["id"] == 2).await;
4600 assert_eq!(ui.poll("approval").unwrap()["answer"], true);
4601 output
4602 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
4603 .await;
4604 client.shutdown().await.unwrap();
4605 assert_eq!(
4606 tokio::time::timeout(std::time::Duration::from_secs(3), task)
4607 .await
4608 .unwrap()
4609 .unwrap(),
4610 0
4611 );
4612 assert!(output.messages().iter().any(|message| {
4613 message["type"] == "agent_settled" && message["outcome"] == "completed"
4614 }));
4615 }
4616
4617 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4618 async fn rpc_notifies_client_when_an_approval_is_cancelled() {
4619 use tokio::io::AsyncWriteExt;
4620 let harness = AgentHarness::create(Default::default()).await.unwrap();
4621 let ui = rpi_extensions::UiDialogMailbox::new();
4622 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
4623 output.wait_for(|m| m["type"] == "ready").await;
4624 ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
4625 .unwrap();
4626 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
4627 ui.cancel_tool_call("c");
4628 output
4629 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
4630 .await;
4631 assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
4633 client.shutdown().await.unwrap();
4634 assert_eq!(task.await.unwrap(), 0);
4635 }
4636
4637 #[tokio::test]
4638 async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
4639 use crate::remote::protocol::RemoteCommand;
4640 use tokio::io::AsyncWriteExt;
4641 let harness = AgentHarness::create(Default::default()).await.unwrap();
4642 let ui = rpi_extensions::UiDialogMailbox::new();
4643 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
4644 output.wait_for(|m| m["type"] == "ready").await;
4645 for (request_id, answer, response_id) in [
4646 (
4647 "answered",
4648 Some(serde_json::json!({"values":["No"],"kind":"selector"})),
4649 5,
4650 ),
4651 ("cancelled", None, 6),
4652 ] {
4653 ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
4654 .unwrap();
4655 output
4656 .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
4657 .await;
4658 let command = RemoteCommand::UiDialogResponse {
4659 request_id: request_id.into(),
4660 cancelled: answer.is_none(),
4661 answer,
4662 }
4663 .with_id(response_id);
4664 client
4665 .write_all(format!("{command}\n").as_bytes())
4666 .await
4667 .unwrap();
4668 let ack = output.wait_for(|m| m["id"] == response_id).await;
4669 assert_eq!(ack["data"]["accepted"], true);
4670 let result = ui.poll(request_id).unwrap();
4671 if request_id == "answered" {
4672 assert_eq!(result["answer"]["values"][0], "No");
4673 } else {
4674 assert_eq!(result["status"], "cancelled");
4675 }
4676 }
4677 client.shutdown().await.unwrap();
4678 assert_eq!(task.await.unwrap(), 0);
4679 }
4680
4681 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
4682 AssistantMessage {
4683 role: rpi_ai::types::AssistantRole,
4684 content: vec![Content::Text(TextContent {
4685 kind: TextContentType,
4686 text: text.into(),
4687 text_signature: None,
4688 })],
4689 api: rpi_ai::Api::AnthropicMessages,
4690 provider: "anthropic".into(),
4691 model: "claude-sonnet-5".into(),
4692 response_model: None,
4693 response_id: None,
4694 usage: Usage::zero(),
4695 stop_reason: stop,
4696 deferred: None,
4697 error_message: None,
4698 raw_stop_reason: None,
4699 end_turn: None,
4700 timestamp: 0,
4701 }
4702 }
4703
4704 #[test]
4705 fn assistant_text_concatenates_text_blocks() {
4706 let m = assistant("hello", StopReason::Stop);
4707 assert_eq!(assistant_text(&m), "hello");
4708 }
4709
4710 #[test]
4711 fn outcome_exit_code_maps_failed_aborted_to_1() {
4712 let failed = HarnessRunOutcome::Failed {
4713 leaf_id: "l".into(),
4714 error: OperationError {
4715 code: "boom".into(),
4716 message: "boom".into(),
4717 },
4718 final_entry_id: None,
4719 final_message: None,
4720 };
4721 assert_eq!(outcome_exit_code(&failed), 1);
4722 let completed = HarnessRunOutcome::Completed {
4723 leaf_id: "l".into(),
4724 final_entry_id: "e".into(),
4725 final_message: assistant("ok", StopReason::Stop),
4726 };
4727 assert_eq!(outcome_exit_code(&completed), 0);
4728 }
4729
4730 #[test]
4731 fn run_end_outcome_str_roundtrip() {
4732 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
4733 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
4734 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
4735 }
4736
4737 #[test]
4738 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
4739 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
4740 assert_eq!(end["type"], "agent_end");
4741 assert_eq!(end["messages"], serde_json::json!([]));
4742
4743 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
4744 tool_call_id: "call-1".into(),
4745 tool_name: "read".into(),
4746 result: AgentToolResult::text("hello"),
4747 is_error: false,
4748 });
4749 assert_eq!(tool["type"], "tool_execution_end");
4750 assert_eq!(tool["result"]["content"][0]["text"], "hello");
4751 assert_eq!(tool["result"]["terminate"], false);
4752
4753 let retry = agent_event_json(&AgentEvent::RetryScheduled {
4754 attempt: 3,
4755 max_retries: 10,
4756 delay_ms: 8_000,
4757 error: "503 service unavailable".into(),
4758 });
4759 assert_eq!(retry["type"], "retry_scheduled");
4760 assert_eq!(retry["attempt"], 3);
4761 assert_eq!(retry["maxRetries"], 10);
4762 assert_eq!(retry["delayMs"], 8_000);
4763 assert_eq!(retry["error"], "503 service unavailable");
4764 }
4765}
4766
4767#[cfg(test)]
4768mod page_window_tests {
4769 use super::page_window;
4770
4771 #[test]
4772 fn tail_is_kept_without_before_id() {
4773 assert_eq!(page_window(12, 5, None), (7..12, true));
4775 assert_eq!(page_window(3, 5, None), (0..3, false));
4777 }
4778
4779 #[test]
4780 fn before_id_opens_the_window_immediately_before_it() {
4781 assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
4783 assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
4785 }
4786
4787 #[test]
4788 fn limit_larger_than_the_branch_never_underflows() {
4789 assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
4790 assert_eq!(page_window(5, 100, None), (0..5, false));
4791 }
4792}
4793
4794#[cfg(test)]
4795mod session_storage_notice_tests {
4796 use super::session_storage_notice;
4797
4798 #[test]
4802 fn the_headless_notice_does_not_promise_an_unavailable_recourse() {
4803 let notice = session_storage_notice("disk full");
4804 assert!(notice.contains("disk full"), "{notice}");
4805 assert!(
4806 !notice.contains("/export"),
4807 "print mode has no interactive /export: {notice}"
4808 );
4809 assert!(
4810 notice.contains("print mode exits"),
4811 "the notice must say the turn is not coming back: {notice}"
4812 );
4813 }
4814}