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