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 last_msg: Option<AssistantMessage> = None;
73
74 let mut prompts: Vec<String> = Vec::new();
77 if let Some(init) = initial {
78 prompts.push(init);
79 }
80 for m in extra_messages {
81 prompts.push(m.clone());
82 }
83
84 if prompts.is_empty() {
85 return 0;
87 }
88
89 let mut images = initial_images;
90 for prompt in prompts {
91 let outcome = lane.prompt_text(&prompt, std::mem::take(&mut images)).await;
92 for failure in lane.take_write_failures() {
96 eprintln!(
97 "session not saved: could not persist {} ({})",
98 failure.what, failure.message
99 );
100 }
101 match outcome {
102 Ok(result) => {
103 last_exit = outcome_exit_code(&result.outcome);
104 match &result.outcome {
105 HarnessRunOutcome::Completed { final_message, .. }
106 | HarnessRunOutcome::Aborted { final_message, .. } => {
107 last_msg = Some(final_message.clone());
108 }
109 HarnessRunOutcome::Failed {
110 error,
111 final_message,
112 ..
113 } => {
114 if let Some(m) = final_message {
115 if m.stop_reason == StopReason::Error {
116 if let Some(em) = &m.error_message {
117 eprintln!("{em}");
118 }
119 }
120 }
121 if error.code == "SessionStorage" {
122 eprintln!("{}", session_storage_notice(&error.message));
123 } else {
124 eprintln!("run failed: {error:?}");
125 }
126 }
127 HarnessRunOutcome::Suspended { .. } => {
128 eprintln!("run suspended (deferred) — resume is not supported in v1");
129 last_exit = 1;
130 }
131 }
132 }
133 Err(e) => {
134 eprintln!("prompt rejected: {e}");
135 return 1;
136 }
137 }
138 }
139
140 if let Some(m) = &last_msg {
142 match m.stop_reason {
143 StopReason::Error => {
144 if let Some(em) = &m.error_message {
145 eprintln!("{em}");
146 }
147 last_exit = 1;
148 }
149 StopReason::Aborted => {
150 eprintln!("request aborted");
151 last_exit = 1;
152 }
153 _ => {
154 let text = assistant_text(m);
155 let mut out = std::io::stdout();
156 let _ = out.write_all(text.as_bytes());
157 if !text.ends_with('\n') {
158 let _ = out.write_all(b"\n");
159 }
160 let _ = out.flush();
161 }
162 }
163 }
164
165 last_exit
166}
167
168pub async fn json(
172 harness: &AgentHarness,
173 _args: &Args,
174 initial: Option<String>,
175 extra_messages: &[String],
176 initial_images: Vec<ImageContent>,
177 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
178) -> i32 {
179 let lane: Arc<dyn AgentLane> = harness.lane("main");
180 let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
181 let collected_for_watch = collected.clone();
182
183 let mut watch = harness.events().watch(|| ());
186 watch.start(Arc::new(move |event: &HarnessEvent| {
187 emit_json_event(event);
189 collected_for_watch.lock().unwrap().push(event.clone());
190 }));
191 std::mem::forget(watch);
194
195 let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
198 let agent_event_task = agent_events.take().map(|mut rx| {
199 tokio::spawn(async move {
200 let done_tx = agent_done_tx;
201 loop {
202 match rx.recv().await {
203 Ok(event) => {
204 let terminal = event.is_terminal();
205 emit_agent_event(&event);
206 if terminal {
207 let _ = done_tx.send(());
208 }
209 }
210 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
211 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
212 }
213 }
214 })
215 });
216
217 let mut prompts: Vec<String> = Vec::new();
218 if let Some(init) = initial {
219 prompts.push(init);
220 }
221 for m in extra_messages {
222 prompts.push(m.clone());
223 }
224
225 let mut last_exit = 0;
226 let mut final_outcome: Option<HarnessRunOutcome> = None;
227
228 let mut images = initial_images;
229 for prompt in prompts {
230 match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
231 Ok(result) => {
232 let _ = tokio::time::timeout(
238 std::time::Duration::from_millis(250),
239 agent_done_rx.recv(),
240 )
241 .await;
242 last_exit = outcome_exit_code(&result.outcome);
243 final_outcome = Some(result.outcome);
244 }
245 Err(e) => {
246 let line = serde_json::json!({
248 "type": "error",
249 "error": e.to_string(),
250 });
251 println!("{line}");
252 if let Some(task) = agent_event_task {
253 task.abort();
254 }
255 return 1;
256 }
257 }
258 }
259
260 let (outcome_str, final_text) = match final_outcome {
262 Some(HarnessRunOutcome::Completed { final_message, .. }) => {
263 ("completed", Some(assistant_text(&final_message)))
264 }
265 Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
266 ("aborted", Some(assistant_text(&final_message)))
267 }
268 Some(HarnessRunOutcome::Failed { final_message, .. }) => {
269 let t = final_message.as_ref().map(assistant_text);
270 ("failed", t)
271 }
272 Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
273 None => ("idle", None),
274 };
275 let result_line = serde_json::json!({
276 "type": "result",
277 "outcome": outcome_str,
278 "finalText": final_text,
279 });
280 println!("{result_line}");
281 if let Some(task) = agent_event_task {
282 task.abort();
283 }
284 last_exit
285}
286
287fn emit_agent_event(event: &AgentEvent) {
288 println!("{}", agent_event_json(event));
289}
290
291pub async fn rpc(
310 harness: &AgentHarness,
311 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
312 model_catalog: Vec<rpi_ai::Model>,
313 ui_dialog: rpi_extensions::UiDialogMailbox,
314 oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
315) -> i32 {
316 rpc_with_io(
317 harness,
318 agent_events.take(),
319 model_catalog,
320 ui_dialog,
321 oauth_snapshot,
322 tokio::io::BufReader::new(tokio::io::stdin()),
323 std::io::stdout(),
324 )
325 .await
326}
327
328async fn rpc_with_io<R, W>(
329 harness: &AgentHarness,
330 mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
331 model_catalog: Vec<rpi_ai::Model>,
332 ui_dialog: rpi_extensions::UiDialogMailbox,
333 oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
334 input: R,
335 output: W,
336) -> i32
337where
338 R: tokio::io::AsyncBufRead + Unpin,
339 W: Write + Send + 'static,
340{
341 let mut command_tasks = Vec::new();
342 let lane: Arc<dyn AgentLane> = harness.lane("main");
343 let (stdout, writer_task, writer_failed) = start_rpc_writer(output);
346 let pump_stop = tokio_util::sync::CancellationToken::new();
347
348 let (commit_tx, mut commit_rx) = tokio::sync::broadcast::channel::<SessionEntryCommit>(256);
352 harness
353 .session()
354 .set_entry_commit_publisher(Some(commit_tx.clone()));
355 let commit_task = {
356 let out = stdout.clone();
357 tokio::spawn(async move {
358 loop {
359 match commit_rx.recv().await {
360 Ok(commit) => {
361 write_line(
362 &out,
363 &serde_json::json!({
364 "type": "session_entry_committed",
365 "entryId": commit.entry_id,
366 "entryType": commit.entry_type,
367 "customType": commit.custom_type,
368 "lane": commit.lane,
369 }),
370 )
371 .await;
372 }
373 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
374 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
375 }
376 }
377 })
378 };
379
380 let (event_flush_tx, mut event_flush_rx) =
383 tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
384 let event_task = agent_events.take().map(|mut rx| {
386 let out = stdout.clone();
387 let stop = pump_stop.clone();
388 tokio::spawn(async move {
389 loop {
390 let received = tokio::select! {
391 _ = stop.cancelled() => {
392 drain_rpc_events(&out, &mut rx).await;
393 break;
394 }
395 Some(done) = event_flush_rx.recv() => {
396 drain_rpc_events(&out, &mut rx).await;
397 let _ = done.send(());
398 continue;
399 }
400 event = rx.recv() => event,
401 };
402 match received {
403 Ok(event) => write_agent_event(&out, &event).await,
404 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
405 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
406 }
407 }
408 })
409 });
410
411 let event_flush = event_task.as_ref().map(|_| event_flush_tx);
412
413 ui_dialog.attach();
419 let dialog_task = {
420 let mailbox = ui_dialog.clone();
421 let out = stdout.clone();
422 let stop = pump_stop.clone();
423 tokio::spawn(async move {
424 let mut shown = std::collections::HashSet::new();
425 while !stop.is_cancelled() {
426 while let Some(request) = mailbox.take_pending() {
427 let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
428 request_id: request.request_id.clone(),
429 tool_call_id: request.tool_call_id,
430 ui: request.ui,
431 };
432 write_line(&out, &serde_json::to_value(event).unwrap()).await;
433 shown.insert(request.request_id);
434 }
435 let closed: Vec<String> = shown
436 .iter()
437 .filter(|id| !mailbox.is_pending(id))
438 .cloned()
439 .collect();
440 for request_id in closed {
441 shown.remove(&request_id);
442 let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
443 write_line(&out, &serde_json::to_value(event).unwrap()).await;
444 }
445 tokio::select! {
446 _ = stop.cancelled() => break,
447 _ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
448 }
449 }
450 })
451 };
452
453 let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
457 let mut run_task: Option<tokio::task::JoinHandle<()>> = None;
458 let mut run_cancel = tokio_util::sync::CancellationToken::new();
459 let mut cancel_on_exit = false;
460
461 use tokio::io::AsyncBufReadExt;
462
463 let mut lines = input.lines();
464 write_line(&stdout, &rpc_protocol::ready()).await;
465
466 loop {
467 let incoming = tokio::select! {
468 _ = writer_failed.cancelled() => {
469 cancel_on_exit = true;
470 break;
471 }
472 line = lines.next_line() => line,
473 };
474 let line = match incoming {
475 Ok(Some(line)) => line,
476 Ok(None) => break, Err(error) => {
478 eprintln!("[rpi] rpc stdin error: {error}");
479 cancel_on_exit = true;
480 break;
481 }
482 };
483 let line = line.trim();
484 if line.is_empty() {
485 continue;
486 }
487 let msg: serde_json::Value = match serde_json::from_str(line) {
488 Ok(value) => value,
489 Err(error) => {
490 write_line(
491 &stdout,
492 &rpc_protocol::response(
493 None,
494 "invalid_request",
495 Err(format!("invalid json: {error}")),
496 ),
497 )
498 .await;
499 continue;
500 }
501 };
502 let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
503 let kind = msg
504 .get("type")
505 .and_then(|v| v.as_str())
506 .unwrap_or("")
507 .to_string();
508
509 if rpc_protocol::SUPPORTED_COMMANDS.contains(&kind.as_str())
510 && serde_json::from_value::<rpc_protocol::Request>(msg.clone()).is_err()
511 {
512 write_line(
513 &stdout,
514 &rpc_protocol::response(
515 Some(id.clone()),
516 &kind,
517 Err("invalid command payload".to_string()),
518 ),
519 )
520 .await;
521 continue;
522 }
523
524 match kind.as_str() {
525 "ui_dialog_response" => {
526 let request_id = msg
527 .get("requestId")
528 .and_then(|v| v.as_str())
529 .unwrap_or("")
530 .to_string();
531 let result = if msg
532 .get("cancelled")
533 .and_then(|v| v.as_bool())
534 .unwrap_or(false)
535 {
536 ui_dialog.cancel(&request_id)
537 } else {
538 let answer = msg
539 .get("answer")
540 .cloned()
541 .unwrap_or(serde_json::Value::Null);
542 ui_dialog.respond(&request_id, answer)
543 };
544 if msg.get("id").is_some() {
545 let response = match result {
546 Ok(()) => rpc_protocol::response(
547 Some(id),
548 "ui_dialog_response",
549 Ok(Some(
550 serde_json::json!({"accepted":true,"requestId":request_id}),
551 )),
552 ),
553 Err(error) => {
554 rpc_protocol::response(Some(id), "ui_dialog_response", Err(error))
555 }
556 };
557 write_line(&stdout, &response).await;
558 } else if let Err(error) = result {
559 eprintln!("[rpi] ui_dialog_response: {error}");
560 }
561 continue;
562 }
563 "execute_command" => {
564 command_tasks.retain(|task: &tokio::task::JoinHandle<()>| !task.is_finished());
565 let snapshot = oauth_snapshot.clone();
566 let request = msg.clone();
567 let out = stdout.clone();
568 command_tasks.push(tokio::spawn(async move {
569 let result = tokio::task::spawn_blocking(move || {
570 rpc_execute_command(snapshot.as_deref(), &request)
571 })
572 .await
573 .unwrap_or_else(|_| Err("command task failed".into()));
574 write_line(
575 &out,
576 &rpc_protocol::response(Some(id), "execute_command", result.map(Some)),
577 )
578 .await;
579 }));
580 continue;
581 }
582 "stop" => {
583 cancel_on_exit = true;
584 write_line(
585 &stdout,
586 &rpc_protocol::response(
587 Some(id.clone()),
588 "stop",
589 Ok(Some(serde_json::json!({"stopped": true}))),
590 ),
591 )
592 .await;
593 break;
594 }
595 "prompt" | "resume_pending" | "compact" => {
596 let resume = kind == "resume_pending";
597 let compact = kind == "compact";
598 if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
599 write_line(
600 &stdout,
601 &rpc_protocol::response(
602 Some(id.clone()),
603 &kind,
604 Err("a run is already active".to_string()),
605 ),
606 )
607 .await;
608 continue;
609 }
610 if let Some(task) = run_task.take() {
611 let _ = task.await;
612 }
613 run_cancel = tokio_util::sync::CancellationToken::new();
614 let cancel_task = run_cancel.clone();
615 if resume && !lane.has_pending_resume().await {
618 run_active.store(false, std::sync::atomic::Ordering::Release);
619 write_line(
620 &stdout,
621 &rpc_protocol::response(
622 Some(id),
623 "resume_pending",
624 Ok(Some(serde_json::json!({"resumed": false}))),
625 ),
626 )
627 .await;
628 continue;
629 }
630 let content = msg
631 .get("message")
632 .or_else(|| msg.get("content"))
633 .or_else(|| msg.get("prompt"))
634 .and_then(|v| v.as_str())
635 .map(str::to_owned);
636 let lane_task = Arc::clone(&lane);
637 let out_task = stdout.clone();
638 let active_task = Arc::clone(&run_active);
639 let mailbox_task = ui_dialog.clone();
640 let flush_events = event_flush.clone();
641 let Some(content) = content.or_else(|| (resume || compact).then(String::new))
642 else {
643 active_task.store(false, std::sync::atomic::Ordering::Release);
644 write_line(
645 &stdout,
646 &rpc_protocol::response(
647 Some(id),
648 "prompt",
649 Err("prompt requires a string `message`".to_string()),
650 ),
651 )
652 .await;
653 continue;
654 };
655 write_line(
656 &stdout,
657 &rpc_protocol::response(
658 Some(id),
659 &kind,
660 Ok(Some(serde_json::json!({"accepted": true}))),
661 ),
662 )
663 .await;
664 let instructions = msg
665 .get("customInstructions")
666 .and_then(|v| v.as_str())
667 .map(str::to_owned);
668 run_task = Some(tokio::spawn(async move {
669 let result = if compact {
670 rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
671 } else if resume {
672 rpc_resume_cancellable(&lane_task, cancel_task).await
673 } else {
674 rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
675 };
676 if let Some(flush) = flush_events {
677 let (done, drained) = tokio::sync::oneshot::channel();
678 if flush.send(done).is_ok() {
679 let _ = drained.await;
680 }
681 }
682 let (outcome, aborted, error) = match &result {
683 Ok(value) => {
684 let outcome = value
685 .get("outcome")
686 .and_then(|v| v.as_str())
687 .unwrap_or("completed")
688 .to_string();
689 let error = value
690 .get("error")
691 .and_then(|v| v.as_str())
692 .map(str::to_owned);
693 (outcome.clone(), outcome == "aborted", error)
694 }
695 Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
696 };
697 let pending_resume = lane_task.has_pending_resume().await;
700 let summary = result
701 .as_ref()
702 .ok()
703 .and_then(|value| value.get("summary"))
704 .cloned();
705 let settled = serde_json::json!({
706 "type": "agent_settled",
707 "runId": result.as_ref().ok().and_then(|value| value.get("runId")),
708 "outcome": outcome,
709 "aborted": aborted,
710 "error": error,
711 "summary": summary,
712 "pendingResume": pending_resume,
713 });
714 write_line(&out_task, &settled).await;
715 mailbox_task.cancel_all();
716 active_task.store(false, std::sync::atomic::Ordering::Release);
717 }));
718 continue;
719 }
720 _ => {}
721 }
722
723 let result = match kind.as_str() {
724 "abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
725 run_cancel.cancel();
727 Ok(serde_json::json!({"aborted": true}))
728 }
729 "abort" => lane
730 .abort()
731 .await
732 .map(|_| serde_json::json!({"aborted": true}))
733 .map_err(|e| e.to_string()),
734 "get_state" => Ok(rpc_get_state(&lane, harness).await),
735 "get_commands" => Ok(rpc_get_commands(oauth_snapshot.as_deref())),
736 "get_messages" => rpc_get_messages(&lane, harness, &msg).await,
737 "get_history" => rpc_get_history(&lane, harness, &msg).await,
738 "get_session_branch" => rpc_get_session_branch(harness, &msg).await,
739 "append_entry" => rpc_append_entry(harness, &msg).await,
740 "get_stats" => rpc_get_stats(&lane, harness).await,
741 "get_queue" => rpc_get_queue(&lane, harness, &msg).await,
742 "queue_message" => rpc_queue_message(&lane, &msg).await,
743 "cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
744 "clear_queue" => rpc_clear_queue(&lane).await,
745 "navigate" => rpc_navigate(&lane, &msg).await,
746 "get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
747 "get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
748 "oauth" => rpc_oauth(&oauth_snapshot, &msg),
749 "set_api_key" => rpc_set_api_key(&msg).await,
750 "set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
751 "get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
752 "set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
753 "get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
754 "set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
755 "ping" => Ok(serde_json::json!({"pong": true})),
756 other => Err(format!("unknown command type: {other}")),
757 };
758
759 let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
760 write_line(&stdout, &response).await;
761 }
762
763 ui_dialog.detach();
767 if cancel_on_exit {
768 run_cancel.cancel();
769 }
770 for task in command_tasks {
771 let _ = task.await;
772 }
773 let mut exit_code = 0;
774 if let Some(task) = run_task {
775 if let Err(error) = task.await {
776 eprintln!("[rpi] rpc prompt task failed: {error}");
777 exit_code = 1;
778 }
779 }
780 pump_stop.cancel();
781 harness.session().set_entry_commit_publisher(None);
782 drop(commit_tx);
785 if let Err(error) = commit_task.await {
786 eprintln!("[rpi] rpc commit event task failed: {error}");
787 exit_code = 1;
788 }
789 let _ = dialog_task.await;
790 if let Some(task) = event_task {
791 let _ = task.await;
792 }
793 drop(stdout);
795 match writer_task.await {
796 Ok(Ok(())) => {}
797 error => {
798 eprintln!("[rpi] rpc output failed: {error:?}");
799 exit_code = 1;
800 }
801 }
802 exit_code
803}
804
805fn rpc_get_commands(snapshot: Option<&rpi_extensions::RegistrySnapshot>) -> serde_json::Value {
806 let commands: Vec<_> = snapshot
807 .into_iter()
808 .flat_map(|s| s.commands())
809 .map(
810 |command| serde_json::json!({"name": command.name, "description": command.description}),
811 )
812 .collect();
813 serde_json::json!({"commands": commands})
814}
815
816fn rpc_execute_command(
817 snapshot: Option<&rpi_extensions::RegistrySnapshot>,
818 request: &serde_json::Value,
819) -> Result<serde_json::Value, String> {
820 let name = request
821 .get("command")
822 .and_then(|v| v.as_str())
823 .filter(|name| !name.trim_start_matches('/').is_empty())
824 .ok_or("command must be a non-empty string")?;
825 let args = match request.get("args") {
826 None => "",
827 Some(value) => value.as_str().ok_or("args must be a string")?,
828 };
829 let command = snapshot
830 .into_iter()
831 .flat_map(|s| s.commands())
832 .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
833 .ok_or_else(|| format!("unknown command: {name}"))?;
834 let input = serde_json::json!({"command": name, "args": args}).to_string();
835 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
836 let mut out = rpi_plugin_sdk::StbString::empty();
837 let rc = (command.handler)(
838 rpi_plugin_sdk::StbStringRef::from_str(&input),
839 &mut out,
840 command.user_data,
841 );
842 let out = unsafe {
845 rpi_plugin_sdk::callbacks::OwnedString::from_raw(
846 out,
847 Some(rpi_extensions::host_free_string),
848 )
849 };
850 if rc != 0 {
851 return Err(format!("command failed: {name} (code {rc})"));
852 }
853 serde_json::from_str(&out.to_string_lossy())
854 .map_err(|_| format!("command returned invalid JSON: {name}"))
855 }))
856 .unwrap_or_else(|_| Err(format!("command panicked: {name}")))
857}
858
859async fn drain_rpc_events(
860 out: &RpcWriter,
861 events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
862) {
863 loop {
864 match events.try_recv() {
865 Ok(event) => write_agent_event(out, &event).await,
866 Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
867 Err(_) => break,
868 }
869 }
870}
871
872async fn rpc_prompt_cancellable(
875 lane: &Arc<dyn AgentLane>,
876 content: &str,
877 cancel: tokio_util::sync::CancellationToken,
878) -> Result<serde_json::Value, String> {
879 let prompt = rpc_prompt_text(lane, content);
880 tokio::pin!(prompt);
881 tokio::select! {
882 biased;
883 _ = cancel.cancelled() => {
884 match lane.abort().await {
885 Ok(_) => prompt.await,
886 Err(_) => Err("Operation aborted".to_string()),
887 }
888 }
889 result = &mut prompt => result,
890 }
891}
892
893async fn rpc_compact_cancellable(
899 lane: &Arc<dyn AgentLane>,
900 instructions: Option<String>,
901 cancel: tokio_util::sync::CancellationToken,
902) -> Result<serde_json::Value, String> {
903 let compact = async {
904 match lane.compact(instructions.as_deref()).await {
905 Ok(result) => Ok(compaction_json(&result)),
906 Err(error) => Err(error.to_string()),
907 }
908 };
909 tokio::pin!(compact);
910 tokio::select! {
911 biased;
912 _ = cancel.cancelled() => {
913 let _ = lane.abort().await;
914 compact.await
915 }
916 result = &mut compact => result,
917 }
918}
919
920fn compaction_json(result: &CompactionResult) -> serde_json::Value {
923 let mut value = match &result.outcome {
924 CompactionOutcome::Completed { leaf_id, entry } => {
925 let (tokens_before, retained) = match entry {
926 rpi_harness::session::types::Entry::Compaction(compaction) => {
927 (compaction.tokens_before, compaction.retained_tail.len())
928 }
929 _ => (0, 0),
930 };
931 serde_json::json!({
932 "outcome": "compacted",
933 "leafId": leaf_id,
934 "tokensBefore": tokens_before,
935 "retainedMessages": retained,
936 "summary": format!(
937 "Conversation compacted · {} tokens before, {} messages kept.",
938 tokens_before, retained
939 ),
940 })
941 }
942 CompactionOutcome::Declined { leaf_id } => serde_json::json!({
943 "outcome": "declined",
944 "leafId": leaf_id,
945 "summary": "Nothing to compact yet.",
946 }),
947 CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
948 "outcome": "aborted",
949 "leafId": leaf_id,
950 "summary": "Compaction aborted.",
951 }),
952 CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
953 "outcome": "failed",
954 "leafId": leaf_id,
955 "error": error.message,
956 "summary": format!("Compaction failed: {}", error.message),
957 }),
958 };
959 value["runId"] = serde_json::json!(result.run_id);
960 value
961}
962
963async fn rpc_resume_cancellable(
968 lane: &Arc<dyn AgentLane>,
969 cancel: tokio_util::sync::CancellationToken,
970) -> Result<serde_json::Value, String> {
971 let resume = async {
972 match lane.resume_pending().await {
973 Ok(Some(result)) => {
974 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
975 Ok(serde_json::json!({
976 "runId": result.run_id,
977 "outcome": outcome,
978 "finalText": final_text,
979 "error": error,
980 "resumed": true,
981 }))
982 }
983 Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
984 Err(error) => Err(error.to_string()),
985 }
986 };
987 tokio::pin!(resume);
988 tokio::select! {
989 biased;
990 _ = cancel.cancelled() => {
991 let _ = lane.abort().await;
992 resume.await
993 }
994 result = &mut resume => result,
995 }
996}
997
998fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
999 match outcome {
1000 HarnessRunOutcome::Completed { final_message, .. } => {
1001 ("completed", Some(assistant_text(final_message)), None)
1002 }
1003 HarnessRunOutcome::Aborted { final_message, .. } => {
1004 ("aborted", Some(assistant_text(final_message)), None)
1005 }
1006 HarnessRunOutcome::Failed {
1009 final_message,
1010 error,
1011 ..
1012 } => (
1013 "failed",
1014 final_message.as_ref().map(assistant_text),
1015 Some(error.message.clone()),
1016 ),
1017 HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
1018 }
1019}
1020
1021async fn rpc_prompt_text(
1022 lane: &Arc<dyn AgentLane>,
1023 content: &str,
1024) -> Result<serde_json::Value, String> {
1025 let result = lane
1026 .prompt_text(content, Vec::new())
1027 .await
1028 .map_err(|e| e.to_string())?;
1029 let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
1030 Ok(
1031 serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
1032 )
1033}
1034
1035async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1036 let model = lane.get_model().await.ok();
1037 let thinking = lane
1038 .get_thinking_level()
1039 .await
1040 .ok()
1041 .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
1042 let active_tools = lane.get_active_tools().await.unwrap_or_default();
1043 let leaf_id = lane.get_leaf_id().await.ok().flatten();
1044 let context = rpc_context_usage(lane, harness).await;
1045 let active_operation = lane
1046 .snapshot()
1047 .await
1048 .ok()
1049 .and_then(|snapshot| snapshot.active)
1050 .map(|active| {
1051 serde_json::json!({
1052 "runId": active.run_id,
1053 "lane": active.lane,
1054 "kind": format!("{:?}", active.kind).to_lowercase(),
1055 })
1056 });
1057 let queued = lane.queued_messages().await.unwrap_or_default();
1058 let stats = rpc_get_stats(lane, harness).await.ok();
1059 serde_json::json!({
1060 "modelId": model.as_ref().map(|model| model.id.clone()),
1061 "model": model.as_ref().map(|model| model.id.clone()),
1062 "provider": model.as_ref().map(|model| model.provider.clone()),
1063 "thinkingLevel": thinking,
1064 "activeTools": active_tools,
1065 "leafId": leaf_id,
1066 "context": context,
1067 "sessionId": harness.session().storage().metadata().id,
1068 "activeOperation": active_operation,
1069 "queue": {
1070 "steering": queued.steering,
1071 "followUp": queued.follow_up,
1072 "steeringCount": queued.steering.len(),
1073 "followUpCount": queued.follow_up.len(),
1074 },
1075 "stats": stats,
1076 })
1077}
1078
1079fn rpc_now_ms() -> i64 {
1080 std::time::SystemTime::now()
1081 .duration_since(std::time::UNIX_EPOCH)
1082 .map(|duration| duration.as_millis() as i64)
1083 .unwrap_or_default()
1084}
1085
1086fn rpc_message_text(value: &serde_json::Value) -> String {
1087 let Some(content) = value.get("content") else {
1088 return String::new();
1089 };
1090 if let Some(text) = content.as_str() {
1091 return text.to_string();
1092 }
1093 content
1094 .as_array()
1095 .into_iter()
1096 .flatten()
1097 .filter_map(|block| block.get("text").and_then(|text| text.as_str()))
1098 .collect::<Vec<_>>()
1099 .join("\n")
1100}
1101
1102async fn rpc_get_history(
1103 lane: &Arc<dyn AgentLane>,
1104 harness: &AgentHarness,
1105 msg: &serde_json::Value,
1106) -> Result<serde_json::Value, String> {
1107 let limit = msg
1108 .get("limit")
1109 .and_then(serde_json::Value::as_u64)
1110 .unwrap_or(100)
1111 .clamp(1, 500) as usize;
1112 let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
1113 let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
1114 let mut records = harness
1115 .session()
1116 .find_records(&RecordQuery {
1117 lane: Some(lane.name().to_string()),
1118 after_seq,
1119 order: Some(EntryOrder::OldestFirst),
1120 ..Default::default()
1121 })
1122 .await
1123 .map_err(|error| error.to_string())?;
1124 if let Some(before) = before_seq {
1125 records.retain(|record| record.seq() < before);
1126 }
1127 let total = records.len();
1128 let has_more = records.len() > limit;
1129 if before_seq.is_some() {
1130 if has_more {
1131 records = records.split_off(records.len() - limit);
1132 }
1133 } else if after_seq.is_some() {
1134 records.truncate(limit);
1135 } else if has_more {
1136 records = records.split_off(records.len() - limit);
1137 }
1138 let first_seq = records.first().map(LaneRecord::seq);
1139 let last_seq = records.last().map(LaneRecord::seq);
1140 let open_operations = harness
1141 .session()
1142 .find_open_operations(lane.name(), Some(50))
1143 .await
1144 .map_err(|error| error.to_string())?;
1145 Ok(serde_json::json!({
1146 "records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1147 "total": total,
1148 "limit": limit,
1149 "afterSeq": after_seq,
1150 "beforeSeq": before_seq,
1151 "firstSeq": first_seq,
1152 "lastSeq": last_seq,
1153 "hasMore": has_more,
1154 "leafId": lane.get_leaf_id().await.ok().flatten(),
1155 "openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
1156 }))
1157}
1158
1159async fn rpc_get_session_branch(
1160 harness: &AgentHarness,
1161 msg: &serde_json::Value,
1162) -> Result<serde_json::Value, String> {
1163 let mut args = msg.clone();
1166 if let Some(args) = args.as_object_mut() {
1167 args.remove("type");
1168 args.remove("id");
1169 }
1170 let request = branch_entry_query(&args)?;
1171 let entries = find_branch_entries(harness, &request).await?;
1172 let entries = entries
1173 .iter()
1174 .map(|entry| serde_json::to_value(entry).map_err(|error| error.to_string()))
1175 .collect::<Result<Vec<_>, _>>()?;
1176 Ok(serde_json::json!({
1177 "entries": entries,
1178 "count": entries.len(),
1179 "filtered": true,
1180 }))
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Eq)]
1184pub(crate) struct BranchEntryQuery {
1185 pub custom_types: Vec<String>,
1186 pub order: EntryOrder,
1187 pub limit: Option<usize>,
1188 pub after_seq: Option<u64>,
1189}
1190
1191impl BranchEntryQuery {
1192 fn entry_queries(&self) -> Vec<EntryQuery> {
1193 let per_query_limit = self.limit;
1196 self.custom_types
1197 .iter()
1198 .map(|custom_type| EntryQuery {
1199 entry_type: Some("custom"),
1200 custom_type: Some(custom_type.clone()),
1201 order: Some(self.order),
1202 limit: per_query_limit,
1203 cursor: self.after_seq.map(|after_seq| EntryCursor { after_seq }),
1204 })
1205 .collect()
1206 }
1207}
1208
1209pub(crate) async fn find_branch_entries(
1210 harness: &AgentHarness,
1211 request: &BranchEntryQuery,
1212) -> Result<Vec<Entry>, String> {
1213 let storage = harness.session().storage();
1217 let leaf_id = storage
1218 .get_lanes()
1219 .await
1220 .map_err(|error| error.to_string())?
1221 .into_iter()
1222 .find(|pointer| pointer.lane == "main")
1223 .ok_or_else(|| "Lane not found: main".to_string())?
1224 .leaf_id;
1225 let Some(start) = leaf_id else {
1226 return Ok(Vec::new());
1227 };
1228 let bounds = BranchBounds {
1229 start: Some(start.clone()),
1230 ..Default::default()
1231 };
1232 let mut entries = Vec::new();
1233 for query in request.entry_queries() {
1234 entries.extend(
1235 storage
1236 .find_entries_on_branch(&query, &bounds, &start)
1237 .await
1238 .map_err(|error| error.to_string())?,
1239 );
1240 }
1241 if request.custom_types.len() > 1 {
1242 let mut seen = HashSet::with_capacity(entries.len());
1243 entries.retain(|entry| seen.insert(entry.id().to_owned()));
1244 match request.order {
1245 EntryOrder::OldestFirst => entries.sort_by_key(Entry::seq),
1246 EntryOrder::NewestFirst => entries.sort_by_key(|entry| Reverse(entry.seq())),
1247 }
1248 if let Some(limit) = request.limit {
1249 entries.truncate(limit);
1250 }
1251 }
1252 Ok(entries)
1253}
1254
1255pub(crate) fn branch_entry_query(msg: &serde_json::Value) -> Result<BranchEntryQuery, String> {
1258 let args = msg
1259 .as_object()
1260 .ok_or("get_session_branch: query must be an object")?;
1261 for key in args.keys() {
1262 if !matches!(key.as_str(), "customTypes" | "afterSeq" | "limit" | "order") {
1263 return Err(format!(
1264 "get_session_branch: unknown query argument `{key}`"
1265 ));
1266 }
1267 }
1268 let values = args
1269 .get("customTypes")
1270 .and_then(serde_json::Value::as_array)
1271 .filter(|values| !values.is_empty())
1272 .ok_or(
1273 "get_session_branch: `customTypes` is required and must be a non-empty string array",
1274 )?;
1275 let mut custom_types = Vec::with_capacity(values.len());
1276 for value in values {
1277 let custom_type = value
1278 .as_str()
1279 .filter(|value| !value.trim().is_empty())
1280 .ok_or("get_session_branch: every `customTypes` item must be a non-empty string")?;
1281 if !custom_types.iter().any(|existing| existing == custom_type) {
1282 custom_types.push(custom_type.to_owned());
1283 }
1284 }
1285 let limit = match msg.get("limit") {
1286 None | Some(serde_json::Value::Null) => None,
1287 Some(value) => {
1288 let limit = value
1289 .as_u64()
1290 .filter(|limit| *limit > 0)
1291 .ok_or("get_session_branch: `limit` must be a positive integer")?;
1292 Some(usize::try_from(limit).map_err(|_| "get_session_branch: `limit` is too large")?)
1293 }
1294 };
1295 let order = match msg.get("order") {
1296 None => EntryOrder::OldestFirst,
1297 Some(serde_json::Value::String(value)) if value == "oldestFirst" => EntryOrder::OldestFirst,
1298 Some(serde_json::Value::String(value)) if value == "newestFirst" => EntryOrder::NewestFirst,
1299 Some(_) => {
1300 return Err("get_session_branch: `order` must be oldestFirst or newestFirst".into())
1301 }
1302 };
1303 let after_seq = match msg.get("afterSeq") {
1304 None | Some(serde_json::Value::Null) => None,
1305 Some(value) => Some(
1306 value
1307 .as_u64()
1308 .ok_or("get_session_branch: `afterSeq` must be a non-negative integer")?,
1309 ),
1310 };
1311 Ok(BranchEntryQuery {
1312 custom_types,
1313 order,
1314 limit,
1315 after_seq,
1316 })
1317}
1318
1319async fn rpc_append_entry(
1320 harness: &AgentHarness,
1321 msg: &serde_json::Value,
1322) -> Result<serde_json::Value, String> {
1323 let custom_type = msg
1324 .get("customType")
1325 .and_then(serde_json::Value::as_str)
1326 .filter(|value| !value.trim().is_empty())
1327 .ok_or_else(|| "append_entry requires a non-empty string `customType`".to_string())?;
1328 let entry_id = harness
1329 .session()
1330 .append_custom_entry(custom_type, msg.get("data").cloned())
1331 .await
1332 .map_err(|error| error.to_string())?;
1333 Ok(serde_json::json!({"entryId": entry_id, "customType": custom_type}))
1334}
1335
1336async fn rpc_get_stats(
1337 lane: &Arc<dyn AgentLane>,
1338 harness: &AgentHarness,
1339) -> Result<serde_json::Value, String> {
1340 let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
1341 let records = harness
1342 .session()
1343 .find_records(&RecordQuery {
1344 lane: Some(lane.name().to_string()),
1345 order: Some(EntryOrder::OldestFirst),
1346 ..Default::default()
1347 })
1348 .await
1349 .map_err(|error| error.to_string())?;
1350 let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
1351 for record in &records {
1352 let kind = match record {
1353 LaneRecord::OperationStarted(started) => {
1354 let key = started.intent.kind();
1355 *operation_counts.entry(key.to_string()).or_default() += 1;
1356 if matches!(
1357 &started.intent,
1358 rpi_harness::session::types::OperationIntent::Run {
1359 resume_data: Some(_),
1360 ..
1361 }
1362 ) {
1363 *operation_counts.entry("resume".to_string()).or_default() += 1;
1364 }
1365 continue;
1366 }
1367 LaneRecord::OperationFinished(_) => "operation_finished",
1368 LaneRecord::RetryPending(_) => "retry_pending",
1369 LaneRecord::Usage(_) => "usage",
1370 _ => continue,
1371 };
1372 *operation_counts.entry(kind.to_string()).or_default() += 1;
1373 }
1374 let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
1375 let model = lane.get_model().await.ok();
1376 let input = stats.cached_tokens + stats.uncached_tokens;
1377 Ok(serde_json::json!({
1378 "messageCount": stats.message_count,
1379 "operationCount": count("operation_finished"),
1380 "retryCount": count("retry_pending"),
1381 "compactionCount": count("compaction"),
1382 "resumeCount": count("resume"),
1383 "operationCounts": operation_counts,
1384 "provider": model.as_ref().map(|value| value.provider.clone()),
1385 "modelId": model.as_ref().map(|value| value.id.clone()),
1386 "cachedTokens": stats.cached_tokens,
1387 "uncachedTokens": stats.uncached_tokens,
1388 "totalTokens": stats.total_tokens,
1389 "costTotal": stats.cost_total,
1390 "cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
1391 }))
1392}
1393
1394async fn rpc_get_queue(
1395 lane: &Arc<dyn AgentLane>,
1396 harness: &AgentHarness,
1397 msg: &serde_json::Value,
1398) -> Result<serde_json::Value, String> {
1399 let include_completed = msg
1400 .get("includeCompleted")
1401 .and_then(serde_json::Value::as_bool)
1402 .unwrap_or(false);
1403 let records = harness
1404 .session()
1405 .find_records(&RecordQuery {
1406 lane: Some(lane.name().to_string()),
1407 order: Some(EntryOrder::OldestFirst),
1408 ..Default::default()
1409 })
1410 .await
1411 .map_err(|error| error.to_string())?;
1412 let mut cancelled = std::collections::HashMap::<String, u64>::new();
1413 for record in &records {
1414 if let LaneRecord::QueueCancelled(record) = record {
1415 cancelled.insert(record.entry_id.clone(), record.base.seq);
1416 }
1417 }
1418 let mut items = Vec::new();
1419 for record in records {
1420 let LaneRecord::QueueEnqueued(record) = record else {
1421 continue;
1422 };
1423 let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
1424 continue;
1425 };
1426 let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
1427 ("cancelled", Some(*seq))
1428 } else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
1429 ("consumed", Some(entry.base().seq))
1430 } else {
1431 ("queued", None)
1432 };
1433 if status != "queued" && !include_completed {
1434 continue;
1435 }
1436 let message = record
1437 .target
1438 .get("message")
1439 .cloned()
1440 .unwrap_or(serde_json::Value::Null);
1441 items.push(serde_json::json!({
1442 "entryId": entry_id,
1443 "queue": record.queue,
1444 "status": status,
1445 "text": rpc_message_text(&message),
1446 "message": message,
1447 "runId": record.run_id,
1448 "seq": record.base.seq,
1449 "completedSeq": completed_seq,
1450 }));
1451 }
1452 Ok(
1453 serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
1454 )
1455}
1456
1457async fn rpc_queue_message(
1458 lane: &Arc<dyn AgentLane>,
1459 msg: &serde_json::Value,
1460) -> Result<serde_json::Value, String> {
1461 let text = msg
1462 .get("message")
1463 .and_then(serde_json::Value::as_str)
1464 .filter(|text| !text.trim().is_empty())
1465 .ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
1466 let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
1467 let queue = msg
1468 .get("deliverAs")
1469 .and_then(serde_json::Value::as_str)
1470 .unwrap_or("followUp");
1471 let result = match queue {
1472 "steer" => lane.steer(message).await,
1473 "followUp" | "follow_up" => lane.follow_up(message).await,
1474 "nextTurn" | "next_run" => lane.next_run(message).await,
1475 other => return Err(format!("unknown queue kind: {other}")),
1476 }
1477 .map_err(|error| error.to_string())?;
1478 Ok(serde_json::json!({
1479 "accepted": true,
1480 "entryId": result.entry_id,
1481 "queue": queue,
1482 }))
1483}
1484
1485async fn rpc_cancel_queued(
1486 lane: &Arc<dyn AgentLane>,
1487 msg: &serde_json::Value,
1488) -> Result<serde_json::Value, String> {
1489 let entry_id = msg
1490 .get("entryId")
1491 .and_then(serde_json::Value::as_str)
1492 .filter(|value| !value.is_empty())
1493 .ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
1494 let result = lane
1495 .cancel_queued(entry_id)
1496 .await
1497 .map_err(|error| error.to_string())?;
1498 Ok(serde_json::json!({
1499 "entryId": entry_id,
1500 "outcome": format!("{:?}", result.outcome).to_lowercase(),
1501 }))
1502}
1503
1504async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
1505 let result = lane
1506 .clear_queue()
1507 .await
1508 .map_err(|error| error.to_string())?;
1509 Ok(serde_json::json!({
1510 "cleared": result.steering.len() + result.follow_up.len(),
1511 "steering": result.steering,
1512 "followUp": result.follow_up,
1513 }))
1514}
1515
1516async fn rpc_navigate(
1517 lane: &Arc<dyn AgentLane>,
1518 msg: &serde_json::Value,
1519) -> Result<serde_json::Value, String> {
1520 let target_id = msg
1521 .get("targetId")
1522 .and_then(serde_json::Value::as_str)
1523 .filter(|value| !value.is_empty());
1524 if lane
1525 .snapshot()
1526 .await
1527 .map_err(|error| error.to_string())?
1528 .active
1529 .is_some()
1530 {
1531 return Err("cannot navigate while an operation is active".to_string());
1532 }
1533 let result = lane
1534 .navigate_tree(target_id, false, None, None)
1535 .await
1536 .map_err(|error| error.to_string())?;
1537 let (outcome, leaf_id, error) = match result.outcome {
1538 rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
1539 ("completed", new_leaf_id, None)
1540 }
1541 rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
1542 ("declined", leaf_id, None)
1543 }
1544 rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
1545 ("aborted", leaf_id, None)
1546 }
1547 rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
1548 ("failed", leaf_id, Some(error.message))
1549 }
1550 };
1551 Ok(serde_json::json!({
1552 "runId": result.run_id,
1553 "outcome": outcome,
1554 "leafId": leaf_id,
1555 "error": error,
1556 }))
1557}
1558
1559async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
1565 use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
1566 use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
1567
1568 let entries = harness
1569 .find_entries_on_branch(
1570 &EntryQuery {
1571 order: Some(EntryOrder::OldestFirst),
1572 ..Default::default()
1573 },
1574 &BranchBounds::default(),
1575 )
1576 .await
1577 .unwrap_or_default();
1578 let estimate = estimate_context_tokens(&build_session_context(&entries).messages);
1579 let window = lane
1580 .get_model()
1581 .await
1582 .ok()
1583 .map(|model| model.context_window)
1584 .unwrap_or(0);
1585 let settings = harness.compaction_settings();
1586 let window = window as i64;
1587 let threshold = window - settings.reserve_tokens;
1588 let percent = if window > 0 {
1589 (estimate.tokens as f64) * 100.0 / (window as f64)
1590 } else {
1591 0.0
1592 };
1593 serde_json::json!({
1594 "tokens": estimate.tokens,
1595 "window": window,
1596 "threshold": threshold,
1597 "percent": (percent * 10.0).round() / 10.0,
1598 "reserveTokens": settings.reserve_tokens,
1599 "keepRecentTokens": settings.keep_recent_tokens,
1600 "compactionEnabled": settings.enabled,
1601 "estimated": estimate.last_usage_index.is_none(),
1604 "trailingTokens": estimate.trailing_tokens,
1605 })
1606}
1607
1608fn page_window(
1611 total: usize,
1612 limit: usize,
1613 before: Option<usize>,
1614) -> (std::ops::Range<usize>, bool) {
1615 match before {
1616 Some(index) => {
1617 let start = index.saturating_sub(limit);
1618 (start..index, start > 0)
1619 }
1620 None => {
1621 let skip = total.saturating_sub(limit);
1622 (skip..total, skip > 0)
1623 }
1624 }
1625}
1626
1627async fn rpc_get_messages(
1631 lane: &Arc<dyn AgentLane>,
1632 harness: &AgentHarness,
1633 msg: &serde_json::Value,
1634) -> Result<serde_json::Value, String> {
1635 use rpi_harness::session::types::{BranchBounds, Entry, EntryOrder, EntryQuery};
1636
1637 const DEFAULT_LIMIT: usize = 400;
1638 let limit = msg
1639 .get("limit")
1640 .and_then(|value| value.as_u64())
1641 .map(|value| value as usize)
1642 .unwrap_or(DEFAULT_LIMIT)
1643 .max(1);
1644 let before_id = msg
1648 .get("beforeId")
1649 .and_then(|value| value.as_str())
1650 .filter(|value| !value.is_empty())
1651 .map(str::to_owned);
1652 let entries = harness
1653 .find_entries_on_branch(
1654 &EntryQuery {
1655 order: Some(EntryOrder::OldestFirst),
1656 ..Default::default()
1657 },
1658 &BranchBounds::default(),
1659 )
1660 .await
1661 .map_err(|error| error.to_string())?;
1662 let mut items: Vec<(String, serde_json::Value)> = Vec::new();
1663 for entry in entries {
1664 match entry {
1665 Entry::Message(entry) => {
1666 let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
1667 value["entryId"] = serde_json::json!(entry.base.id);
1670 items.push((entry.base.id, value));
1671 }
1672 Entry::Compaction(compaction) => items.push((
1676 compaction.base.id.clone(),
1677 serde_json::json!({
1678 "role": "compaction",
1679 "entryId": compaction.base.id,
1680 "tokensBefore": compaction.tokens_before,
1681 "retainedMessages": compaction.retained_tail.len(),
1682 "timestamp": compaction.base.timestamp,
1683 }),
1684 )),
1685 _ => {}
1686 }
1687 }
1688 let total = items.len();
1689 let before_index = match &before_id {
1690 Some(before) => match items.iter().position(|(id, _)| id == before) {
1691 Some(index) => Some(index),
1692 None => return Err(format!("beforeId not found: {before}")),
1693 },
1694 None => None,
1695 };
1696 let (range, has_more) = page_window(total, limit, before_index);
1697 let window = items[range].to_vec();
1698 let first_id = window.first().map(|(id, _)| id.clone());
1699 let last_id = window.last().map(|(id, _)| id.clone());
1700 let messages = window
1701 .iter()
1702 .map(|(_, value)| value.clone())
1703 .collect::<Vec<_>>();
1704 let leaf_id = lane.get_leaf_id().await.ok().flatten();
1707 let pending_resume = lane.has_pending_resume().await;
1708 Ok(serde_json::json!({
1709 "messages": messages,
1710 "total": total,
1711 "truncated": has_more,
1712 "limit": limit,
1713 "firstId": first_id,
1714 "lastId": last_id,
1715 "hasMore": has_more,
1716 "beforeId": before_id,
1717 "leafId": leaf_id,
1718 "pendingResume": pending_resume,
1719 }))
1720}
1721
1722fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
1723 let mut providers =
1724 std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
1725 providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
1726 if let Ok(models) = crate::config::load_models_config() {
1727 providers.extend(models.providers.keys().cloned());
1728 }
1729 providers
1730}
1731
1732fn rpc_get_auth_status(
1733 model_catalog: &[rpi_ai::Model],
1734 oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
1735) -> serde_json::Value {
1736 let store = crate::config::read_auth().unwrap_or_default();
1737 let models = crate::config::load_models_config().unwrap_or_default();
1738 let manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
1739 snapshot
1740 .providers()
1741 .iter()
1742 .filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
1743 .collect()
1744 });
1745 let auth_states = crate::credentials::statuses(&models, &store, &manifests);
1746 let providers = rpc_auth_provider_ids(model_catalog)
1747 .into_iter()
1748 .map(|provider| {
1749 let credential = store.get(&provider);
1750 let state = auth_states.get(&provider).cloned().unwrap_or(
1751 rpi_runtime::credentials::AuthState {
1752 status: rpi_runtime::credentials::AuthStatus::Missing,
1753 source: crate::credentials::CredentialSource::Missing,
1754 },
1755 );
1756 let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
1757 let oauth_available =
1758 oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
1759 serde_json::json!({
1760 "provider": provider,
1761 "ready": state.is_ready(),
1762 "source": state.source.as_json_str(),
1763 "status": state.status,
1764 "oauth": oauth,
1765 "oauthAvailable": oauth_available,
1766 })
1767 })
1768 .collect::<Vec<_>>();
1769 serde_json::json!({"providers": providers})
1770}
1771
1772fn rpc_oauth(
1773 snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
1774 msg: &serde_json::Value,
1775) -> Result<serde_json::Value, String> {
1776 let snapshot = snapshot
1777 .as_ref()
1778 .ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
1779 let provider = msg
1780 .get("provider")
1781 .and_then(serde_json::Value::as_str)
1782 .map(str::trim)
1783 .filter(|value| !value.is_empty())
1784 .ok_or_else(|| "oauth requires a provider".to_string())?;
1785 let action = msg
1786 .get("action")
1787 .and_then(serde_json::Value::as_str)
1788 .map(str::trim)
1789 .filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
1790 .ok_or_else(|| "oauth requires a valid action".to_string())?;
1791 let input = msg
1792 .get("input")
1793 .cloned()
1794 .unwrap_or_else(|| serde_json::json!({}));
1795 let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
1796 .map_err(|_| "OAuth provider request failed".to_string())?;
1797 let response = rpi_extensions::OAuthActionResponse::parse(raw)
1798 .map_err(|_| "OAuth provider returned an invalid response".to_string())?;
1799 match response.kind {
1800 rpi_extensions::OAuthResponseKind::Credential => {
1801 let credential = response
1802 .credential()
1803 .map_err(|_| "OAuth provider returned an invalid credential".to_string())?
1804 .ok_or_else(|| "OAuth provider returned no credential".to_string())?;
1805 crate::config::upsert_oauth_token(
1806 provider,
1807 credential.access,
1808 credential.refresh,
1809 credential.expires_at,
1810 )
1811 .map_err(|_| "OAuth credentials could not be saved".to_string())?;
1812 Ok(serde_json::json!({
1813 "provider": provider,
1814 "status": "authenticated"
1815 }))
1816 }
1817 rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
1818 "provider": provider,
1819 "status": "interaction",
1820 "display": oauth_display(&response.payload)
1821 })),
1822 rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
1823 "provider": provider,
1824 "status": "success"
1825 })),
1826 rpi_extensions::OAuthResponseKind::Error => {
1827 Err("OAuth provider rejected the request".to_string())
1828 }
1829 }
1830}
1831
1832fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
1836 let display = payload.get("display").unwrap_or(payload);
1837 let Some(object) = display.as_object() else {
1838 return serde_json::json!({});
1839 };
1840 let mut safe = serde_json::Map::new();
1841 for key in [
1842 "title",
1843 "url",
1844 "instructions",
1845 "user_code",
1846 "verification_uri",
1847 ] {
1848 if let Some(value) = object.get(key) {
1849 safe.insert(key.to_string(), value.clone());
1850 }
1851 }
1852 serde_json::Value::Object(safe)
1853}
1854
1855async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
1856 let provider = msg
1857 .get("provider")
1858 .and_then(serde_json::Value::as_str)
1859 .map(str::trim)
1860 .filter(|value| !value.is_empty())
1861 .ok_or_else(|| "set_api_key requires a provider".to_string())?;
1862 let api_key = msg
1863 .get("apiKey")
1864 .and_then(serde_json::Value::as_str)
1865 .map(str::trim)
1866 .filter(|value| !value.is_empty())
1867 .ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
1868 crate::config::upsert_credential(
1869 provider,
1870 crate::config::Credential::ApiKey {
1871 key: Some(api_key.to_string()),
1872 env: None,
1873 },
1874 )
1875 .map_err(|error| error.to_string())?;
1876 Ok(serde_json::json!({
1877 "provider": provider,
1878 "ready": true,
1879 "source": "auth.json",
1880 "requiresRestart": true,
1881 }))
1882}
1883
1884fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
1885 let models = catalog
1886 .iter()
1887 .map(crate::rpc::protocol::model_descriptor)
1888 .collect::<Vec<_>>();
1889 serde_json::json!({"models": models})
1890}
1891
1892async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
1893 let levels = lane
1894 .get_model()
1895 .await
1896 .map(|model| model.supported_thinking_levels())
1897 .unwrap_or_default();
1898 serde_json::json!({"levels": levels})
1899}
1900
1901async fn rpc_get_available_tools(
1902 lane: &Arc<dyn AgentLane>,
1903 harness: &AgentHarness,
1904) -> serde_json::Value {
1905 let active = lane.get_active_tools().await.unwrap_or_default();
1906 let tools = harness
1907 .get_tools()
1908 .await
1909 .map(|tools| {
1910 tools
1911 .iter()
1912 .map(|tool| tool.tool.schema().name.clone())
1913 .collect::<Vec<_>>()
1914 })
1915 .unwrap_or_else(|_| active.clone());
1916 serde_json::json!({"tools": tools, "activeTools": active})
1917}
1918
1919async fn rpc_set_model(
1920 lane: &Arc<dyn AgentLane>,
1921 catalog: &[rpi_ai::Model],
1922 msg: &serde_json::Value,
1923) -> Result<serde_json::Value, String> {
1924 let id = msg
1925 .get("modelId")
1926 .or_else(|| msg.get("model"))
1927 .and_then(|v| v.as_str())
1928 .ok_or("set_model requires `modelId`")?;
1929 let provider = msg.get("provider").and_then(|v| v.as_str());
1930 let model = catalog
1931 .iter()
1932 .find(|m| {
1933 m.id.eq_ignore_ascii_case(id)
1934 && provider.map_or(true, |provider| m.provider.eq_ignore_ascii_case(provider))
1935 })
1936 .cloned()
1937 .ok_or_else(|| format!("unknown model: {id}"))?;
1938 lane.set_model(model).await.map_err(|e| e.to_string())?;
1939 Ok(serde_json::json!({"modelId": id, "provider": provider}))
1940}
1941
1942async fn rpc_set_thinking_level(
1943 lane: &Arc<dyn AgentLane>,
1944 msg: &serde_json::Value,
1945) -> Result<serde_json::Value, String> {
1946 let value = msg
1947 .get("level")
1948 .or_else(|| msg.get("thinkingLevel"))
1949 .ok_or("set_thinking_level requires `level`")?;
1950 let level: rpi_ai::types::ThinkingLevel =
1951 serde_json::from_value(value.clone()).map_err(|_| {
1952 "invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
1953 })?;
1954 lane.set_thinking_level(level)
1955 .await
1956 .map_err(|e| e.to_string())?;
1957 Ok(serde_json::json!({"thinkingLevel": level}))
1958}
1959
1960async fn rpc_set_active_tools(
1961 lane: &Arc<dyn AgentLane>,
1962 msg: &serde_json::Value,
1963) -> Result<serde_json::Value, String> {
1964 let tools = msg
1965 .get("tools")
1966 .and_then(|v| v.as_array())
1967 .map(|arr| {
1968 arr.iter()
1969 .filter_map(|v| v.as_str().map(str::to_owned))
1970 .collect::<Vec<_>>()
1971 })
1972 .ok_or("set_active_tools requires a `tools` array")?;
1973 lane.set_active_tools(tools.clone())
1974 .await
1975 .map_err(|e| e.to_string())?;
1976 Ok(serde_json::json!({"activeTools": tools}))
1977}
1978
1979fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
1984 crate::rpc::events::project(event)
1985}
1986
1987fn emit_json_event(event: &HarnessEvent) {
1991 let line = match event {
1992 HarnessEvent::CompactionProgress(e) => {
1993 serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
1994 }
1995 HarnessEvent::RunStart(e) => serde_json::json!({
1996 "type": "run_start",
1997 "lane": e.lane,
1998 "runId": e.run_id,
1999 }),
2000 HarnessEvent::RunEnd(e) => serde_json::json!({
2001 "type": "run_end",
2002 "lane": e.lane,
2003 "runId": e.run_id,
2004 "outcome": run_end_outcome_str(e.outcome),
2005 "leafId": e.leaf_id,
2006 }),
2007 };
2008 println!("{line}");
2009}
2010
2011fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
2012 match o {
2013 RunEndOutcome::Completed => "completed",
2014 RunEndOutcome::Aborted => "aborted",
2015 RunEndOutcome::Failed => "failed",
2016 }
2017}
2018
2019pub async fn interactive(
2028 harness: &AgentHarness,
2029 event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
2030 args: &Args,
2031 model_catalog: Vec<rpi_ai::Model>,
2032 initial: Option<String>,
2033 extra_messages: &[String],
2034 initial_images: Vec<ImageContent>,
2035 theme: Option<&str>,
2036 no_themes: bool,
2037 reload_context: &crate::session::ReloadContext,
2038) -> i32 {
2039 if let Some(reason) = crate::session::dispatch_session_event_async(
2046 reload_context,
2047 rpi_plugin_sdk::EventTag::BeforeTuiStart,
2048 )
2049 .await
2050 {
2051 eprintln!("[rpi] startup vetoed by extension: {reason}");
2052 return crate::app::EXIT_VETOED;
2053 }
2054
2055 let force_tui = std::env::var("RPI_FORCE_TUI")
2057 .map(|v| v == "1")
2058 .unwrap_or(false);
2059 let code = if force_tui || crate::interactive_tui::is_tui_supported() {
2060 crate::interactive_tui::interactive_tui(
2062 harness,
2063 event_rx,
2064 args,
2065 model_catalog,
2066 initial,
2067 extra_messages,
2068 initial_images,
2069 theme,
2070 no_themes,
2071 reload_context,
2072 )
2073 .await
2074 } else {
2075 for notice in &reload_context.startup_notices {
2078 eprintln!("note: {notice}");
2079 }
2080 interactive_repl(harness, args, initial, extra_messages, initial_images).await
2081 };
2082
2083 if let Some(reason) = crate::session::dispatch_session_event_async(
2089 reload_context,
2090 rpi_plugin_sdk::EventTag::SessionShutdown,
2091 )
2092 .await
2093 {
2094 tracing::warn!(
2095 "[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
2096 );
2097 }
2098
2099 code
2100}
2101
2102pub async fn interactive_repl(
2104 harness: &AgentHarness,
2105 #[allow(unused_variables)] args: &Args,
2106 initial: Option<String>,
2107 extra_messages: &[String],
2108 initial_images: Vec<ImageContent>,
2109) -> i32 {
2110 let lane: Arc<dyn AgentLane> = harness.lane("main");
2112 let stdin = std::io::stdin();
2113 let is_tty = stdin.is_terminal();
2114
2115 if is_tty {
2116 println!(
2117 "rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
2118 );
2119 }
2120
2121 let mut prompts: Vec<String> = Vec::new();
2123 if let Some(init) = initial {
2124 prompts.push(init);
2125 }
2126 for m in extra_messages {
2127 prompts.push(m.clone());
2128 }
2129 let mut images = initial_images;
2130 for prompt in prompts {
2131 if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
2132 return code;
2133 }
2134 }
2135
2136 let mut line = String::new();
2138 loop {
2139 if is_tty {
2140 print!("> ");
2141 let _ = std::io::stdout().flush();
2142 }
2143 line.clear();
2144 match stdin.lock().read_line(&mut line) {
2145 Ok(0) => break, Ok(_) => {}
2147 Err(_) => break,
2148 }
2149 let trimmed = line.trim();
2150 if trimmed.is_empty() {
2151 continue;
2152 }
2153 if trimmed == "/exit" || trimmed == "/quit" {
2154 break;
2155 }
2156 if trimmed == "/abort" {
2157 let _ = lane.abort().await;
2158 eprintln!("(aborted)");
2159 continue;
2160 }
2161 if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
2162 return code;
2163 }
2164 }
2165 0
2166}
2167
2168async fn run_one(
2172 lane: &Arc<dyn AgentLane>,
2173 prompt: &str,
2174 images: Vec<ImageContent>,
2175) -> Result<(), i32> {
2176 match lane.prompt_text(prompt, images).await {
2177 Ok(result) => {
2178 match &result.outcome {
2179 HarnessRunOutcome::Completed { final_message, .. }
2180 | HarnessRunOutcome::Aborted { final_message, .. } => {
2181 let text = assistant_text(final_message);
2182 if !text.is_empty() {
2183 println!("{text}");
2184 }
2185 }
2186 HarnessRunOutcome::Failed {
2187 error,
2188 final_message,
2189 ..
2190 } => {
2191 if let Some(m) = final_message {
2192 if let Some(em) = &m.error_message {
2193 eprintln!("error: {em}");
2194 }
2195 }
2196 eprintln!("run failed: {error:?}");
2197 }
2198 HarnessRunOutcome::Suspended { .. } => {
2199 eprintln!("run suspended (deferred) — resume not supported in v1");
2200 }
2201 }
2202 Ok(())
2203 }
2204 Err(e) => {
2205 eprintln!("prompt rejected: {e}");
2206 Err(1)
2207 }
2208 }
2209}
2210
2211fn session_storage_notice(message: &str) -> String {
2218 format!(
2219 "conversation not saved: {message} — the session log is missing this turn \
2220 and print mode exits without writing it"
2221 )
2222}
2223
2224#[cfg(test)]
2225mod tests {
2226 use super::*;
2227 use rpi_agent::events::AgentEvent;
2228 use rpi_agent::types::AgentToolResult;
2229 use rpi_ai::types::{
2230 AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
2231 };
2232 use rpi_harness::session::types::OperationError;
2233
2234 #[test]
2235 fn session_branch_query_requires_canonical_arguments() {
2236 let query = branch_entry_query(&serde_json::json!({
2237 "customTypes": ["alpha", "beta", "alpha"], "afterSeq": 42,
2238 }))
2239 .unwrap();
2240 assert_eq!(query.custom_types, ["alpha", "beta"]);
2241 assert_eq!(query.order, EntryOrder::OldestFirst);
2242 assert_eq!(query.after_seq, Some(42));
2243 assert!(query.entry_queries().iter().all(|q| q.limit.is_none()));
2244 for invalid in [
2245 serde_json::json!({}),
2246 serde_json::json!(null),
2247 serde_json::json!([]),
2248 serde_json::json!({"customTypes": []}),
2249 serde_json::json!({"customTypes": "alpha"}),
2250 serde_json::json!({"customTypes": [""]}),
2251 serde_json::json!({"customTypes": [" \t "]}),
2252 serde_json::json!({"customTypes": ["alpha", 1]}),
2253 serde_json::json!({"customTypes": ["alpha"], "limit": 0}),
2254 serde_json::json!({"customTypes": ["alpha"], "limit": 1.5}),
2255 serde_json::json!({"customTypes": ["alpha"], "order": "sideways"}),
2256 serde_json::json!({"customTypes": ["alpha"], "order": 1}),
2257 serde_json::json!({"customTypes": ["alpha"], "afterSeq": -1}),
2258 ] {
2259 assert!(branch_entry_query(&invalid).is_err(), "{invalid}");
2260 }
2261 }
2262
2263 #[test]
2264 fn session_branch_multi_type_limit_is_pushed_to_each_storage_query() {
2265 for order in ["oldestFirst", "newestFirst"] {
2266 let request = branch_entry_query(&serde_json::json!({
2267 "customTypes": ["alpha", "beta", "alpha"],
2268 "limit": 2, "order": order, "afterSeq": 42,
2269 }))
2270 .unwrap();
2271 let queries = request.entry_queries();
2272 assert_eq!(queries.len(), 2);
2273 for query in queries {
2274 assert_eq!(query.limit, Some(2));
2275 assert_eq!(query.order, Some(request.order));
2276 assert_eq!(query.cursor, Some(EntryCursor { after_seq: 42 }));
2277 }
2278 }
2279 }
2280
2281 #[tokio::test]
2282 async fn session_branch_exact_type_identities_round_trip_rpc_and_actions() {
2283 use rpi_extensions::RuntimeActionHost;
2284 let harness = AgentHarness::create(Default::default()).await.unwrap();
2285 let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
2286 vec![],
2287 std::env::temp_dir(),
2288 tokio::runtime::Handle::current(),
2289 Default::default(),
2290 rpi_extensions::ToolCallContext::default(),
2291 );
2292 crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
2293 let names = ["alpha", " alpha ", "beta", " beta "];
2294 for (index, name) in names.iter().enumerate() {
2295 let args = serde_json::json!({"customType": name, "data": {"n": index}});
2296 if index % 2 == 0 {
2297 host.append_entry(args).await.unwrap();
2298 } else {
2299 rpc_append_entry(&harness, &args).await.unwrap();
2300 }
2301 }
2302 let request = branch_entry_query(&serde_json::json!({
2303 "customTypes": ["alpha", " alpha ", "beta", " beta ", "alpha"],
2304 }))
2305 .unwrap();
2306 assert_eq!(request.custom_types, names);
2307 let mut input = String::new();
2308 let mut expected = Vec::new();
2309 for (index, name) in names.iter().enumerate() {
2310 let args = serde_json::json!({"customTypes": [name]});
2311 let result = host.get_session_branch(args.clone()).await.unwrap();
2312 assert_eq!(result["count"], 1);
2313 assert_eq!(result["entries"][0]["customType"], *name);
2314 assert_eq!(result["entries"][0]["data"]["n"], index);
2315 let mut envelope = args;
2316 envelope["type"] = serde_json::json!("get_session_branch");
2317 envelope["id"] = serde_json::json!(index);
2318 input.push_str(&format!("{envelope}\n"));
2319 expected.push(result);
2320 }
2321 let output = RpcOutput::default();
2322 assert_eq!(
2323 rpc_with_io(
2324 &harness,
2325 None,
2326 vec![],
2327 rpi_extensions::UiDialogMailbox::new(),
2328 None,
2329 tokio::io::BufReader::new(input.as_bytes()),
2330 output.clone(),
2331 )
2332 .await,
2333 0
2334 );
2335 let messages = output.messages();
2336 for (index, expected) in expected.into_iter().enumerate() {
2337 let response = messages
2338 .iter()
2339 .find(|message| message["id"] == index)
2340 .unwrap();
2341 assert_eq!(response["data"], expected);
2342 }
2343 }
2344
2345 #[tokio::test]
2346 async fn session_branch_filters_merge_and_respect_fork_bounds() {
2347 use rpi_harness::session::types::{ForkOptions, ForkPosition, SessionRepo};
2348 use rpi_harness::session::{CounterIdGenerator, FakeClock, InMemorySessionRepo, Session};
2349 let repo = InMemorySessionRepo::new(
2350 Arc::new(FakeClock::new()),
2351 Arc::new(CounterIdGenerator::new()),
2352 );
2353 let storage = repo.create(&Default::default()).await.unwrap();
2354 let session = Session::new(Arc::new(storage), None);
2355 let harness = AgentHarness::create(rpi_harness::types::AgentHarnessOptions {
2356 session,
2357 ..Default::default()
2358 })
2359 .await
2360 .unwrap();
2361 let session = harness.session();
2362 let empty = rpc_get_session_branch(
2363 &harness,
2364 &serde_json::json!({"customTypes":["alpha","beta"]}),
2365 )
2366 .await
2367 .unwrap();
2368 assert_eq!(empty["entries"], serde_json::json!([]));
2369 assert_eq!(empty["filtered"], true);
2370 let mut ids = Vec::new();
2371 let mut fork_target = None;
2372 for (index, kind) in ["alpha", "noise", "beta", "alpha", "noise", "beta"]
2373 .into_iter()
2374 .enumerate()
2375 {
2376 if index == 2 {
2377 fork_target = Some(
2378 session
2379 .append_message(rpi_agent::AgentMessage::User(UserMessage::new(
2380 "fork target",
2381 0,
2382 )))
2383 .await
2384 .unwrap(),
2385 );
2386 }
2387 ids.push(session.append_custom_entry(kind, None).await.unwrap());
2388 }
2389 session
2391 .append_message(rpi_agent::AgentMessage::User(UserMessage::new("noise", 0)))
2392 .await
2393 .unwrap();
2394 session
2395 .append_record(
2396 serde_json::from_value(serde_json::json!({
2397 "type":"usage", "id":"usage-noise", "seq":0, "timestamp":0,
2398 "lane":"main", "usage":Usage::zero(), "cause":"adjustment"
2399 }))
2400 .unwrap(),
2401 )
2402 .await
2403 .unwrap();
2404 let run = |args| {
2405 let harness = &harness;
2406 async move { rpc_get_session_branch(harness, &args).await.unwrap() }
2407 };
2408 let all = run(serde_json::json!({"customTypes":["alpha","beta","alpha"]})).await;
2409 let entry_ids = |value: &serde_json::Value| -> Vec<String> {
2410 value["entries"]
2411 .as_array()
2412 .unwrap()
2413 .iter()
2414 .map(|entry| entry["id"].as_str().unwrap().to_owned())
2415 .collect()
2416 };
2417 assert_eq!(
2418 entry_ids(&all),
2419 vec![
2420 ids[0].clone(),
2421 ids[2].clone(),
2422 ids[3].clone(),
2423 ids[5].clone()
2424 ]
2425 );
2426 assert_eq!(all["count"], 4);
2427 assert_eq!(all["filtered"], true);
2428 let cursor = all["entries"][1]["seq"].as_u64().unwrap();
2429 for (order, expected) in [
2430 (
2431 "oldestFirst",
2432 vec![ids[0].clone(), ids[2].clone(), ids[3].clone()],
2433 ),
2434 (
2435 "newestFirst",
2436 vec![ids[5].clone(), ids[3].clone(), ids[2].clone()],
2437 ),
2438 ] {
2439 let result =
2440 run(serde_json::json!({"customTypes":["alpha","beta"], "order":order, "limit":3}))
2441 .await;
2442 assert_eq!(entry_ids(&result), expected);
2443 let result = run(serde_json::json!({"customTypes":["alpha","beta"], "order":order, "afterSeq":cursor, "limit":1})).await;
2444 assert_eq!(
2445 entry_ids(&result),
2446 vec![if order == "oldestFirst" {
2447 ids[3].clone()
2448 } else {
2449 ids[0].clone()
2450 }]
2451 );
2452 }
2453 let single = run(serde_json::json!({"customTypes":["beta"], "afterSeq":all["entries"][3]["seq"], "order":"newestFirst", "limit":1})).await;
2454 assert_eq!(entry_ids(&single), vec![ids[2].clone()]);
2455
2456 session.move_lane("main", Some(&ids[2])).await.unwrap();
2458 let branch_tip = session.append_custom_entry("alpha", None).await.unwrap();
2459 session.create_lane("other", Some(&ids[5])).await.unwrap();
2460 let branch = run(serde_json::json!({"customTypes":["alpha","beta"]})).await;
2461 assert_eq!(
2462 entry_ids(&branch),
2463 vec![ids[0].clone(), ids[2].clone(), branch_tip]
2464 );
2465 let empty =
2466 run(serde_json::json!({"customTypes":["alpha","beta"], "afterSeq":u64::MAX - 1})).await;
2467 assert!(entry_ids(&empty).is_empty());
2468
2469 let fork = repo
2471 .fork(
2472 &session.storage().metadata(),
2473 &Default::default(),
2474 &ForkOptions::Branch {
2475 entry_id: fork_target,
2476 position: Some(ForkPosition::Before),
2477 },
2478 )
2479 .await
2480 .unwrap();
2481 harness
2482 .set_session(Session::new(Arc::new(fork), None))
2483 .await
2484 .unwrap();
2485 let forked = run(
2486 serde_json::json!({"customTypes":["alpha","beta"], "order":"newestFirst", "limit":2}),
2487 )
2488 .await;
2489 assert_eq!(entry_ids(&forked), vec![ids[0].clone()]);
2490 }
2491
2492 #[tokio::test]
2493 async fn session_branch_rpc_and_action_share_strict_query_behavior() {
2494 use rpi_extensions::RuntimeActionHost;
2495 let harness = AgentHarness::create(Default::default()).await.unwrap();
2496 let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
2497 vec![],
2498 std::env::temp_dir(),
2499 tokio::runtime::Handle::current(),
2500 Default::default(),
2501 rpi_extensions::ToolCallContext::default(),
2502 );
2503 crate::extensions_actions::HarnessActionHost::set_harness(&cell, Arc::new(harness.clone()));
2504 host.append_entry(serde_json::json!({"customType":"alpha", "data":{"n":1}}))
2506 .await
2507 .unwrap();
2508 rpc_append_entry(
2509 &harness,
2510 &serde_json::json!({"customType":"beta", "data":{"n":2}}),
2511 )
2512 .await
2513 .unwrap();
2514 let valid =
2515 serde_json::json!({"customTypes":["alpha","beta"], "limit":1, "order":"newestFirst"});
2516 let expected = host.get_session_branch(valid.clone()).await.unwrap();
2517 assert_eq!(expected["entries"][0]["customType"], "beta");
2518 let mut requests = vec![serde_json::json!({})];
2519 for key in [
2520 "customType",
2521 "custom_type",
2522 "custom_types",
2523 "after_seq",
2524 "unknown",
2525 ] {
2526 let value = if key == "after_seq" {
2527 serde_json::json!(0)
2528 } else {
2529 serde_json::json!("alpha")
2530 };
2531 requests.push(serde_json::json!({key:value.clone()}));
2532 let mut mixed = valid.clone();
2533 mixed[key] = value;
2534 requests.push(mixed);
2535 }
2536 let mut input = String::new();
2537 let mut envelope = valid;
2538 envelope["type"] = serde_json::json!("get_session_branch");
2539 envelope["id"] = serde_json::json!("modern");
2540 input.push_str(&format!("{envelope}\n"));
2541 for (index, args) in requests.iter().enumerate() {
2542 let error = host.get_session_branch(args.clone()).await.unwrap_err();
2543 assert!(error.contains("get_session_branch"), "{error}");
2544 let mut envelope = args.clone();
2545 envelope["type"] = serde_json::json!("get_session_branch");
2546 envelope["id"] = serde_json::json!(index);
2547 assert_eq!(
2548 rpc_get_session_branch(&harness, &envelope)
2549 .await
2550 .unwrap_err(),
2551 error
2552 );
2553 input.push_str(&format!("{envelope}\n"));
2554 }
2555 let output = RpcOutput::default();
2556 assert_eq!(
2557 rpc_with_io(
2558 &harness,
2559 None,
2560 vec![],
2561 rpi_extensions::UiDialogMailbox::new(),
2562 None,
2563 tokio::io::BufReader::new(input.as_bytes()),
2564 output.clone(),
2565 )
2566 .await,
2567 0
2568 );
2569 let messages = output.messages();
2570 assert_eq!(
2571 messages.iter().find(|m| m["id"] == "modern").unwrap()["data"],
2572 expected
2573 );
2574 for index in 0..requests.len() {
2575 let response = messages.iter().find(|m| m["id"] == index).unwrap();
2576 assert_eq!(response["success"], false, "{response}");
2577 }
2578 }
2579
2580 #[tokio::test]
2581 async fn rpc_commands_list_and_execute_registered_handler() {
2582 extern "C" fn echo(
2583 input: rpi_plugin_sdk::StbStringRef,
2584 out: *mut rpi_plugin_sdk::StbString,
2585 _: *mut std::ffi::c_void,
2586 ) -> i32 {
2587 unsafe {
2589 *out = rpi_plugin_sdk::StbString::from_string(input.as_str().to_owned());
2590 }
2591 0
2592 }
2593 let mut registry = rpi_extensions::ExtensionRegistry::new();
2594 registry.register_command(
2595 "echo".into(),
2596 "Echo arguments".into(),
2597 echo,
2598 std::ptr::null_mut(),
2599 );
2600 let harness = AgentHarness::create(Default::default()).await.unwrap();
2601 let output = RpcOutput::default();
2602 let input = concat!(
2603 "{\"id\":1,\"type\":\"get_commands\"}\n",
2604 "{\"id\":2,\"type\":\"execute_command\",\"command\":\"/echo\",\"args\":\"hello world\"}\n",
2605 "{\"id\":3,\"type\":\"execute_command\",\"command\":\"missing\"}\n",
2606 "{\"id\":4,\"type\":\"execute_command\",\"command\":\"echo\",\"args\":42}\n"
2607 );
2608 assert_eq!(
2609 rpc_with_io(
2610 &harness,
2611 None,
2612 vec![],
2613 rpi_extensions::UiDialogMailbox::new(),
2614 Some(Arc::new(registry.snapshot())),
2615 tokio::io::BufReader::new(input.as_bytes()),
2616 output.clone()
2617 )
2618 .await,
2619 0
2620 );
2621 let messages = output.messages();
2622 let response = |id| messages.iter().find(|m| m["id"] == id).unwrap();
2623 assert_eq!(response(1)["data"]["commands"][0]["name"], "echo");
2624 assert_eq!(
2625 response(2)["data"],
2626 serde_json::json!({"command":"/echo", "args":"hello world"})
2627 );
2628 assert_eq!(response(3)["success"], false);
2629 assert_eq!(response(4)["success"], false);
2630 assert_eq!(rpc_get_commands(None), serde_json::json!({"commands":[]}));
2631 }
2632
2633 #[test]
2634 fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
2635 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2636 struct RestoreConfig(Option<std::ffi::OsString>);
2637 impl Drop for RestoreConfig {
2638 fn drop(&mut self) {
2639 match self.0.take() {
2640 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2641 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2642 }
2643 }
2644 }
2645 let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2646 let directory = tempfile::tempdir().unwrap();
2647 std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
2648 std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
2649 "header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
2650 "expired-account": {"api":"openai-completions", "models":[]}
2651 }}).to_string()).unwrap();
2652 std::fs::write(directory.path().join("auth.json"), serde_json::json!({
2653 "expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
2654 }).to_string()).unwrap();
2655 let status = rpc_get_auth_status(&[], None);
2656 let providers = status["providers"].as_array().unwrap();
2657 let header = providers
2658 .iter()
2659 .find(|row| row["provider"] == "header-gateway")
2660 .unwrap();
2661 assert_eq!(header["ready"], true);
2662 assert_eq!(header["source"], "headers");
2663 assert_eq!(header["status"], "ready");
2664 let expired = providers
2665 .iter()
2666 .find(|row| row["provider"] == "expired-account")
2667 .unwrap();
2668 assert_eq!(expired["ready"], false);
2669 assert_eq!(expired["status"], "expired");
2670 assert!(!status.to_string().contains("secret"));
2671 }
2672
2673 #[test]
2674 fn oauth_display_drops_token_like_fields() {
2675 let display = oauth_display(&serde_json::json!({
2676 "display": {
2677 "title": "Sign in",
2678 "url": "https://example.test/authorize",
2679 "instructions": ["Paste the code"],
2680 "access_token": "secret-access",
2681 "refresh_token": "secret-refresh",
2682 }
2683 }));
2684 assert_eq!(display["title"], "Sign in");
2685 assert!(display.get("access_token").is_none());
2686 assert!(display.get("refresh_token").is_none());
2687 }
2688
2689 #[test]
2690 fn rpc_message_text_joins_content_blocks_with_real_newlines() {
2691 let text = rpc_message_text(&serde_json::json!({
2692 "content": [
2693 {"type": "text", "text": "first"},
2694 {"type": "text", "text": "second"}
2695 ]
2696 }));
2697 assert_eq!(text, "first\nsecond");
2698 assert!(!text.contains("\\n"));
2699 }
2700
2701 #[derive(Clone, Default)]
2702 struct RpcOutput(Arc<Mutex<Vec<u8>>>);
2703
2704 impl Write for RpcOutput {
2705 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
2706 self.0.lock().unwrap().extend_from_slice(bytes);
2707 Ok(bytes.len())
2708 }
2709 fn flush(&mut self) -> std::io::Result<()> {
2710 Ok(())
2711 }
2712 }
2713
2714 impl RpcOutput {
2715 fn messages(&self) -> Vec<serde_json::Value> {
2716 String::from_utf8(self.0.lock().unwrap().clone())
2717 .unwrap()
2718 .lines()
2719 .map(|line| serde_json::from_str(line).unwrap())
2720 .collect()
2721 }
2722
2723 async fn wait_for(
2724 &self,
2725 predicate: impl Fn(&serde_json::Value) -> bool,
2726 ) -> serde_json::Value {
2727 tokio::time::timeout(std::time::Duration::from_secs(3), async {
2728 loop {
2729 if let Some(message) = self.messages().into_iter().find(&predicate) {
2730 return message;
2731 }
2732 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
2733 }
2734 })
2735 .await
2736 .expect("RPC message not received")
2737 }
2738 }
2739
2740 async fn rpc_test_harness(
2741 text: &str,
2742 tokens_per_second: f64,
2743 ) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
2744 let provider = rpi_ai::providers::faux::FauxProvider::new(
2745 rpi_ai::providers::faux::FauxScript::new()
2746 .with_text(text)
2747 .with_tokens_per_second(tokens_per_second),
2748 );
2749 let options = rpi_harness::types::AgentHarnessOptions {
2750 model: provider.default_model().clone(),
2751 models: vec![provider.clone()],
2752 ..Default::default()
2753 };
2754 (AgentHarness::create(options).await.unwrap(), provider)
2755 }
2756
2757 fn start_test_rpc(
2758 harness: AgentHarness,
2759 ui: rpi_extensions::UiDialogMailbox,
2760 ) -> (
2761 tokio::io::DuplexStream,
2762 RpcOutput,
2763 tokio::task::JoinHandle<i32>,
2764 ) {
2765 let (client, input) = tokio::io::duplex(4096);
2766 let output = RpcOutput::default();
2767 let captured = output.clone();
2768 let task = tokio::spawn(async move {
2769 rpc_with_io(
2770 &harness,
2771 None,
2772 vec![],
2773 ui,
2774 None,
2775 tokio::io::BufReader::new(input),
2776 captured,
2777 )
2778 .await
2779 });
2780 (client, output, task)
2781 }
2782
2783 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2784 async fn rpc_eof_waits_for_accepted_prompt() {
2785 use tokio::io::AsyncWriteExt;
2786 let (harness, _) = rpc_test_harness("completed", 100.0).await;
2787 let ui = rpi_extensions::UiDialogMailbox::new();
2788 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
2789 client
2790 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2791 .await
2792 .unwrap();
2793 client.shutdown().await.unwrap();
2794 assert_eq!(
2795 tokio::time::timeout(std::time::Duration::from_secs(3), task)
2796 .await
2797 .unwrap()
2798 .unwrap(),
2799 0
2800 );
2801 let response = output
2802 .messages()
2803 .into_iter()
2804 .find(|m| m["id"] == 1)
2805 .unwrap();
2806 assert_eq!(response["command"], "prompt");
2807 assert_eq!(response["data"]["accepted"], true);
2808 assert!(output.messages().iter().any(|message| {
2809 message["type"] == "agent_settled" && message["outcome"] == "completed"
2810 }));
2811 assert!(!ui.is_attached());
2812 }
2813
2814 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2815 async fn rpc_append_entry_publishes_generic_commit_event() {
2816 use tokio::io::AsyncWriteExt;
2817 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2818 let (mut client, output, task) =
2819 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2820 client
2821 .write_all(
2822 br#"{"type":"append_entry","id":1,"customType":"durable-state","data":{"revision":1}}
2823"#,
2824 )
2825 .await
2826 .unwrap();
2827
2828 let event = output
2829 .wait_for(|message| message["type"] == "session_entry_committed")
2830 .await;
2831 assert_eq!(event["customType"], "durable-state");
2832 assert_eq!(event["entryType"], "custom");
2833 assert_eq!(event["lane"], "main");
2834 assert!(event["entryId"].as_str().is_some());
2835
2836 client
2837 .write_all(
2838 br#"{"type":"stop","id":2}
2839"#,
2840 )
2841 .await
2842 .unwrap();
2843 assert_eq!(task.await.unwrap(), 0);
2844 }
2845
2846 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2847 async fn rpc_prompt_publishes_committed_message_event() {
2848 use tokio::io::AsyncWriteExt;
2849 let (harness, _) = rpc_test_harness("completed", 0.0).await;
2850 let (mut client, output, task) =
2851 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
2852 client
2853 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
2854 .await
2855 .unwrap();
2856 let event = output
2857 .wait_for(|message| {
2858 message["type"] == "session_entry_committed" && message["entryType"] == "message"
2859 })
2860 .await;
2861 assert_eq!(event["lane"], "main");
2862 assert!(event["entryId"].as_str().is_some());
2863 output
2864 .wait_for(|message| message["type"] == "agent_settled")
2865 .await;
2866 client
2867 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
2868 .await
2869 .unwrap();
2870 assert_eq!(task.await.unwrap(), 0);
2871 }
2872
2873 #[tokio::test]
2874 async fn rpc_stop_flushes_all_accepted_entry_invalidations() {
2875 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2876 let output = RpcOutput::default();
2877 let mut input = String::new();
2878 for id in 1..=4 {
2879 input.push_str(&format!(
2880 "{{\"type\":\"append_entry\",\"id\":{id},\"customType\":\"durable-state\"}}\n"
2881 ));
2882 }
2883 input.push_str("{\"type\":\"stop\",\"id\":5}\n");
2884 let result = rpc_with_io(
2885 &harness,
2886 None,
2887 vec![],
2888 rpi_extensions::UiDialogMailbox::new(),
2889 None,
2890 tokio::io::BufReader::new(input.as_bytes()),
2891 output.clone(),
2892 )
2893 .await;
2894 assert_eq!(result, 0);
2895 let messages = output.messages();
2896 let commits: std::collections::HashSet<_> = messages
2897 .iter()
2898 .filter(|m| m["type"] == "session_entry_committed")
2899 .map(|m| m["entryId"].as_str().unwrap())
2900 .collect();
2901 assert_eq!(
2902 commits.len(),
2903 4,
2904 "stop must flush every committed entry: {messages:?}"
2905 );
2906 }
2907
2908 #[tokio::test]
2909 async fn rpc_eof_flushes_queued_entry_invalidations() {
2910 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2911 let output = RpcOutput::default();
2912 let input = b"{\"type\":\"append_entry\",\"id\":1,\"customType\":\"durable-state\",\"data\":{\"revision\":1}}\n";
2913 let result = rpc_with_io(
2914 &harness,
2915 None,
2916 vec![],
2917 rpi_extensions::UiDialogMailbox::new(),
2918 None,
2919 tokio::io::BufReader::new(&input[..]),
2920 output.clone(),
2921 )
2922 .await;
2923 assert_eq!(result, 0);
2924 let messages = output.messages();
2925 assert!(
2926 messages
2927 .iter()
2928 .any(|m| m["type"] == "response" && m["id"] == 1 && m["success"] == true),
2929 "append succeeded: {messages:?}"
2930 );
2931 assert!(
2932 messages
2933 .iter()
2934 .any(|m| m["type"] == "session_entry_committed"),
2935 "EOF must flush the queued commit event: {messages:?}"
2936 );
2937 }
2938
2939 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2940 async fn rpc_compaction_settlement_identifies_its_durable_operation() {
2941 let (harness, _) = rpc_test_harness("unused", 0.0).await;
2942 let output = RpcOutput::default();
2943 let code = rpc_with_io(
2944 &harness,
2945 None,
2946 vec![],
2947 rpi_extensions::UiDialogMailbox::new(),
2948 None,
2949 tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
2950 output.clone(),
2951 )
2952 .await;
2953 assert_eq!(code, 0);
2954 let messages = output.messages();
2955 let settled = messages
2956 .iter()
2957 .find(|message| message["type"] == "agent_settled")
2958 .unwrap();
2959 assert_eq!(settled["outcome"], "declined");
2960 let run_id = settled["runId"]
2961 .as_str()
2962 .expect("compaction settlement needs its durable run id");
2963 let records = harness
2964 .session()
2965 .find_records(&Default::default())
2966 .await
2967 .unwrap();
2968 assert!(records.iter().any(|record| matches!(record,
2969 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
2970 if finished.run_id == run_id)));
2971 }
2972
2973 #[tokio::test]
2974 async fn rpc_eof_drains_queued_final_events() {
2975 let harness = AgentHarness::create(Default::default()).await.unwrap();
2976 let (tx, rx) = tokio::sync::broadcast::channel(256);
2977 for _ in 0..100 {
2978 tx.send(AgentEvent::AgentStart).unwrap();
2979 }
2980 tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
2981 let output = RpcOutput::default();
2982 let code = rpc_with_io(
2983 &harness,
2984 Some(rx),
2985 vec![],
2986 rpi_extensions::UiDialogMailbox::new(),
2987 None,
2988 tokio::io::BufReader::new(&b""[..]),
2989 output.clone(),
2990 )
2991 .await;
2992 assert_eq!(code, 0);
2993 let messages = output.messages();
2994 assert_eq!(
2995 messages.len(),
2996 102,
2997 "ready plus every queued event must be flushed"
2998 );
2999 assert_eq!(messages.last().unwrap()["type"], "agent_end");
3000 }
3001
3002 struct BackpressuredOutput {
3003 entered: Arc<std::sync::atomic::AtomicBool>,
3004 release: Arc<std::sync::atomic::AtomicBool>,
3005 output: RpcOutput,
3006 }
3007
3008 impl Write for BackpressuredOutput {
3009 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
3010 self.entered
3011 .store(true, std::sync::atomic::Ordering::SeqCst);
3012 while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
3013 std::thread::sleep(std::time::Duration::from_millis(1));
3014 }
3015 self.output.write(bytes)
3016 }
3017 fn flush(&mut self) -> std::io::Result<()> {
3018 Ok(())
3019 }
3020 }
3021
3022 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3023 async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
3024 use std::sync::atomic::{AtomicBool, Ordering};
3025 let release = Arc::new(AtomicBool::new(false));
3026 struct ReleaseOnDrop(Arc<AtomicBool>);
3027 impl Drop for ReleaseOnDrop {
3028 fn drop(&mut self) {
3029 self.0.store(true, Ordering::SeqCst);
3030 }
3031 }
3032 let _release = ReleaseOnDrop(release.clone());
3033 let entered = Arc::new(AtomicBool::new(false));
3034 let output = RpcOutput::default();
3035 let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
3036 entered: entered.clone(),
3037 release: release.clone(),
3038 output: output.clone(),
3039 });
3040 write_line(&writer, &serde_json::json!({"id": 0})).await;
3041 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3042 while !entered.load(Ordering::SeqCst) {
3043 tokio::task::yield_now().await;
3044 }
3045 })
3046 .await
3047 .unwrap();
3048 let producer_writer = writer.clone();
3049 let producer = tokio::spawn(async move {
3050 for id in 1..=100 {
3051 write_line(&producer_writer, &serde_json::json!({"id": id})).await;
3052 }
3053 });
3054 tokio::time::timeout(
3055 std::time::Duration::from_millis(150),
3056 tokio::time::sleep(std::time::Duration::from_millis(20)),
3057 )
3058 .await
3059 .unwrap();
3060 assert!(
3061 !producer.is_finished(),
3062 "the bounded queue must apply backpressure"
3063 );
3064 release.store(true, Ordering::SeqCst);
3065 producer.await.unwrap();
3066 drop(writer);
3067 task.await.unwrap().unwrap();
3068 let messages = output.messages();
3069 assert_eq!(messages.len(), 101);
3070 for (index, message) in messages.iter().enumerate() {
3071 assert_eq!(message["id"], index);
3072 }
3073 }
3074
3075 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3076 async fn rpc_completion_response_follows_final_event_under_backpressure() {
3077 use std::sync::atomic::{AtomicBool, Ordering};
3078 let (harness, _) = rpc_test_harness("completed", 0.0).await;
3079 let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
3080 harness
3081 .set_agent_emitter(Some(Arc::new(emitter)))
3082 .await
3083 .unwrap();
3084 let output = RpcOutput::default();
3085 let release = Arc::new(AtomicBool::new(false));
3086 let release_writer = release.clone();
3087 let release_thread = std::thread::spawn(move || {
3088 std::thread::sleep(std::time::Duration::from_millis(50));
3089 release_writer.store(true, Ordering::SeqCst);
3090 });
3091 let code = rpc_with_io(
3092 &harness,
3093 Some(events),
3094 vec![],
3095 rpi_extensions::UiDialogMailbox::new(),
3096 None,
3097 tokio::io::BufReader::new(
3098 &b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
3099 ),
3100 BackpressuredOutput {
3101 entered: Arc::new(AtomicBool::new(false)),
3102 release,
3103 output: output.clone(),
3104 },
3105 )
3106 .await;
3107 release_thread.join().unwrap();
3108 assert_eq!(code, 0);
3109 let messages = output.messages();
3110 let end = messages
3111 .iter()
3112 .position(|message| message["type"] == "agent_end")
3113 .unwrap();
3114 let settled = messages
3115 .iter()
3116 .position(|message| message["type"] == "agent_settled")
3117 .unwrap();
3118 assert!(end < settled);
3119 let run_id = messages[settled]["runId"].as_str().unwrap();
3120 assert!(!run_id.is_empty(), "settlement identifies its durable run");
3121 let records = harness
3122 .session()
3123 .find_records(&Default::default())
3124 .await
3125 .unwrap();
3126 assert!(records.iter().any(|record| matches!(record,
3127 rpi_harness::session::types::LaneRecord::OperationFinished(finished)
3128 if finished.run_id == run_id)));
3129 assert!(messages.iter().any(|message| {
3130 message["type"] == "response" && message["data"]["accepted"] == true
3131 }));
3132 assert!(messages.iter().any(|message| {
3133 message["type"] == "agent_settled" && message["outcome"] == "completed"
3134 }));
3135 }
3136
3137 #[tokio::test]
3138 async fn rpc_output_failure_closes_without_waiting_for_more_input() {
3139 struct BrokenOutput;
3140 impl Write for BrokenOutput {
3141 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
3142 Err(std::io::Error::new(
3143 std::io::ErrorKind::BrokenPipe,
3144 "closed client output",
3145 ))
3146 }
3147 fn flush(&mut self) -> std::io::Result<()> {
3148 Ok(())
3149 }
3150 }
3151 let harness = AgentHarness::create(Default::default()).await.unwrap();
3152 let (_client, input) = tokio::io::duplex(4096);
3153 let ui = rpi_extensions::UiDialogMailbox::new();
3154 let code = tokio::time::timeout(
3155 std::time::Duration::from_secs(2),
3156 rpc_with_io(
3157 &harness,
3158 None,
3159 vec![],
3160 ui.clone(),
3161 None,
3162 tokio::io::BufReader::new(input),
3163 BrokenOutput,
3164 ),
3165 )
3166 .await
3167 .unwrap();
3168 assert_eq!(code, 1);
3169 assert!(!ui.is_attached());
3170 }
3171
3172 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3173 async fn rpc_stop_aborts_and_joins_active_prompt() {
3174 use tokio::io::AsyncWriteExt;
3175 let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
3176 let lane = harness.lane("main");
3177 let (mut client, output, task) =
3178 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3179 client
3180 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
3181 .await
3182 .unwrap();
3183 tokio::time::timeout(std::time::Duration::from_secs(3), async {
3184 while provider
3185 .state()
3186 .call_count
3187 .load(std::sync::atomic::Ordering::SeqCst)
3188 == 0
3189 {
3190 tokio::task::yield_now().await;
3191 }
3192 })
3193 .await
3194 .unwrap();
3195 client
3196 .write_all(b"{\"type\":\"stop\",\"id\":2}\n")
3197 .await
3198 .unwrap();
3199 assert_eq!(
3200 tokio::time::timeout(std::time::Duration::from_secs(3), task)
3201 .await
3202 .unwrap()
3203 .unwrap(),
3204 0
3205 );
3206 let messages = output.messages();
3207 assert!(messages.iter().any(|message| {
3208 message["type"] == "agent_settled"
3209 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
3210 }));
3211 assert_eq!(
3212 messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
3213 true
3214 );
3215 assert!(
3216 lane.abort().await.is_err(),
3217 "RPC returned with an active lane"
3218 );
3219 }
3220
3221 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3222 async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
3223 use tokio::io::AsyncWriteExt;
3224 let (harness, _) = rpc_test_harness("not completed", 20.0).await;
3225 let lane = harness.lane("main");
3226 let (mut client, output, task) =
3227 start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
3228 client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
3229 assert_eq!(
3230 tokio::time::timeout(std::time::Duration::from_secs(3), task)
3231 .await
3232 .unwrap()
3233 .unwrap(),
3234 0
3235 );
3236 let messages = output.messages();
3237 assert!(messages.iter().any(|message| {
3238 message["type"] == "agent_settled"
3239 && (message["outcome"] == "aborted" || message["outcome"] == "failed")
3240 }));
3241 assert!(lane.abort().await.is_err());
3242 }
3243
3244 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3245 async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
3246 use tokio::io::AsyncWriteExt;
3247 let (harness, _) =
3248 rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
3249 let ui = rpi_extensions::UiDialogMailbox::new();
3250 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
3251 output.wait_for(|m| m["type"] == "ready").await;
3252 client
3253 .write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
3254 .await
3255 .unwrap();
3256 ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
3257 .unwrap();
3258 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
3259 client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
3260 output.wait_for(|m| m["id"] == 2).await;
3261 assert_eq!(ui.poll("approval").unwrap()["answer"], true);
3262 output
3263 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
3264 .await;
3265 client.shutdown().await.unwrap();
3266 assert_eq!(
3267 tokio::time::timeout(std::time::Duration::from_secs(3), task)
3268 .await
3269 .unwrap()
3270 .unwrap(),
3271 0
3272 );
3273 assert!(output.messages().iter().any(|message| {
3274 message["type"] == "agent_settled" && message["outcome"] == "completed"
3275 }));
3276 }
3277
3278 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3279 async fn rpc_notifies_client_when_an_approval_is_cancelled() {
3280 use tokio::io::AsyncWriteExt;
3281 let harness = AgentHarness::create(Default::default()).await.unwrap();
3282 let ui = rpi_extensions::UiDialogMailbox::new();
3283 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
3284 output.wait_for(|m| m["type"] == "ready").await;
3285 ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
3286 .unwrap();
3287 output.wait_for(|m| m["type"] == "ui_dialog_request").await;
3288 ui.cancel_tool_call("c");
3289 output
3290 .wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
3291 .await;
3292 assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
3294 client.shutdown().await.unwrap();
3295 assert_eq!(task.await.unwrap(), 0);
3296 }
3297
3298 #[tokio::test]
3299 async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
3300 use crate::remote::protocol::RemoteCommand;
3301 use tokio::io::AsyncWriteExt;
3302 let harness = AgentHarness::create(Default::default()).await.unwrap();
3303 let ui = rpi_extensions::UiDialogMailbox::new();
3304 let (mut client, output, task) = start_test_rpc(harness, ui.clone());
3305 output.wait_for(|m| m["type"] == "ready").await;
3306 for (request_id, answer, response_id) in [
3307 (
3308 "answered",
3309 Some(serde_json::json!({"values":["No"],"kind":"selector"})),
3310 5,
3311 ),
3312 ("cancelled", None, 6),
3313 ] {
3314 ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
3315 .unwrap();
3316 output
3317 .wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
3318 .await;
3319 let command = RemoteCommand::UiDialogResponse {
3320 request_id: request_id.into(),
3321 cancelled: answer.is_none(),
3322 answer,
3323 }
3324 .with_id(response_id);
3325 client
3326 .write_all(format!("{command}\n").as_bytes())
3327 .await
3328 .unwrap();
3329 let ack = output.wait_for(|m| m["id"] == response_id).await;
3330 assert_eq!(ack["data"]["accepted"], true);
3331 let result = ui.poll(request_id).unwrap();
3332 if request_id == "answered" {
3333 assert_eq!(result["answer"]["values"][0], "No");
3334 } else {
3335 assert_eq!(result["status"], "cancelled");
3336 }
3337 }
3338 client.shutdown().await.unwrap();
3339 assert_eq!(task.await.unwrap(), 0);
3340 }
3341
3342 fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
3343 AssistantMessage {
3344 role: rpi_ai::types::AssistantRole,
3345 content: vec![Content::Text(TextContent {
3346 kind: TextContentType,
3347 text: text.into(),
3348 text_signature: None,
3349 })],
3350 api: rpi_ai::Api::AnthropicMessages,
3351 provider: "anthropic".into(),
3352 model: "claude-sonnet-5".into(),
3353 response_model: None,
3354 response_id: None,
3355 usage: Usage::zero(),
3356 stop_reason: stop,
3357 deferred: None,
3358 error_message: None,
3359 raw_stop_reason: None,
3360 end_turn: None,
3361 timestamp: 0,
3362 }
3363 }
3364
3365 #[test]
3366 fn assistant_text_concatenates_text_blocks() {
3367 let m = assistant("hello", StopReason::Stop);
3368 assert_eq!(assistant_text(&m), "hello");
3369 }
3370
3371 #[test]
3372 fn outcome_exit_code_maps_failed_aborted_to_1() {
3373 let failed = HarnessRunOutcome::Failed {
3374 leaf_id: "l".into(),
3375 error: OperationError {
3376 code: "boom".into(),
3377 message: "boom".into(),
3378 },
3379 final_entry_id: None,
3380 final_message: None,
3381 };
3382 assert_eq!(outcome_exit_code(&failed), 1);
3383 let completed = HarnessRunOutcome::Completed {
3384 leaf_id: "l".into(),
3385 final_entry_id: "e".into(),
3386 final_message: assistant("ok", StopReason::Stop),
3387 };
3388 assert_eq!(outcome_exit_code(&completed), 0);
3389 }
3390
3391 #[test]
3392 fn run_end_outcome_str_roundtrip() {
3393 assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
3394 assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
3395 assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
3396 }
3397
3398 #[test]
3399 fn agent_event_projection_keeps_terminal_and_tool_payloads() {
3400 let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
3401 assert_eq!(end["type"], "agent_end");
3402 assert_eq!(end["messages"], serde_json::json!([]));
3403
3404 let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
3405 tool_call_id: "call-1".into(),
3406 tool_name: "read".into(),
3407 result: AgentToolResult::text("hello"),
3408 is_error: false,
3409 });
3410 assert_eq!(tool["type"], "tool_execution_end");
3411 assert_eq!(tool["result"]["content"][0]["text"], "hello");
3412 assert_eq!(tool["result"]["terminate"], false);
3413
3414 let retry = agent_event_json(&AgentEvent::RetryScheduled {
3415 attempt: 3,
3416 max_retries: 10,
3417 delay_ms: 8_000,
3418 error: "503 service unavailable".into(),
3419 });
3420 assert_eq!(retry["type"], "retry_scheduled");
3421 assert_eq!(retry["attempt"], 3);
3422 assert_eq!(retry["maxRetries"], 10);
3423 assert_eq!(retry["delayMs"], 8_000);
3424 assert_eq!(retry["error"], "503 service unavailable");
3425 }
3426}
3427
3428#[cfg(test)]
3429mod page_window_tests {
3430 use super::page_window;
3431
3432 #[test]
3433 fn tail_is_kept_without_before_id() {
3434 assert_eq!(page_window(12, 5, None), (7..12, true));
3436 assert_eq!(page_window(3, 5, None), (0..3, false));
3438 }
3439
3440 #[test]
3441 fn before_id_opens_the_window_immediately_before_it() {
3442 assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
3444 assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
3446 }
3447
3448 #[test]
3449 fn limit_larger_than_the_branch_never_underflows() {
3450 assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
3451 assert_eq!(page_window(5, 100, None), (0..5, false));
3452 }
3453}
3454
3455#[cfg(test)]
3456mod session_storage_notice_tests {
3457 use super::session_storage_notice;
3458
3459 #[test]
3463 fn the_headless_notice_does_not_promise_an_unavailable_recourse() {
3464 let notice = session_storage_notice("disk full");
3465 assert!(notice.contains("disk full"), "{notice}");
3466 assert!(
3467 !notice.contains("/export"),
3468 "print mode has no interactive /export: {notice}"
3469 );
3470 assert!(
3471 notice.contains("print mode exits"),
3472 "the notice must say the turn is not coming back: {notice}"
3473 );
3474 }
3475}