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