use std::io::{BufRead, IsTerminal, Write};
use std::sync::{Arc, Mutex};
use rpi_agent::events::AgentEvent;
use rpi_ai::types::{AssistantMessage, Content, ImageContent, StopReason, UserMessage};
use rpi_harness::agent_harness::{
AgentHarness, AgentLane, CompactionOutcome, CompactionResult, HarnessRunOutcome,
};
use rpi_harness::events::{HarnessEvent, RunEndOutcome};
use rpi_harness::session::types::{EntryOrder, LaneRecord, RecordQuery};
use crate::args::Args;
use crate::rpc::protocol as rpc_protocol;
use crate::rpc::writer::{start_rpc_writer, write_agent_event, write_line, RpcWriter};
pub fn assistant_text(msg: &AssistantMessage) -> String {
msg.content
.iter()
.filter_map(|c| match c {
Content::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect()
}
pub fn outcome_exit_code(outcome: &HarnessRunOutcome) -> i32 {
match outcome {
HarnessRunOutcome::Failed { .. } | HarnessRunOutcome::Aborted { .. } => 1,
_ => 0,
}
}
pub async fn print(
harness: &AgentHarness,
_args: &Args,
initial: Option<String>,
extra_messages: &[String],
initial_images: Vec<ImageContent>,
) -> i32 {
let lane: Arc<dyn AgentLane> = harness.lane("main");
let mut last_exit = 0;
let mut last_msg: Option<AssistantMessage> = None;
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
if prompts.is_empty() {
return 0;
}
let mut images = initial_images;
for prompt in prompts {
match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
Ok(result) => {
last_exit = outcome_exit_code(&result.outcome);
match &result.outcome {
HarnessRunOutcome::Completed { final_message, .. }
| HarnessRunOutcome::Aborted { final_message, .. } => {
last_msg = Some(final_message.clone());
}
HarnessRunOutcome::Failed {
error,
final_message,
..
} => {
if let Some(m) = final_message {
if m.stop_reason == StopReason::Error {
if let Some(em) = &m.error_message {
eprintln!("{em}");
}
}
}
eprintln!("run failed: {error:?}");
}
HarnessRunOutcome::Suspended { .. } => {
eprintln!("run suspended (deferred) — resume is not supported in v1");
last_exit = 1;
}
}
}
Err(e) => {
eprintln!("prompt rejected: {e}");
return 1;
}
}
}
if let Some(m) = &last_msg {
match m.stop_reason {
StopReason::Error => {
if let Some(em) = &m.error_message {
eprintln!("{em}");
}
last_exit = 1;
}
StopReason::Aborted => {
eprintln!("request aborted");
last_exit = 1;
}
_ => {
let text = assistant_text(m);
let mut out = std::io::stdout();
let _ = out.write_all(text.as_bytes());
if !text.ends_with('\n') {
let _ = out.write_all(b"\n");
}
let _ = out.flush();
}
}
}
last_exit
}
pub async fn json(
harness: &AgentHarness,
_args: &Args,
initial: Option<String>,
extra_messages: &[String],
initial_images: Vec<ImageContent>,
mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
) -> i32 {
let lane: Arc<dyn AgentLane> = harness.lane("main");
let collected: Arc<Mutex<Vec<HarnessEvent>>> = Arc::new(Mutex::new(Vec::new()));
let collected_for_watch = collected.clone();
let mut watch = harness.events().watch(|| ());
watch.start(Arc::new(move |event: &HarnessEvent| {
emit_json_event(event);
collected_for_watch.lock().unwrap().push(event.clone());
}));
std::mem::forget(watch);
let (agent_done_tx, mut agent_done_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let agent_event_task = agent_events.take().map(|mut rx| {
tokio::spawn(async move {
let done_tx = agent_done_tx;
loop {
match rx.recv().await {
Ok(event) => {
let terminal = event.is_terminal();
emit_agent_event(&event);
if terminal {
let _ = done_tx.send(());
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
});
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
let mut last_exit = 0;
let mut final_outcome: Option<HarnessRunOutcome> = None;
let mut images = initial_images;
for prompt in prompts {
match lane.prompt_text(&prompt, std::mem::take(&mut images)).await {
Ok(result) => {
let _ = tokio::time::timeout(
std::time::Duration::from_millis(250),
agent_done_rx.recv(),
)
.await;
last_exit = outcome_exit_code(&result.outcome);
final_outcome = Some(result.outcome);
}
Err(e) => {
let line = serde_json::json!({
"type": "error",
"error": e.to_string(),
});
println!("{line}");
if let Some(task) = agent_event_task {
task.abort();
}
return 1;
}
}
}
let (outcome_str, final_text) = match final_outcome {
Some(HarnessRunOutcome::Completed { final_message, .. }) => {
("completed", Some(assistant_text(&final_message)))
}
Some(HarnessRunOutcome::Aborted { final_message, .. }) => {
("aborted", Some(assistant_text(&final_message)))
}
Some(HarnessRunOutcome::Failed { final_message, .. }) => {
let t = final_message.as_ref().map(assistant_text);
("failed", t)
}
Some(HarnessRunOutcome::Suspended { .. }) => ("suspended", None),
None => ("idle", None),
};
let result_line = serde_json::json!({
"type": "result",
"outcome": outcome_str,
"finalText": final_text,
});
println!("{result_line}");
if let Some(task) = agent_event_task {
task.abort();
}
last_exit
}
fn emit_agent_event(event: &AgentEvent) {
println!("{}", agent_event_json(event));
}
pub async fn rpc(
harness: &AgentHarness,
mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
model_catalog: Vec<rpi_ai::Model>,
ui_dialog: rpi_extensions::UiDialogMailbox,
oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
) -> i32 {
rpc_with_io(
harness,
agent_events.take(),
model_catalog,
ui_dialog,
oauth_snapshot,
tokio::io::BufReader::new(tokio::io::stdin()),
std::io::stdout(),
)
.await
}
async fn rpc_with_io<R, W>(
harness: &AgentHarness,
mut agent_events: Option<tokio::sync::broadcast::Receiver<AgentEvent>>,
model_catalog: Vec<rpi_ai::Model>,
ui_dialog: rpi_extensions::UiDialogMailbox,
oauth_snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
input: R,
output: W,
) -> i32
where
R: tokio::io::AsyncBufRead + Unpin,
W: Write + Send + 'static,
{
let lane: Arc<dyn AgentLane> = harness.lane("main");
let (stdout, writer_task, writer_failed) = start_rpc_writer(output);
let pump_stop = tokio_util::sync::CancellationToken::new();
let (event_flush_tx, mut event_flush_rx) =
tokio::sync::mpsc::unbounded_channel::<tokio::sync::oneshot::Sender<()>>();
let event_task = agent_events.take().map(|mut rx| {
let out = stdout.clone();
let stop = pump_stop.clone();
tokio::spawn(async move {
loop {
let received = tokio::select! {
_ = stop.cancelled() => {
drain_rpc_events(&out, &mut rx).await;
break;
}
Some(done) = event_flush_rx.recv() => {
drain_rpc_events(&out, &mut rx).await;
let _ = done.send(());
continue;
}
event = rx.recv() => event,
};
match received {
Ok(event) => write_agent_event(&out, &event).await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
});
let event_flush = event_task.as_ref().map(|_| event_flush_tx);
ui_dialog.attach();
let dialog_task = {
let mailbox = ui_dialog.clone();
let out = stdout.clone();
let stop = pump_stop.clone();
tokio::spawn(async move {
let mut shown = std::collections::HashSet::new();
while !stop.is_cancelled() {
while let Some(request) = mailbox.take_pending() {
let event = crate::remote::protocol::RemoteEvent::UiDialogRequest {
request_id: request.request_id.clone(),
tool_call_id: request.tool_call_id,
ui: request.ui,
};
write_line(&out, &serde_json::to_value(event).unwrap()).await;
shown.insert(request.request_id);
}
let closed: Vec<String> = shown
.iter()
.filter(|id| !mailbox.is_pending(id))
.cloned()
.collect();
for request_id in closed {
shown.remove(&request_id);
let event = crate::remote::protocol::RemoteEvent::UiDialogClosed { request_id };
write_line(&out, &serde_json::to_value(event).unwrap()).await;
}
tokio::select! {
_ = stop.cancelled() => break,
_ = tokio::time::sleep(std::time::Duration::from_millis(30)) => {}
}
}
})
};
let run_active = Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut run_task: Option<tokio::task::JoinHandle<()>> = None;
let mut run_cancel = tokio_util::sync::CancellationToken::new();
let mut cancel_on_exit = false;
use tokio::io::AsyncBufReadExt;
let mut lines = input.lines();
write_line(&stdout, &rpc_protocol::ready()).await;
loop {
let incoming = tokio::select! {
_ = writer_failed.cancelled() => {
cancel_on_exit = true;
break;
}
line = lines.next_line() => line,
};
let line = match incoming {
Ok(Some(line)) => line,
Ok(None) => break, Err(error) => {
eprintln!("[rpi] rpc stdin error: {error}");
cancel_on_exit = true;
break;
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
let msg: serde_json::Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(error) => {
write_line(
&stdout,
&rpc_protocol::response(
None,
"invalid_request",
Err(format!("invalid json: {error}")),
),
)
.await;
continue;
}
};
let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
let kind = msg
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if rpc_protocol::SUPPORTED_COMMANDS.contains(&kind.as_str())
&& serde_json::from_value::<rpc_protocol::Request>(msg.clone()).is_err()
{
write_line(
&stdout,
&rpc_protocol::response(
Some(id.clone()),
&kind,
Err("invalid command payload".to_string()),
),
)
.await;
continue;
}
match kind.as_str() {
"ui_dialog_response" => {
let request_id = msg
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let result = if msg
.get("cancelled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
ui_dialog.cancel(&request_id)
} else {
let answer = msg
.get("answer")
.cloned()
.unwrap_or(serde_json::Value::Null);
ui_dialog.respond(&request_id, answer)
};
if msg.get("id").is_some() {
let response = match result {
Ok(()) => rpc_protocol::response(
Some(id),
"ui_dialog_response",
Ok(Some(
serde_json::json!({"accepted":true,"requestId":request_id}),
)),
),
Err(error) => {
rpc_protocol::response(Some(id), "ui_dialog_response", Err(error))
}
};
write_line(&stdout, &response).await;
} else if let Err(error) = result {
eprintln!("[rpi] ui_dialog_response: {error}");
}
continue;
}
"stop" => {
cancel_on_exit = true;
write_line(
&stdout,
&rpc_protocol::response(
Some(id.clone()),
"stop",
Ok(Some(serde_json::json!({"stopped": true}))),
),
)
.await;
break;
}
"prompt" | "resume_pending" | "compact" => {
let resume = kind == "resume_pending";
let compact = kind == "compact";
if run_active.swap(true, std::sync::atomic::Ordering::AcqRel) {
write_line(
&stdout,
&rpc_protocol::response(
Some(id.clone()),
&kind,
Err("a run is already active".to_string()),
),
)
.await;
continue;
}
if let Some(task) = run_task.take() {
let _ = task.await;
}
run_cancel = tokio_util::sync::CancellationToken::new();
let cancel_task = run_cancel.clone();
if resume && !lane.has_pending_resume().await {
run_active.store(false, std::sync::atomic::Ordering::Release);
write_line(
&stdout,
&rpc_protocol::response(
Some(id),
"resume_pending",
Ok(Some(serde_json::json!({"resumed": false}))),
),
)
.await;
continue;
}
let content = msg
.get("message")
.and_then(|v| v.as_str())
.map(str::to_owned);
let lane_task = Arc::clone(&lane);
let out_task = stdout.clone();
let active_task = Arc::clone(&run_active);
let mailbox_task = ui_dialog.clone();
let flush_events = event_flush.clone();
let Some(content) = content.or_else(|| (resume || compact).then(String::new))
else {
active_task.store(false, std::sync::atomic::Ordering::Release);
write_line(
&stdout,
&rpc_protocol::response(
Some(id),
"prompt",
Err("prompt requires a string `message`".to_string()),
),
)
.await;
continue;
};
write_line(
&stdout,
&rpc_protocol::response(
Some(id),
&kind,
Ok(Some(serde_json::json!({"accepted": true}))),
),
)
.await;
let instructions = msg
.get("customInstructions")
.and_then(|v| v.as_str())
.map(str::to_owned);
run_task = Some(tokio::spawn(async move {
let result = if compact {
rpc_compact_cancellable(&lane_task, instructions, cancel_task).await
} else if resume {
rpc_resume_cancellable(&lane_task, cancel_task).await
} else {
rpc_prompt_cancellable(&lane_task, &content, cancel_task).await
};
if let Some(flush) = flush_events {
let (done, drained) = tokio::sync::oneshot::channel();
if flush.send(done).is_ok() {
let _ = drained.await;
}
}
let (outcome, aborted, error) = match &result {
Ok(value) => {
let outcome = value
.get("outcome")
.and_then(|v| v.as_str())
.unwrap_or("completed")
.to_string();
let error = value
.get("error")
.and_then(|v| v.as_str())
.map(str::to_owned);
(outcome.clone(), outcome == "aborted", error)
}
Err(ref error) => ("failed".to_string(), false, Some(error.clone())),
};
let pending_resume = lane_task.has_pending_resume().await;
let summary = result
.as_ref()
.ok()
.and_then(|value| value.get("summary"))
.cloned();
let settled = serde_json::json!({
"type": "agent_settled",
"runId": result.as_ref().ok().and_then(|value| value.get("runId")),
"outcome": outcome,
"aborted": aborted,
"error": error,
"summary": summary,
"pendingResume": pending_resume,
});
write_line(&out_task, &settled).await;
mailbox_task.cancel_all();
active_task.store(false, std::sync::atomic::Ordering::Release);
}));
continue;
}
_ => {}
}
let result = match kind.as_str() {
"abort" if run_active.load(std::sync::atomic::Ordering::Acquire) => {
run_cancel.cancel();
Ok(serde_json::json!({"aborted": true}))
}
"abort" => lane
.abort()
.await
.map(|_| serde_json::json!({"aborted": true}))
.map_err(|e| e.to_string()),
"get_state" => Ok(rpc_get_state(&lane, harness).await),
"get_messages" => rpc_get_messages(&lane, harness, &msg).await,
"get_history" => rpc_get_history(&lane, harness, &msg).await,
"get_stats" => rpc_get_stats(&lane, harness).await,
"get_queue" => rpc_get_queue(&lane, harness, &msg).await,
"queue_message" => rpc_queue_message(&lane, &msg).await,
"cancel_queued" => rpc_cancel_queued(&lane, &msg).await,
"clear_queue" => rpc_clear_queue(&lane).await,
"navigate" => rpc_navigate(&lane, &msg).await,
"get_available_models" => Ok(rpc_get_available_models(&model_catalog)),
"get_auth_status" => Ok(rpc_get_auth_status(&model_catalog, oauth_snapshot.as_ref())),
"oauth" => rpc_oauth(&oauth_snapshot, &msg),
"set_api_key" => rpc_set_api_key(&msg).await,
"set_model" => rpc_set_model(&lane, &model_catalog, &msg).await,
"get_available_thinking_levels" => Ok(rpc_get_available_thinking_levels(&lane).await),
"set_thinking_level" => rpc_set_thinking_level(&lane, &msg).await,
"get_available_tools" => Ok(rpc_get_available_tools(&lane, harness).await),
"set_active_tools" => rpc_set_active_tools(&lane, &msg).await,
"ping" => Ok(serde_json::json!({"pong": true})),
other => Err(format!("unknown command type: {other}")),
};
let response = rpc_protocol::response(Some(id.clone()), &kind, result.map(Some));
write_line(&stdout, &response).await;
}
ui_dialog.detach();
if cancel_on_exit {
run_cancel.cancel();
}
let mut exit_code = 0;
if let Some(task) = run_task {
if let Err(error) = task.await {
eprintln!("[rpi] rpc prompt task failed: {error}");
exit_code = 1;
}
}
pump_stop.cancel();
let _ = dialog_task.await;
if let Some(task) = event_task {
let _ = task.await;
}
drop(stdout);
match writer_task.await {
Ok(Ok(())) => {}
error => {
eprintln!("[rpi] rpc output failed: {error:?}");
exit_code = 1;
}
}
exit_code
}
async fn drain_rpc_events(
out: &RpcWriter,
events: &mut tokio::sync::broadcast::Receiver<AgentEvent>,
) {
loop {
match events.try_recv() {
Ok(event) => write_agent_event(out, &event).await,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
}
async fn rpc_prompt_cancellable(
lane: &Arc<dyn AgentLane>,
content: &str,
cancel: tokio_util::sync::CancellationToken,
) -> Result<serde_json::Value, String> {
let prompt = rpc_prompt_text(lane, content);
tokio::pin!(prompt);
tokio::select! {
biased;
_ = cancel.cancelled() => {
match lane.abort().await {
Ok(_) => prompt.await,
Err(_) => Err("Operation aborted".to_string()),
}
}
result = &mut prompt => result,
}
}
async fn rpc_compact_cancellable(
lane: &Arc<dyn AgentLane>,
instructions: Option<String>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<serde_json::Value, String> {
let compact = async {
match lane.compact(instructions.as_deref()).await {
Ok(result) => Ok(compaction_json(&result)),
Err(error) => Err(error.to_string()),
}
};
tokio::pin!(compact);
tokio::select! {
biased;
_ = cancel.cancelled() => {
let _ = lane.abort().await;
compact.await
}
result = &mut compact => result,
}
}
fn compaction_json(result: &CompactionResult) -> serde_json::Value {
let mut value = match &result.outcome {
CompactionOutcome::Completed { leaf_id, entry } => {
let (tokens_before, retained) = match entry {
rpi_harness::session::types::Entry::Compaction(compaction) => {
(compaction.tokens_before, compaction.retained_tail.len())
}
_ => (0, 0),
};
serde_json::json!({
"outcome": "compacted",
"leafId": leaf_id,
"tokensBefore": tokens_before,
"retainedMessages": retained,
"summary": format!(
"Conversation compacted · {} tokens before, {} messages kept.",
tokens_before, retained
),
})
}
CompactionOutcome::Declined { leaf_id } => serde_json::json!({
"outcome": "declined",
"leafId": leaf_id,
"summary": "Nothing to compact yet.",
}),
CompactionOutcome::Aborted { leaf_id } => serde_json::json!({
"outcome": "aborted",
"leafId": leaf_id,
"summary": "Compaction aborted.",
}),
CompactionOutcome::Failed { leaf_id, error } => serde_json::json!({
"outcome": "failed",
"leafId": leaf_id,
"error": error.message,
"summary": format!("Compaction failed: {}", error.message),
}),
};
value["runId"] = serde_json::json!(result.run_id);
value
}
async fn rpc_resume_cancellable(
lane: &Arc<dyn AgentLane>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<serde_json::Value, String> {
let resume = async {
match lane.resume_pending().await {
Ok(Some(result)) => {
let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
Ok(serde_json::json!({
"runId": result.run_id,
"outcome": outcome,
"finalText": final_text,
"error": error,
"resumed": true,
}))
}
Ok(None) => Ok(serde_json::json!({"outcome": "skipped", "resumed": false})),
Err(error) => Err(error.to_string()),
}
};
tokio::pin!(resume);
tokio::select! {
biased;
_ = cancel.cancelled() => {
let _ = lane.abort().await;
resume.await
}
result = &mut resume => result,
}
}
fn rpc_outcome_str(outcome: &HarnessRunOutcome) -> (&'static str, Option<String>, Option<String>) {
match outcome {
HarnessRunOutcome::Completed { final_message, .. } => {
("completed", Some(assistant_text(final_message)), None)
}
HarnessRunOutcome::Aborted { final_message, .. } => {
("aborted", Some(assistant_text(final_message)), None)
}
HarnessRunOutcome::Failed {
final_message,
error,
..
} => (
"failed",
final_message.as_ref().map(assistant_text),
Some(error.message.clone()),
),
HarnessRunOutcome::Suspended { .. } => ("suspended", None, None),
}
}
async fn rpc_prompt_text(
lane: &Arc<dyn AgentLane>,
content: &str,
) -> Result<serde_json::Value, String> {
let result = lane
.prompt_text(content, Vec::new())
.await
.map_err(|e| e.to_string())?;
let (outcome, final_text, error) = rpc_outcome_str(&result.outcome);
Ok(
serde_json::json!({"runId":result.run_id, "outcome": outcome, "finalText": final_text, "error": error}),
)
}
async fn rpc_get_state(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
let model = lane.get_model().await.ok();
let thinking = lane
.get_thinking_level()
.await
.ok()
.map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
let active_tools = lane.get_active_tools().await.unwrap_or_default();
let leaf_id = lane.get_leaf_id().await.ok().flatten();
let context = rpc_context_usage(lane, harness).await;
let active_operation = lane
.snapshot()
.await
.ok()
.and_then(|snapshot| snapshot.active)
.map(|active| {
serde_json::json!({
"runId": active.run_id,
"lane": active.lane,
"kind": format!("{:?}", active.kind).to_lowercase(),
})
});
let queued = lane.queued_messages().await.unwrap_or_default();
let stats = rpc_get_stats(lane, harness).await.ok();
serde_json::json!({
"modelId": model.as_ref().map(|model| model.id.clone()),
"provider": model.as_ref().map(|model| model.provider.clone()),
"thinkingLevel": thinking,
"activeTools": active_tools,
"leafId": leaf_id,
"context": context,
"sessionId": harness.session().storage().metadata().id,
"activeOperation": active_operation,
"queue": {
"steering": queued.steering,
"followUp": queued.follow_up,
"steeringCount": queued.steering.len(),
"followUpCount": queued.follow_up.len(),
},
"stats": stats,
})
}
fn rpc_now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
fn rpc_message_text(value: &serde_json::Value) -> String {
let Some(content) = value.get("content") else {
return String::new();
};
if let Some(text) = content.as_str() {
return text.to_string();
}
content
.as_array()
.into_iter()
.flatten()
.filter_map(|block| block.get("text").and_then(|text| text.as_str()))
.collect::<Vec<_>>()
.join("\\n")
}
async fn rpc_get_history(
lane: &Arc<dyn AgentLane>,
harness: &AgentHarness,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let limit = msg
.get("limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(100)
.clamp(1, 500) as usize;
let after_seq = msg.get("afterSeq").and_then(serde_json::Value::as_u64);
let before_seq = msg.get("beforeSeq").and_then(serde_json::Value::as_u64);
let mut records = harness
.session()
.find_records(&RecordQuery {
lane: Some(lane.name().to_string()),
after_seq,
order: Some(EntryOrder::OldestFirst),
..Default::default()
})
.await
.map_err(|error| error.to_string())?;
if let Some(before) = before_seq {
records.retain(|record| record.seq() < before);
}
let total = records.len();
let has_more = records.len() > limit;
if before_seq.is_some() {
if has_more {
records = records.split_off(records.len() - limit);
}
} else if after_seq.is_some() {
records.truncate(limit);
} else if has_more {
records = records.split_off(records.len() - limit);
}
let first_seq = records.first().map(LaneRecord::seq);
let last_seq = records.last().map(LaneRecord::seq);
let open_operations = harness
.session()
.find_open_operations(lane.name(), Some(50))
.await
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"records": records.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
"total": total,
"limit": limit,
"afterSeq": after_seq,
"beforeSeq": before_seq,
"firstSeq": first_seq,
"lastSeq": last_seq,
"hasMore": has_more,
"leafId": lane.get_leaf_id().await.ok().flatten(),
"openOperations": open_operations.iter().map(|record| serde_json::to_value(record).unwrap_or(serde_json::Value::Null)).collect::<Vec<_>>(),
}))
}
async fn rpc_get_stats(
lane: &Arc<dyn AgentLane>,
harness: &AgentHarness,
) -> Result<serde_json::Value, String> {
let stats = lane.get_stats().await.map_err(|error| error.to_string())?;
let records = harness
.session()
.find_records(&RecordQuery {
lane: Some(lane.name().to_string()),
order: Some(EntryOrder::OldestFirst),
..Default::default()
})
.await
.map_err(|error| error.to_string())?;
let mut operation_counts = std::collections::BTreeMap::<String, u64>::new();
for record in &records {
let kind = match record {
LaneRecord::OperationStarted(started) => {
let key = started.intent.kind();
*operation_counts.entry(key.to_string()).or_default() += 1;
if matches!(
&started.intent,
rpi_harness::session::types::OperationIntent::Run {
resume_data: Some(_),
..
}
) {
*operation_counts.entry("resume".to_string()).or_default() += 1;
}
continue;
}
LaneRecord::OperationFinished(_) => "operation_finished",
LaneRecord::RetryPending(_) => "retry_pending",
LaneRecord::Usage(_) => "usage",
_ => continue,
};
*operation_counts.entry(kind.to_string()).or_default() += 1;
}
let count = |name: &str| operation_counts.get(name).copied().unwrap_or_default();
let model = lane.get_model().await.ok();
let input = stats.cached_tokens + stats.uncached_tokens;
Ok(serde_json::json!({
"messageCount": stats.message_count,
"operationCount": count("operation_finished"),
"retryCount": count("retry_pending"),
"compactionCount": count("compaction"),
"resumeCount": count("resume"),
"operationCounts": operation_counts,
"provider": model.as_ref().map(|value| value.provider.clone()),
"modelId": model.as_ref().map(|value| value.id.clone()),
"cachedTokens": stats.cached_tokens,
"uncachedTokens": stats.uncached_tokens,
"totalTokens": stats.total_tokens,
"costTotal": stats.cost_total,
"cacheHitRatio": if input > 0 { Some(stats.cached_tokens as f64 / input as f64) } else { None::<f64> },
}))
}
async fn rpc_get_queue(
lane: &Arc<dyn AgentLane>,
harness: &AgentHarness,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let include_completed = msg
.get("includeCompleted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let records = harness
.session()
.find_records(&RecordQuery {
lane: Some(lane.name().to_string()),
order: Some(EntryOrder::OldestFirst),
..Default::default()
})
.await
.map_err(|error| error.to_string())?;
let mut cancelled = std::collections::HashMap::<String, u64>::new();
for record in &records {
if let LaneRecord::QueueCancelled(record) = record {
cancelled.insert(record.entry_id.clone(), record.base.seq);
}
}
let mut items = Vec::new();
for record in records {
let LaneRecord::QueueEnqueued(record) = record else {
continue;
};
let Some(entry_id) = record.target.get("id").and_then(|value| value.as_str()) else {
continue;
};
let (status, completed_seq) = if let Some(seq) = cancelled.get(entry_id) {
("cancelled", Some(*seq))
} else if let Some(entry) = harness.get_entry(entry_id).await.ok().flatten() {
("consumed", Some(entry.base().seq))
} else {
("queued", None)
};
if status != "queued" && !include_completed {
continue;
}
let message = record
.target
.get("message")
.cloned()
.unwrap_or(serde_json::Value::Null);
items.push(serde_json::json!({
"entryId": entry_id,
"queue": record.queue,
"status": status,
"text": rpc_message_text(&message),
"message": message,
"runId": record.run_id,
"seq": record.base.seq,
"completedSeq": completed_seq,
}));
}
Ok(
serde_json::json!({"items": items, "count": items.len(), "includeCompleted": include_completed}),
)
}
async fn rpc_queue_message(
lane: &Arc<dyn AgentLane>,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let text = msg
.get("message")
.and_then(serde_json::Value::as_str)
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| "queue_message requires a non-empty string `message`".to_string())?;
let message = rpi_agent::message::AgentMessage::User(UserMessage::new(text, rpc_now_ms()));
let queue = msg
.get("deliverAs")
.and_then(serde_json::Value::as_str)
.unwrap_or("followUp");
let result = match queue {
"steer" => lane.steer(message).await,
"followUp" | "follow_up" => lane.follow_up(message).await,
"nextTurn" | "next_run" => lane.next_run(message).await,
other => return Err(format!("unknown queue kind: {other}")),
}
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"accepted": true,
"entryId": result.entry_id,
"queue": queue,
}))
}
async fn rpc_cancel_queued(
lane: &Arc<dyn AgentLane>,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let entry_id = msg
.get("entryId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| "cancel_queued requires `entryId`".to_string())?;
let result = lane
.cancel_queued(entry_id)
.await
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"entryId": entry_id,
"outcome": format!("{:?}", result.outcome).to_lowercase(),
}))
}
async fn rpc_clear_queue(lane: &Arc<dyn AgentLane>) -> Result<serde_json::Value, String> {
let result = lane
.clear_queue()
.await
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"cleared": result.steering.len() + result.follow_up.len(),
"steering": result.steering,
"followUp": result.follow_up,
}))
}
async fn rpc_navigate(
lane: &Arc<dyn AgentLane>,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let target_id = msg
.get("targetId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty());
if lane
.snapshot()
.await
.map_err(|error| error.to_string())?
.active
.is_some()
{
return Err("cannot navigate while an operation is active".to_string());
}
let result = lane
.navigate_tree(target_id, false, None, None)
.await
.map_err(|error| error.to_string())?;
let (outcome, leaf_id, error) = match result.outcome {
rpi_harness::agent_harness::NavigationOutcome::Completed { new_leaf_id, .. } => {
("completed", new_leaf_id, None)
}
rpi_harness::agent_harness::NavigationOutcome::Declined { leaf_id } => {
("declined", leaf_id, None)
}
rpi_harness::agent_harness::NavigationOutcome::Aborted { leaf_id } => {
("aborted", leaf_id, None)
}
rpi_harness::agent_harness::NavigationOutcome::Failed { leaf_id, error } => {
("failed", leaf_id, Some(error.message))
}
};
Ok(serde_json::json!({
"runId": result.run_id,
"outcome": outcome,
"leafId": leaf_id,
"error": error,
}))
}
async fn rpc_context_usage(lane: &Arc<dyn AgentLane>, harness: &AgentHarness) -> serde_json::Value {
use rpi_harness::compaction::{build_session_context, estimate_context_tokens};
use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
let entries = harness
.find_entries_on_branch(
&EntryQuery {
order: Some(EntryOrder::OldestFirst),
..Default::default()
},
&BranchBounds::default(),
)
.await
.unwrap_or_default();
let estimate = estimate_context_tokens(&build_session_context(&entries).messages);
let window = lane
.get_model()
.await
.ok()
.map(|model| model.context_window)
.unwrap_or(0);
let settings = harness.compaction_settings();
let window = window as i64;
let threshold = window - settings.reserve_tokens;
let percent = if window > 0 {
(estimate.tokens as f64) * 100.0 / (window as f64)
} else {
0.0
};
serde_json::json!({
"tokens": estimate.tokens,
"window": window,
"threshold": threshold,
"percent": (percent * 10.0).round() / 10.0,
"reserveTokens": settings.reserve_tokens,
"keepRecentTokens": settings.keep_recent_tokens,
"compactionEnabled": settings.enabled,
"estimated": estimate.last_usage_index.is_none(),
"trailingTokens": estimate.trailing_tokens,
})
}
fn page_window(
total: usize,
limit: usize,
before: Option<usize>,
) -> (std::ops::Range<usize>, bool) {
match before {
Some(index) => {
let start = index.saturating_sub(limit);
(start..index, start > 0)
}
None => {
let skip = total.saturating_sub(limit);
(skip..total, skip > 0)
}
}
}
async fn rpc_get_messages(
lane: &Arc<dyn AgentLane>,
harness: &AgentHarness,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
use rpi_harness::session::types::{BranchBounds, Entry, EntryOrder, EntryQuery};
const DEFAULT_LIMIT: usize = 400;
let limit = msg
.get("limit")
.and_then(|value| value.as_u64())
.map(|value| value as usize)
.unwrap_or(DEFAULT_LIMIT)
.max(1);
let before_id = msg
.get("beforeId")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(str::to_owned);
let entries = harness
.find_entries_on_branch(
&EntryQuery {
order: Some(EntryOrder::OldestFirst),
..Default::default()
},
&BranchBounds::default(),
)
.await
.map_err(|error| error.to_string())?;
let mut items: Vec<(String, serde_json::Value)> = Vec::new();
for entry in entries {
match entry {
Entry::Message(entry) => {
let mut value = serde_json::to_value(&entry.message).map_err(|e| e.to_string())?;
value["entryId"] = serde_json::json!(entry.base.id);
items.push((entry.base.id, value));
}
Entry::Compaction(compaction) => items.push((
compaction.base.id.clone(),
serde_json::json!({
"role": "compaction",
"entryId": compaction.base.id,
"tokensBefore": compaction.tokens_before,
"retainedMessages": compaction.retained_tail.len(),
"timestamp": compaction.base.timestamp,
}),
)),
_ => {}
}
}
let total = items.len();
let before_index = match &before_id {
Some(before) => match items.iter().position(|(id, _)| id == before) {
Some(index) => Some(index),
None => return Err(format!("beforeId not found: {before}")),
},
None => None,
};
let (range, has_more) = page_window(total, limit, before_index);
let window = items[range].to_vec();
let first_id = window.first().map(|(id, _)| id.clone());
let last_id = window.last().map(|(id, _)| id.clone());
let messages = window
.iter()
.map(|(_, value)| value.clone())
.collect::<Vec<_>>();
let leaf_id = lane.get_leaf_id().await.ok().flatten();
let pending_resume = lane.has_pending_resume().await;
Ok(serde_json::json!({
"messages": messages,
"total": total,
"truncated": has_more,
"limit": limit,
"firstId": first_id,
"lastId": last_id,
"hasMore": has_more,
"beforeId": before_id,
"leafId": leaf_id,
"pendingResume": pending_resume,
}))
}
fn rpc_auth_provider_ids(model_catalog: &[rpi_ai::Model]) -> std::collections::BTreeSet<String> {
let mut providers =
std::collections::BTreeSet::from(["anthropic".to_string(), "openai".to_string()]);
providers.extend(model_catalog.iter().map(|model| model.provider.clone()));
if let Ok(models) = crate::config::load_models_config() {
providers.extend(models.providers.keys().cloned());
}
providers
}
fn rpc_get_auth_status(
model_catalog: &[rpi_ai::Model],
oauth_snapshot: Option<&Arc<rpi_extensions::RegistrySnapshot>>,
) -> serde_json::Value {
let store = crate::config::read_auth().unwrap_or_default();
let models = crate::config::load_models_config().unwrap_or_default();
let manifests = oauth_snapshot.map_or_else(Vec::new, |snapshot| {
snapshot
.providers()
.iter()
.filter_map(|provider| snapshot.provider_manifest(&provider.provider_id).cloned())
.collect()
});
let auth_states = crate::credentials::statuses(&models, &store, &manifests);
let providers = rpc_auth_provider_ids(model_catalog)
.into_iter()
.map(|provider| {
let credential = store.get(&provider);
let state = auth_states.get(&provider).cloned().unwrap_or(
rpi_runtime::credentials::AuthState {
status: rpi_runtime::credentials::AuthStatus::Missing,
source: crate::credentials::CredentialSource::Missing,
},
);
let oauth = matches!(credential, Some(crate::config::Credential::Oauth { .. }));
let oauth_available =
oauth_snapshot.is_some_and(|snapshot| snapshot.oauth_provider(&provider).is_some());
serde_json::json!({
"provider": provider,
"ready": state.is_ready(),
"source": state.source.as_json_str(),
"status": state.status,
"oauth": oauth,
"oauthAvailable": oauth_available,
})
})
.collect::<Vec<_>>();
serde_json::json!({"providers": providers})
}
fn rpc_oauth(
snapshot: &Option<Arc<rpi_extensions::RegistrySnapshot>>,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let snapshot = snapshot
.as_ref()
.ok_or_else(|| "OAuth is unavailable without an extension provider".to_string())?;
let provider = msg
.get("provider")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "oauth requires a provider".to_string())?;
let action = msg
.get("action")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| matches!(*value, "begin" | "exchange" | "refresh" | "revoke"))
.ok_or_else(|| "oauth requires a valid action".to_string())?;
let input = msg
.get("input")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let raw = rpi_extensions::request_oauth(snapshot, provider, action, input)
.map_err(|_| "OAuth provider request failed".to_string())?;
let response = rpi_extensions::OAuthActionResponse::parse(raw)
.map_err(|_| "OAuth provider returned an invalid response".to_string())?;
match response.kind {
rpi_extensions::OAuthResponseKind::Credential => {
let credential = response
.credential()
.map_err(|_| "OAuth provider returned an invalid credential".to_string())?
.ok_or_else(|| "OAuth provider returned no credential".to_string())?;
crate::config::upsert_oauth_token(
provider,
credential.access,
credential.refresh,
credential.expires_at,
)
.map_err(|_| "OAuth credentials could not be saved".to_string())?;
Ok(serde_json::json!({
"provider": provider,
"status": "authenticated"
}))
}
rpi_extensions::OAuthResponseKind::Interaction => Ok(serde_json::json!({
"provider": provider,
"status": "interaction",
"display": oauth_display(&response.payload)
})),
rpi_extensions::OAuthResponseKind::Success => Ok(serde_json::json!({
"provider": provider,
"status": "success"
})),
rpi_extensions::OAuthResponseKind::Error => {
Err("OAuth provider rejected the request".to_string())
}
}
}
fn oauth_display(payload: &serde_json::Value) -> serde_json::Value {
let display = payload.get("display").unwrap_or(payload);
let Some(object) = display.as_object() else {
return serde_json::json!({});
};
let mut safe = serde_json::Map::new();
for key in [
"title",
"url",
"instructions",
"user_code",
"verification_uri",
] {
if let Some(value) = object.get(key) {
safe.insert(key.to_string(), value.clone());
}
}
serde_json::Value::Object(safe)
}
async fn rpc_set_api_key(msg: &serde_json::Value) -> Result<serde_json::Value, String> {
let provider = msg
.get("provider")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "set_api_key requires a provider".to_string())?;
let api_key = msg
.get("apiKey")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "set_api_key requires a non-empty apiKey".to_string())?;
crate::config::upsert_credential(
provider,
crate::config::Credential::ApiKey {
key: Some(api_key.to_string()),
env: None,
},
)
.map_err(|error| error.to_string())?;
Ok(serde_json::json!({
"provider": provider,
"ready": true,
"source": "auth.json",
"requiresRestart": true,
}))
}
fn rpc_get_available_models(catalog: &[rpi_ai::Model]) -> serde_json::Value {
let models = catalog
.iter()
.map(crate::rpc::protocol::model_descriptor)
.collect::<Vec<_>>();
serde_json::json!({"models": models})
}
async fn rpc_get_available_thinking_levels(lane: &Arc<dyn AgentLane>) -> serde_json::Value {
let levels = lane
.get_model()
.await
.map(|model| model.supported_thinking_levels())
.unwrap_or_default();
serde_json::json!({"levels": levels})
}
async fn rpc_get_available_tools(
lane: &Arc<dyn AgentLane>,
harness: &AgentHarness,
) -> serde_json::Value {
let active = lane.get_active_tools().await.unwrap_or_default();
let tools = harness
.get_tools()
.await
.map(|tools| {
tools
.iter()
.map(|tool| tool.tool.schema().name.clone())
.collect::<Vec<_>>()
})
.unwrap_or_else(|_| active.clone());
serde_json::json!({"tools": tools, "activeTools": active})
}
async fn rpc_set_model(
lane: &Arc<dyn AgentLane>,
catalog: &[rpi_ai::Model],
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let id = msg
.get("modelId")
.or_else(|| msg.get("model"))
.and_then(|v| v.as_str())
.ok_or("set_model requires `modelId`")?;
let provider = msg.get("provider").and_then(|v| v.as_str());
let model = catalog
.iter()
.find(|m| {
m.id.eq_ignore_ascii_case(id)
&& provider.is_none_or(|provider| m.provider.eq_ignore_ascii_case(provider))
})
.cloned()
.ok_or_else(|| format!("unknown model: {id}"))?;
lane.set_model(model).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"modelId": id, "provider": provider}))
}
async fn rpc_set_thinking_level(
lane: &Arc<dyn AgentLane>,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let value = msg
.get("level")
.or_else(|| msg.get("thinkingLevel"))
.ok_or("set_thinking_level requires `level`")?;
let level: rpi_ai::types::ThinkingLevel =
serde_json::from_value(value.clone()).map_err(|_| {
"invalid thinking level (off|minimal|low|medium|high|xhigh|max)".to_string()
})?;
lane.set_thinking_level(level)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"thinkingLevel": level}))
}
async fn rpc_set_active_tools(
lane: &Arc<dyn AgentLane>,
msg: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let tools = msg
.get("tools")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect::<Vec<_>>()
})
.ok_or("set_active_tools requires a `tools` array")?;
lane.set_active_tools(tools.clone())
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({"activeTools": tools}))
}
fn agent_event_json(event: &AgentEvent) -> serde_json::Value {
crate::rpc::events::project(event)
}
fn emit_json_event(event: &HarnessEvent) {
let line = match event {
HarnessEvent::CompactionProgress(e) => {
serde_json::json!({ "type": "compaction_progress", "lane": e.lane, "manual": e.manual, "active": e.active })
}
HarnessEvent::RunStart(e) => serde_json::json!({
"type": "run_start",
"lane": e.lane,
"runId": e.run_id,
}),
HarnessEvent::RunEnd(e) => serde_json::json!({
"type": "run_end",
"lane": e.lane,
"runId": e.run_id,
"outcome": run_end_outcome_str(e.outcome),
"leafId": e.leaf_id,
}),
};
println!("{line}");
}
fn run_end_outcome_str(o: RunEndOutcome) -> &'static str {
match o {
RunEndOutcome::Completed => "completed",
RunEndOutcome::Aborted => "aborted",
RunEndOutcome::Failed => "failed",
}
}
pub async fn interactive(
harness: &AgentHarness,
event_rx: Option<tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>>,
args: &Args,
model_catalog: Vec<rpi_ai::Model>,
initial: Option<String>,
extra_messages: &[String],
initial_images: Vec<ImageContent>,
theme: Option<&str>,
no_themes: bool,
reload_context: &crate::session::ReloadContext,
) -> i32 {
if let Some(reason) = crate::session::dispatch_session_event_async(
reload_context,
rpi_plugin_sdk::EventTag::BeforeTuiStart,
)
.await
{
eprintln!("[rpi] startup vetoed by extension: {reason}");
return crate::app::EXIT_VETOED;
}
let force_tui = std::env::var("RPI_FORCE_TUI")
.map(|v| v == "1")
.unwrap_or(false);
let code = if force_tui || crate::interactive_tui::is_tui_supported() {
crate::interactive_tui::interactive_tui(
harness,
event_rx,
args,
model_catalog,
initial,
extra_messages,
initial_images,
theme,
no_themes,
reload_context,
)
.await
} else {
interactive_repl(harness, args, initial, extra_messages, initial_images).await
};
if let Some(reason) = crate::session::dispatch_session_event_async(
reload_context,
rpi_plugin_sdk::EventTag::SessionShutdown,
)
.await
{
tracing::warn!(
"[rpi] extension vetoed SessionShutdown (ignored, session closing): {reason}"
);
}
code
}
pub async fn interactive_repl(
harness: &AgentHarness,
#[allow(unused_variables)] args: &Args,
initial: Option<String>,
extra_messages: &[String],
initial_images: Vec<ImageContent>,
) -> i32 {
let lane: Arc<dyn AgentLane> = harness.lane("main");
let stdin = std::io::stdin();
let is_tty = stdin.is_terminal();
if is_tty {
println!(
"rpi interactive (v1 minimal REPL). Type /exit to quit, /abort to cancel a run.\n"
);
}
let mut prompts: Vec<String> = Vec::new();
if let Some(init) = initial {
prompts.push(init);
}
for m in extra_messages {
prompts.push(m.clone());
}
let mut images = initial_images;
for prompt in prompts {
if let Err(code) = run_one(&lane, &prompt, std::mem::take(&mut images)).await {
return code;
}
}
let mut line = String::new();
loop {
if is_tty {
print!("> ");
let _ = std::io::stdout().flush();
}
line.clear();
match stdin.lock().read_line(&mut line) {
Ok(0) => break, Ok(_) => {}
Err(_) => break,
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed == "/exit" || trimmed == "/quit" {
break;
}
if trimmed == "/abort" {
let _ = lane.abort().await;
eprintln!("(aborted)");
continue;
}
if let Err(code) = run_one(&lane, trimmed, Vec::new()).await {
return code;
}
}
0
}
async fn run_one(
lane: &Arc<dyn AgentLane>,
prompt: &str,
images: Vec<ImageContent>,
) -> Result<(), i32> {
match lane.prompt_text(prompt, images).await {
Ok(result) => {
match &result.outcome {
HarnessRunOutcome::Completed { final_message, .. }
| HarnessRunOutcome::Aborted { final_message, .. } => {
let text = assistant_text(final_message);
if !text.is_empty() {
println!("{text}");
}
}
HarnessRunOutcome::Failed {
error,
final_message,
..
} => {
if let Some(m) = final_message {
if let Some(em) = &m.error_message {
eprintln!("error: {em}");
}
}
eprintln!("run failed: {error:?}");
}
HarnessRunOutcome::Suspended { .. } => {
eprintln!("run suspended (deferred) — resume not supported in v1");
}
}
Ok(())
}
Err(e) => {
eprintln!("prompt rejected: {e}");
Err(1)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rpi_agent::events::AgentEvent;
use rpi_agent::types::AgentToolResult;
use rpi_ai::types::{
AssistantMessage, Content, StopReason, TextContent, TextContentType, Usage,
};
use rpi_harness::session::types::OperationError;
#[test]
fn rpc_auth_status_shares_header_readiness_and_expired_token_state() {
let _guard = crate::config::test_support::env_lock().lock().unwrap();
struct RestoreConfig(Option<std::ffi::OsString>);
impl Drop for RestoreConfig {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
}
}
}
let _restore = RestoreConfig(std::env::var_os(crate::config::CONFIG_DIR_ENV));
let directory = tempfile::tempdir().unwrap();
std::env::set_var(crate::config::CONFIG_DIR_ENV, directory.path());
std::fs::write(directory.path().join("models.json"), serde_json::json!({"providers": {
"header-gateway": {"api":"openai-completions", "headers":{"Authorization":"Bearer header-secret"}, "models":[{"id":"model"}]},
"expired-account": {"api":"openai-completions", "models":[]}
}}).to_string()).unwrap();
std::fs::write(directory.path().join("auth.json"), serde_json::json!({
"expired-account": {"type":"oauth", "access":"expired-secret", "refresh":"refresh-secret", "expires":1}
}).to_string()).unwrap();
let status = rpc_get_auth_status(&[], None);
let providers = status["providers"].as_array().unwrap();
let header = providers
.iter()
.find(|row| row["provider"] == "header-gateway")
.unwrap();
assert_eq!(header["ready"], true);
assert_eq!(header["source"], "headers");
assert_eq!(header["status"], "ready");
let expired = providers
.iter()
.find(|row| row["provider"] == "expired-account")
.unwrap();
assert_eq!(expired["ready"], false);
assert_eq!(expired["status"], "expired");
assert!(!status.to_string().contains("secret"));
}
#[test]
fn oauth_display_drops_token_like_fields() {
let display = oauth_display(&serde_json::json!({
"display": {
"title": "Sign in",
"url": "https://example.test/authorize",
"instructions": ["Paste the code"],
"access_token": "secret-access",
"refresh_token": "secret-refresh",
}
}));
assert_eq!(display["title"], "Sign in");
assert!(display.get("access_token").is_none());
assert!(display.get("refresh_token").is_none());
}
#[derive(Clone, Default)]
struct RpcOutput(Arc<Mutex<Vec<u8>>>);
impl Write for RpcOutput {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl RpcOutput {
fn messages(&self) -> Vec<serde_json::Value> {
String::from_utf8(self.0.lock().unwrap().clone())
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
async fn wait_for(
&self,
predicate: impl Fn(&serde_json::Value) -> bool,
) -> serde_json::Value {
tokio::time::timeout(std::time::Duration::from_secs(3), async {
loop {
if let Some(message) = self.messages().into_iter().find(&predicate) {
return message;
}
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.expect("RPC message not received")
}
}
async fn rpc_test_harness(
text: &str,
tokens_per_second: f64,
) -> (AgentHarness, Arc<rpi_ai::providers::faux::FauxProvider>) {
let provider = rpi_ai::providers::faux::FauxProvider::new(
rpi_ai::providers::faux::FauxScript::new()
.with_text(text)
.with_tokens_per_second(tokens_per_second),
);
let options = rpi_harness::types::AgentHarnessOptions {
model: provider.default_model().clone(),
models: vec![provider.clone()],
..Default::default()
};
(AgentHarness::create(options).await.unwrap(), provider)
}
fn start_test_rpc(
harness: AgentHarness,
ui: rpi_extensions::UiDialogMailbox,
) -> (
tokio::io::DuplexStream,
RpcOutput,
tokio::task::JoinHandle<i32>,
) {
let (client, input) = tokio::io::duplex(4096);
let output = RpcOutput::default();
let captured = output.clone();
let task = tokio::spawn(async move {
rpc_with_io(
&harness,
None,
vec![],
ui,
None,
tokio::io::BufReader::new(input),
captured,
)
.await
});
(client, output, task)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_eof_waits_for_accepted_prompt() {
use tokio::io::AsyncWriteExt;
let (harness, _) = rpc_test_harness("completed", 100.0).await;
let ui = rpi_extensions::UiDialogMailbox::new();
let (mut client, output, task) = start_test_rpc(harness, ui.clone());
client
.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
.await
.unwrap();
client.shutdown().await.unwrap();
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap(),
0
);
let response = output
.messages()
.into_iter()
.find(|m| m["id"] == 1)
.unwrap();
assert_eq!(response["command"], "prompt");
assert_eq!(response["data"]["accepted"], true);
assert!(output.messages().iter().any(|message| {
message["type"] == "agent_settled" && message["outcome"] == "completed"
}));
assert!(!ui.is_attached());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_compaction_settlement_identifies_its_durable_operation() {
let (harness, _) = rpc_test_harness("unused", 0.0).await;
let output = RpcOutput::default();
let code = rpc_with_io(
&harness,
None,
vec![],
rpi_extensions::UiDialogMailbox::new(),
None,
tokio::io::BufReader::new(&b"{\"type\":\"compact\",\"id\":1}\n"[..]),
output.clone(),
)
.await;
assert_eq!(code, 0);
let messages = output.messages();
let settled = messages
.iter()
.find(|message| message["type"] == "agent_settled")
.unwrap();
assert_eq!(settled["outcome"], "declined");
let run_id = settled["runId"]
.as_str()
.expect("compaction settlement needs its durable run id");
let records = harness
.session()
.find_records(&Default::default())
.await
.unwrap();
assert!(records.iter().any(|record| matches!(record,
rpi_harness::session::types::LaneRecord::OperationFinished(finished)
if finished.run_id == run_id)));
}
#[tokio::test]
async fn rpc_eof_drains_queued_final_events() {
let harness = AgentHarness::create(Default::default()).await.unwrap();
let (tx, rx) = tokio::sync::broadcast::channel(256);
for _ in 0..100 {
tx.send(AgentEvent::AgentStart).unwrap();
}
tx.send(AgentEvent::AgentEnd { messages: vec![] }).unwrap();
let output = RpcOutput::default();
let code = rpc_with_io(
&harness,
Some(rx),
vec![],
rpi_extensions::UiDialogMailbox::new(),
None,
tokio::io::BufReader::new(&b""[..]),
output.clone(),
)
.await;
assert_eq!(code, 0);
let messages = output.messages();
assert_eq!(
messages.len(),
102,
"ready plus every queued event must be flushed"
);
assert_eq!(messages.last().unwrap()["type"], "agent_end");
}
struct BackpressuredOutput {
entered: Arc<std::sync::atomic::AtomicBool>,
release: Arc<std::sync::atomic::AtomicBool>,
output: RpcOutput,
}
impl Write for BackpressuredOutput {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.entered
.store(true, std::sync::atomic::Ordering::SeqCst);
while !self.release.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
self.output.write(bytes)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_output_backpressure_is_bounded_and_keeps_workers_responsive() {
use std::sync::atomic::{AtomicBool, Ordering};
let release = Arc::new(AtomicBool::new(false));
struct ReleaseOnDrop(Arc<AtomicBool>);
impl Drop for ReleaseOnDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let _release = ReleaseOnDrop(release.clone());
let entered = Arc::new(AtomicBool::new(false));
let output = RpcOutput::default();
let (writer, task, _) = start_rpc_writer(BackpressuredOutput {
entered: entered.clone(),
release: release.clone(),
output: output.clone(),
});
write_line(&writer, &serde_json::json!({"id": 0})).await;
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while !entered.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let producer_writer = writer.clone();
let producer = tokio::spawn(async move {
for id in 1..=100 {
write_line(&producer_writer, &serde_json::json!({"id": id})).await;
}
});
tokio::time::timeout(
std::time::Duration::from_millis(150),
tokio::time::sleep(std::time::Duration::from_millis(20)),
)
.await
.unwrap();
assert!(
!producer.is_finished(),
"the bounded queue must apply backpressure"
);
release.store(true, Ordering::SeqCst);
producer.await.unwrap();
drop(writer);
task.await.unwrap().unwrap();
let messages = output.messages();
assert_eq!(messages.len(), 101);
for (index, message) in messages.iter().enumerate() {
assert_eq!(message["id"], index);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_completion_response_follows_final_event_under_backpressure() {
use std::sync::atomic::{AtomicBool, Ordering};
let (harness, _) = rpc_test_harness("completed", 0.0).await;
let (emitter, events) = rpi_agent::events::BroadcastEmitter::new(256);
harness
.set_agent_emitter(Some(Arc::new(emitter)))
.await
.unwrap();
let output = RpcOutput::default();
let release = Arc::new(AtomicBool::new(false));
let release_writer = release.clone();
let release_thread = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
release_writer.store(true, Ordering::SeqCst);
});
let code = rpc_with_io(
&harness,
Some(events),
vec![],
rpi_extensions::UiDialogMailbox::new(),
None,
tokio::io::BufReader::new(
&b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n"[..],
),
BackpressuredOutput {
entered: Arc::new(AtomicBool::new(false)),
release,
output: output.clone(),
},
)
.await;
release_thread.join().unwrap();
assert_eq!(code, 0);
let messages = output.messages();
let end = messages
.iter()
.position(|message| message["type"] == "agent_end")
.unwrap();
let settled = messages
.iter()
.position(|message| message["type"] == "agent_settled")
.unwrap();
assert!(end < settled);
let run_id = messages[settled]["runId"].as_str().unwrap();
assert!(!run_id.is_empty(), "settlement identifies its durable run");
let records = harness
.session()
.find_records(&Default::default())
.await
.unwrap();
assert!(records.iter().any(|record| matches!(record,
rpi_harness::session::types::LaneRecord::OperationFinished(finished)
if finished.run_id == run_id)));
assert!(messages.iter().any(|message| {
message["type"] == "response" && message["data"]["accepted"] == true
}));
assert!(messages.iter().any(|message| {
message["type"] == "agent_settled" && message["outcome"] == "completed"
}));
}
#[tokio::test]
async fn rpc_output_failure_closes_without_waiting_for_more_input() {
struct BrokenOutput;
impl Write for BrokenOutput {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"closed client output",
))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let harness = AgentHarness::create(Default::default()).await.unwrap();
let (_client, input) = tokio::io::duplex(4096);
let ui = rpi_extensions::UiDialogMailbox::new();
let code = tokio::time::timeout(
std::time::Duration::from_secs(2),
rpc_with_io(
&harness,
None,
vec![],
ui.clone(),
None,
tokio::io::BufReader::new(input),
BrokenOutput,
),
)
.await
.unwrap();
assert_eq!(code, 1);
assert!(!ui.is_attached());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_stop_aborts_and_joins_active_prompt() {
use tokio::io::AsyncWriteExt;
let (harness, provider) = rpc_test_harness("still streaming the response", 20.0).await;
let lane = harness.lane("main");
let (mut client, output, task) =
start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
client
.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(3), async {
while provider
.state()
.call_count
.load(std::sync::atomic::Ordering::SeqCst)
== 0
{
tokio::task::yield_now().await;
}
})
.await
.unwrap();
client
.write_all(b"{\"type\":\"stop\",\"id\":2}\n")
.await
.unwrap();
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap(),
0
);
let messages = output.messages();
assert!(messages.iter().any(|message| {
message["type"] == "agent_settled"
&& (message["outcome"] == "aborted" || message["outcome"] == "failed")
}));
assert_eq!(
messages.iter().find(|m| m["id"] == 2).unwrap()["data"]["stopped"],
true
);
assert!(
lane.abort().await.is_err(),
"RPC returned with an active lane"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_immediate_stop_cancels_prompt_before_or_after_lane_start() {
use tokio::io::AsyncWriteExt;
let (harness, _) = rpc_test_harness("not completed", 20.0).await;
let lane = harness.lane("main");
let (mut client, output, task) =
start_test_rpc(harness, rpi_extensions::UiDialogMailbox::new());
client.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n{\"type\":\"stop\",\"id\":2}\n").await.unwrap();
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap(),
0
);
let messages = output.messages();
assert!(messages.iter().any(|message| {
message["type"] == "agent_settled"
&& (message["outcome"] == "aborted" || message["outcome"] == "failed")
}));
assert!(lane.abort().await.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_dialog_answers_and_ping_are_read_while_prompt_runs() {
use tokio::io::AsyncWriteExt;
let (harness, _) =
rpc_test_harness("the streamed response finishes after the dialog", 30.0).await;
let ui = rpi_extensions::UiDialogMailbox::new();
let (mut client, output, task) = start_test_rpc(harness, ui.clone());
output.wait_for(|m| m["type"] == "ready").await;
client
.write_all(b"{\"type\":\"prompt\",\"id\":1,\"message\":\"test\"}\n")
.await
.unwrap();
ui.handle(serde_json::json!({"requestId":"approval", "toolCallId":"call", "ui":{}}))
.unwrap();
output.wait_for(|m| m["type"] == "ui_dialog_request").await;
client.write_all(b"{\"type\":\"ui_dialog_response\",\"requestId\":\"approval\",\"answer\":true}\n{\"type\":\"ping\",\"id\":2}\n").await.unwrap();
output.wait_for(|m| m["id"] == 2).await;
assert_eq!(ui.poll("approval").unwrap()["answer"], true);
output
.wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "approval")
.await;
client.shutdown().await.unwrap();
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(3), task)
.await
.unwrap()
.unwrap(),
0
);
assert!(output.messages().iter().any(|message| {
message["type"] == "agent_settled" && message["outcome"] == "completed"
}));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_notifies_client_when_an_approval_is_cancelled() {
use tokio::io::AsyncWriteExt;
let harness = AgentHarness::create(Default::default()).await.unwrap();
let ui = rpi_extensions::UiDialogMailbox::new();
let (mut client, output, task) = start_test_rpc(harness, ui.clone());
output.wait_for(|m| m["type"] == "ready").await;
ui.handle(serde_json::json!({"requestId":"cancelled", "toolCallId":"c", "ui":{}}))
.unwrap();
output.wait_for(|m| m["type"] == "ui_dialog_request").await;
ui.cancel_tool_call("c");
output
.wait_for(|m| m["type"] == "ui_dialog_closed" && m["requestId"] == "cancelled")
.await;
assert_eq!(ui.poll("cancelled").unwrap()["status"], "cancelled");
client.shutdown().await.unwrap();
assert_eq!(task.await.unwrap(), 0);
}
#[tokio::test]
async fn rpc_accepts_native_dialog_responses_without_consuming_plugin_answers() {
use crate::remote::protocol::RemoteCommand;
use tokio::io::AsyncWriteExt;
let harness = AgentHarness::create(Default::default()).await.unwrap();
let ui = rpi_extensions::UiDialogMailbox::new();
let (mut client, output, task) = start_test_rpc(harness, ui.clone());
output.wait_for(|m| m["type"] == "ready").await;
for (request_id, answer, response_id) in [
(
"answered",
Some(serde_json::json!({"values":["No"],"kind":"selector"})),
5,
),
("cancelled", None, 6),
] {
ui.handle(serde_json::json!({"requestId":request_id,"toolCallId":request_id,"ui":{}}))
.unwrap();
output
.wait_for(|m| m["type"] == "ui_dialog_request" && m["requestId"] == request_id)
.await;
let command = RemoteCommand::UiDialogResponse {
request_id: request_id.into(),
cancelled: answer.is_none(),
answer,
}
.with_id(response_id);
client
.write_all(format!("{command}\n").as_bytes())
.await
.unwrap();
let ack = output.wait_for(|m| m["id"] == response_id).await;
assert_eq!(ack["data"]["accepted"], true);
let result = ui.poll(request_id).unwrap();
if request_id == "answered" {
assert_eq!(result["answer"]["values"][0], "No");
} else {
assert_eq!(result["status"], "cancelled");
}
}
client.shutdown().await.unwrap();
assert_eq!(task.await.unwrap(), 0);
}
fn assistant(text: &str, stop: StopReason) -> AssistantMessage {
AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![Content::Text(TextContent {
kind: TextContentType,
text: text.into(),
text_signature: None,
})],
api: rpi_ai::Api::AnthropicMessages,
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
response_model: None,
response_id: None,
usage: Usage::zero(),
stop_reason: stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}
}
#[test]
fn assistant_text_concatenates_text_blocks() {
let m = assistant("hello", StopReason::Stop);
assert_eq!(assistant_text(&m), "hello");
}
#[test]
fn outcome_exit_code_maps_failed_aborted_to_1() {
let failed = HarnessRunOutcome::Failed {
leaf_id: "l".into(),
error: OperationError {
code: "boom".into(),
message: "boom".into(),
},
final_entry_id: None,
final_message: None,
};
assert_eq!(outcome_exit_code(&failed), 1);
let completed = HarnessRunOutcome::Completed {
leaf_id: "l".into(),
final_entry_id: "e".into(),
final_message: assistant("ok", StopReason::Stop),
};
assert_eq!(outcome_exit_code(&completed), 0);
}
#[test]
fn run_end_outcome_str_roundtrip() {
assert_eq!(run_end_outcome_str(RunEndOutcome::Completed), "completed");
assert_eq!(run_end_outcome_str(RunEndOutcome::Aborted), "aborted");
assert_eq!(run_end_outcome_str(RunEndOutcome::Failed), "failed");
}
#[test]
fn agent_event_projection_keeps_terminal_and_tool_payloads() {
let end = agent_event_json(&AgentEvent::AgentEnd { messages: vec![] });
assert_eq!(end["type"], "agent_end");
assert_eq!(end["messages"], serde_json::json!([]));
let tool = agent_event_json(&AgentEvent::ToolExecutionEnd {
tool_call_id: "call-1".into(),
tool_name: "read".into(),
result: AgentToolResult::text("hello"),
is_error: false,
});
assert_eq!(tool["type"], "tool_execution_end");
assert_eq!(tool["result"]["content"][0]["text"], "hello");
assert_eq!(tool["result"]["terminate"], false);
let retry = agent_event_json(&AgentEvent::RetryScheduled {
attempt: 3,
max_retries: 10,
delay_ms: 8_000,
error: "503 service unavailable".into(),
});
assert_eq!(retry["type"], "retry_scheduled");
assert_eq!(retry["attempt"], 3);
assert_eq!(retry["maxRetries"], 10);
assert_eq!(retry["delayMs"], 8_000);
assert_eq!(retry["error"], "503 service unavailable");
}
}
#[cfg(test)]
mod page_window_tests {
use super::page_window;
#[test]
fn tail_is_kept_without_before_id() {
assert_eq!(page_window(12, 5, None), (7..12, true));
assert_eq!(page_window(3, 5, None), (0..3, false));
}
#[test]
fn before_id_opens_the_window_immediately_before_it() {
assert_eq!(page_window(12, 4, Some(10)), (6..10, true));
assert_eq!(page_window(12, 10, Some(10)), (0..10, false));
}
#[test]
fn limit_larger_than_the_branch_never_underflows() {
assert_eq!(page_window(5, 100, Some(2)), (0..2, false));
assert_eq!(page_window(5, 100, None), (0..5, false));
}
}