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