use anyhow::Context;
use futures::StreamExt;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use synaps_cli::core::config::load_config;
use synaps_cli::runtime::openai::registry::{list_models, list_providers};
use synaps_cli::{
core::rpc_dispatch::{
accumulate_usage, build_tools_list_body, map_stream_event, parse_frame,
MAX_FRAME_BYTES,
},
core::rpc_protocol::{RpcAttachment, RpcCommand, RpcEvent, TurnUsage, RPC_PROTOCOL_VERSION},
engine::reactor::{
auto_turn_cap_reached, claim_auto_turn_with_cap, drain_event_queue,
event_payload_from_drained, spawn_prompt_registration_check, wake_action_with_cap,
WakeAction,
},
engine::setup::{self, EngineOpts},
Runtime, Session, SessionEvent, StreamEvent,
};
const WRITER_CHAN_CAP: usize = 256;
struct InFlight {
prompt_id: String,
cancel: CancellationToken,
handle: JoinHandle<()>,
}
struct RpcState {
runtime: Runtime,
session: Session,
context_head: synaps_cli::engine::session::ContextHeadPersistence,
api_messages: Vec<synaps_cli::SharedMessage>,
total_input_tokens: u64,
total_output_tokens: u64,
session_cost: f64,
in_flight: Option<InFlight>,
pending_events: Vec<String>,
consecutive_auto_turns: u32,
auto_turn_pending: bool,
events_auto_turn: bool,
auto_turn_cap: u32,
save_owed: bool,
#[allow(dead_code)] session_lock: Option<synaps_cli::core::session_lock::SessionLock>,
}
impl RpcState {
async fn save_session(&mut self) {
if self.context_head.is_blocked(&self.session) || self.api_messages.is_empty() {
return;
}
self.session.api_messages = self.api_messages.clone();
self.session.total_input_tokens = self.total_input_tokens;
self.session.total_output_tokens = self.total_output_tokens;
self.session.session_cost = self.session_cost;
self.session.model = self.runtime.model().to_string();
self.session.system_prompt = self.runtime.system_prompt().map(|s| s.to_string());
self.session.thinking_level = self.runtime.thinking_level().to_string();
self.session.updated_at = chrono::Utc::now();
self.session.auto_title();
if let Err(e) = self.session.save().await {
tracing::error!(error = %e, "failed to save session");
}
}
async fn persist_context_head(
&mut self,
session_id: &str,
messages: Vec<synaps_cli::SharedMessage>,
) -> std::io::Result<()> {
let mut candidate = self.session.clone();
candidate.api_messages = messages;
candidate.total_input_tokens = self.total_input_tokens;
candidate.total_output_tokens = self.total_output_tokens;
candidate.session_cost = self.session_cost;
candidate.model = self.runtime.model().to_string();
candidate.system_prompt = self.runtime.system_prompt().map(str::to_string);
candidate.thinking_level = self.runtime.thinking_level().to_string();
candidate.updated_at = chrono::Utc::now();
candidate.auto_title();
self.context_head
.persist(
&mut self.session,
&mut self.api_messages,
session_id,
candidate,
)
.await
}
fn is_busy(&self) -> bool {
self.in_flight.is_some() || self.auto_turn_pending
}
fn is_streaming(&self) -> bool {
self.in_flight.is_some()
}
}
fn encode_event(ev: &RpcEvent) -> String {
serde_json::to_string(ev).unwrap_or_else(|e| {
tracing::error!(error = %e, "BUG: failed to serialise RpcEvent");
format!(r#"{{"type":"error","message":"internal serialisation error: {e}"}}"#)
})
}
fn spawn_writer(mut rx: mpsc::Receiver<RpcEvent>) -> JoinHandle<()> {
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
println!("{}", encode_event(&ev));
}
})
}
async fn mark_interrupted(
state: &Mutex<RpcState>,
completion: &agent_engine::runtime::TurnCompletion,
) {
if completion.completed() {
return;
}
let mut st = state.lock().await;
if agent_engine::engine::interrupt::append_marker(
&mut st.api_messages,
agent_engine::engine::interrupt::InterruptReason::User,
) {
st.save_session().await;
}
}
async fn terminal_flush(state: &Mutex<RpcState>, allow_chain: bool) -> Option<String> {
let mut st = state.lock().await;
st.in_flight = None;
st.auto_turn_pending = false;
let to_inject = std::mem::take(&mut st.pending_events);
let had_buffered = !to_inject.is_empty();
for formatted in to_inject {
st.api_messages.push(std::sync::Arc::new(
serde_json::json!({"role": "user", "content": formatted}),
));
}
if had_buffered || std::mem::take(&mut st.save_owed) {
st.save_session().await;
}
if allow_chain
&& had_buffered
&& st.events_auto_turn
&& !st.context_head.is_blocked(&st.session)
&& !auto_turn_cap_reached(st.consecutive_auto_turns, st.auto_turn_cap)
&& st
.api_messages
.last()
.map(|m| m["role"].as_str() == Some("user"))
.unwrap_or(false)
{
let cap = st.auto_turn_cap;
if claim_auto_turn_with_cap(&mut st.consecutive_auto_turns, cap) {
st.auto_turn_pending = true;
let auto_id = format!("auto:post-flush-{}", chrono::Utc::now().timestamp_millis());
return Some(auto_id);
}
}
None
}
async fn spawn_prompt(
prompt_id: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
auto_turn_tx: mpsc::UnboundedSender<String>,
) {
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let cancel_check = cancel.clone();
let turn_completion = agent_engine::runtime::TurnCompletion::new();
let pid = prompt_id.clone();
let wtx = writer_tx.clone();
let messages: Vec<synaps_cli::SharedMessage> = {
let mut st = state.lock().await;
if prompt_id.starts_with("auto:") && (!st.auto_turn_pending || st.in_flight.is_some()) {
tracing::warn!(
prompt_id,
auto_turn_pending = st.auto_turn_pending,
in_flight_live = st.in_flight.is_some(),
"rpc: spawn_prompt: auto-turn reservation invalidated at snapshot — aborting"
);
st.auto_turn_pending = false;
return;
}
if st.context_head.is_blocked(&st.session) {
st.auto_turn_pending = false;
let _ = wtx
.send(RpcEvent::Error {
id: Some(prompt_id.clone()),
message: "context head is unverified — start a new session or restart/resume"
.into(),
})
.await;
return;
}
st.api_messages.clone()
};
let (start_tx, start_rx) = oneshot::channel::<()>();
let state_task = Arc::clone(&state);
let handle = tokio::spawn(async move {
let state = state_task;
if start_rx.await.is_err() {
return;
}
let mut stream = {
let st = state.lock().await;
st.runtime
.run_stream_tracked(
messages,
cancel_clone,
None,
None,
false,
turn_completion.clone(),
)
.await
};
let mut usage_acc = TurnUsage {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
cache_creation_5m: None,
cache_creation_1h: None,
model: None,
};
while let Some(ev) = stream.next().await {
if let StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
session_id,
messages,
receipt,
}) = ev
{
let mut st = state.lock().await;
receipt.complete(st.persist_context_head(&session_id, messages).await);
continue;
}
if let StreamEvent::Session(SessionEvent::MessageHistory(msgs)) = ev {
let mut st = state.lock().await;
st.api_messages = msgs;
if tokio::time::timeout(
agent_engine::session::budgets::SAVE_TIMEOUT,
st.save_session(),
)
.await
.is_err()
{
tracing::warn!("rpc: round checkpoint save timed out");
st.save_owed = true;
}
continue;
}
match &ev {
StreamEvent::Session(
se @ SessionEvent::Usage {
input_tokens,
output_tokens,
..
},
) => {
accumulate_usage(&mut usage_acc, se);
let mut st = state.lock().await;
st.total_input_tokens += input_tokens;
st.total_output_tokens += output_tokens;
continue;
}
StreamEvent::Session(SessionEvent::Done) => {
let cancelled = cancel_check.is_cancelled();
let _ = wtx
.send(RpcEvent::AgentEnd {
usage: usage_acc.clone(),
})
.await;
if cancelled {
mark_interrupted(&state, &turn_completion).await;
}
let post_flush_id = terminal_flush(&state, !cancelled).await;
let resp_command = if pid.starts_with("auto:") {
"auto_turn"
} else {
"prompt"
};
let body = if cancelled {
serde_json::json!({ "ok": true, "cancelled": true })
} else {
serde_json::json!({ "ok": true })
};
let _ = wtx
.send(RpcEvent::Response {
id: pid.clone(),
command: resp_command.to_string(),
body,
})
.await;
if let Some(auto_id) = post_flush_id {
let _ = auto_turn_tx.send(auto_id);
}
return;
}
StreamEvent::Session(SessionEvent::Error(msg)) => {
if cancel_check.is_cancelled() {
let _ = wtx
.send(RpcEvent::AgentEnd {
usage: usage_acc.clone(),
})
.await;
let resp_command = if pid.starts_with("auto:") {
"auto_turn"
} else {
"prompt"
};
let _ = wtx
.send(RpcEvent::Response {
id: pid.clone(),
command: resp_command.to_string(),
body: serde_json::json!({ "ok": true, "cancelled": true }),
})
.await;
mark_interrupted(&state, &turn_completion).await;
let _ = terminal_flush(&state, false).await;
return;
}
let _ = wtx
.send(RpcEvent::Error {
id: Some(pid.clone()),
message: msg.message.clone(),
})
.await;
let _ = wtx
.send(RpcEvent::AgentEnd {
usage: usage_acc.clone(),
})
.await;
let resp_command = if pid.starts_with("auto:") {
"auto_turn"
} else {
"prompt"
};
let _ = wtx
.send(RpcEvent::Response {
id: pid.clone(),
command: resp_command.to_string(),
body: serde_json::json!({
"ok": false,
"error": msg.message,
"outcome": msg.outcome,
}),
})
.await;
let _ = terminal_flush(&state, false).await;
return;
}
_ => {}
}
if let Some(rpc_ev) = map_stream_event(&ev) {
if wtx.send(rpc_ev).await.is_err() {
tracing::warn!("writer channel closed; aborting stream early");
break;
}
}
}
let cancelled = cancel_check.is_cancelled();
let _ = wtx
.send(RpcEvent::AgentEnd {
usage: usage_acc.clone(),
})
.await;
let body = if cancelled {
serde_json::json!({ "ok": true, "cancelled": true })
} else {
serde_json::json!({
"ok": false,
"error": "stream ended without Done"
})
};
let resp_command = if pid.starts_with("auto:") {
"auto_turn"
} else {
"prompt"
};
let _ = wtx
.send(RpcEvent::Response {
id: pid.clone(),
command: resp_command.to_string(),
body,
})
.await;
if cancelled {
mark_interrupted(&state, &turn_completion).await;
}
let _ = terminal_flush(&state, false).await;
});
{
let mut st = state.lock().await;
let is_auto = prompt_id.starts_with("auto:");
let in_flight_live = st.in_flight.is_some();
if !spawn_prompt_registration_check(is_auto, &mut st.auto_turn_pending, in_flight_live) {
tracing::warn!(
prompt_id,
auto_turn_pending = st.auto_turn_pending,
in_flight_live,
"rpc: spawn_prompt: Abort cleared reservation between snapshot and registration — dropping task"
);
st.auto_turn_pending = false;
drop(start_tx);
drop(handle);
return;
}
st.in_flight = Some(InFlight {
prompt_id,
cancel,
handle,
});
st.auto_turn_pending = false;
}
let _ = start_tx.send(());
}
fn rpc_attachment_paths(attachments: &[RpcAttachment]) -> Result<Vec<std::path::PathBuf>, String> {
let mut paths = Vec::with_capacity(attachments.len());
for a in attachments {
let path = std::path::PathBuf::from(&a.path);
if !path.is_absolute() {
return Err(format!("attachment path must be absolute: {}", a.path));
}
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(format!("attachment path must not contain ..: {}", a.path));
}
paths.push(path);
}
Ok(paths)
}
async fn load_rpc_user_content(
message: &str,
attachments: &[RpcAttachment],
model: &str,
existing_messages: &[std::sync::Arc<serde_json::Value>],
) -> Result<(serde_json::Value, usize), String> {
let paths = rpc_attachment_paths(attachments)?;
if paths.is_empty() {
return Ok((serde_json::Value::String(message.to_string()), 0));
}
let content = agent_engine::attachments::build_user_content(message, &paths).await?;
let count = paths.len();
let candidate = std::sync::Arc::new(serde_json::json!({"role": "user", "content": content}));
let mut proposed = existing_messages.to_vec();
proposed.push(candidate);
agent_engine::runtime::attachments::validate_messages(model, &proposed)?;
Ok((content, count))
}
async fn handle_prompt(
id: String,
message: String,
attachments: Vec<RpcAttachment>,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
auto_turn_tx: mpsc::UnboundedSender<String>,
) {
{
let st = state.lock().await;
if st.is_busy() {
tracing::warn!(id, "rejected concurrent prompt — session busy");
let _ = writer_tx
.send(RpcEvent::Error {
id: Some(id),
message: "another prompt is in flight; abort first".to_string(),
})
.await;
return;
}
}
let (content, attachment_count) = if attachments.is_empty() {
(serde_json::Value::String(message.clone()), 0)
} else {
let (model, msgs) = {
let st = state.lock().await;
(st.runtime.model().to_string(), st.api_messages.clone())
};
match load_rpc_user_content(&message, &attachments, &model, &msgs).await {
Ok(result) => result,
Err(e) => {
let _ = writer_tx
.send(RpcEvent::Error {
id: Some(id),
message: e,
})
.await;
return;
}
}
};
{
let st = state.lock().await;
if st.is_busy() {
let _ = writer_tx
.send(RpcEvent::Error {
id: Some(id),
message: "session became busy during attachment load".to_string(),
})
.await;
return;
}
}
if attachment_count > 0 {
let _ = writer_tx
.send(RpcEvent::Response {
id: id.clone(),
command: "attachments.disclosure".to_string(),
body: serde_json::json!({
"count": attachment_count,
"message": format!("{attachment_count} attachment(s) loaded from local filesystem"),
}),
})
.await;
}
{
let mut st = state.lock().await;
st.consecutive_auto_turns = 0;
st.api_messages.push(std::sync::Arc::new(
serde_json::json!({"role": "user", "content": content}),
));
}
spawn_prompt(id, state.clone(), writer_tx, auto_turn_tx).await;
}
async fn handle_compact(
id: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
use synaps_cli::runtime::compaction::{
apply_compaction, CompactionPolicy, CompactionTransition,
};
let (msgs, runtime, session) = {
let st = state.lock().await;
if st.context_head.is_blocked(&st.session) {
let _ = writer_tx
.send(RpcEvent::Error {
id: Some(id),
message: "cannot compact while the context head is unverified".into(),
})
.await;
return;
}
(
st.api_messages.clone(),
st.runtime.clone(),
st.session.clone(),
)
};
let disclosure =
synaps_cli::runtime::compaction::preview_compaction_disclosure(&runtime, &msgs);
tracing::info!(disclosure = %disclosure.render_line(), "compaction disclosure");
let _ = writer_tx
.send(RpcEvent::Response {
id: id.clone(),
command: "compact.disclosure".to_string(),
body: serde_json::to_value(&disclosure).unwrap_or_default(),
})
.await;
let applied =
match synaps_cli::runtime::compaction::compact_conversation(&msgs, &runtime, None).await {
Ok(outcome) => apply_compaction(
&runtime,
&session,
&msgs,
&outcome,
CompactionTransition {
policy: CompactionPolicy::InPlace,
pending_events: Vec::new(),
queued_message: None,
hook_source: "manual".to_string(),
},
)
.await
.map(|applied| (applied, outcome.summary_text)),
Err(e) => Err(e),
};
match applied {
Ok((applied, summary)) => {
{
let mut st = state.lock().await;
st.session = applied.session;
st.api_messages = applied.api_messages;
st.save_session().await;
}
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "compact".to_string(),
body: serde_json::json!({ "summary": summary, "disclosure": disclosure }),
})
.await;
}
Err(e) => {
tracing::error!(error = %e, "compaction failed");
let _ = writer_tx
.send(RpcEvent::Error {
id: Some(id),
message: e.to_string(),
})
.await;
}
}
}
async fn handle_new_session(
id: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
{
let st = state.lock().await;
if st.is_busy() {
tracing::warn!(id, "rejected new_session — session busy");
let _ = writer_tx
.send(RpcEvent::Error {
id: Some(id),
message: "another prompt is in flight; abort first".to_string(),
})
.await;
return;
}
}
let new_session_id = {
let mut st = state.lock().await;
st.save_session().await;
let new_sess = Session::new(
st.runtime.model(),
st.runtime.thinking_level(),
st.runtime.system_prompt(),
);
let sid = new_sess.id.clone();
st.session_lock = setup::lock_session(&sid, false, "rpc").unwrap_or(None);
st.session = new_sess;
st.context_head = Default::default();
st.api_messages.clear();
st.runtime.reset_context_continuation(&sid, &[]);
st.total_input_tokens = 0;
st.total_output_tokens = 0;
st.session_cost = 0.0;
sid
};
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "new_session".to_string(),
body: serde_json::json!({ "session_id": new_session_id }),
})
.await;
}
async fn handle_get_messages(
id: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
let messages = state.lock().await.api_messages.clone();
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "get_messages".to_string(),
body: serde_json::json!({ "messages": messages }),
})
.await;
}
async fn handle_set_model(
id: String,
model: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
state.lock().await.runtime.set_model(model.clone());
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "set_model".to_string(),
body: serde_json::json!({ "model": model }),
})
.await;
}
async fn handle_get_available_models(id: String, writer_tx: mpsc::Sender<RpcEvent>) {
let providers = list_providers();
let mut models_list: Vec<serde_json::Value> = Vec::new();
for (provider_key, _provider_name, _has_key, _count) in &providers {
if let Some(models) = list_models(provider_key) {
for (model_id, model_name, _default_flag) in models {
models_list.push(serde_json::json!({
"provider": provider_key,
"model_id": model_id,
"model_name": model_name,
}));
}
}
}
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "get_available_models".to_string(),
body: serde_json::json!({ "models": models_list }),
})
.await;
}
async fn handle_abort(id: String, state: Arc<Mutex<RpcState>>, writer_tx: mpsc::Sender<RpcEvent>) {
let handle_opt = {
let mut st = state.lock().await;
st.auto_turn_pending = false;
if let Some(inf) = st.in_flight.take() {
tracing::info!(prompt_id = %inf.prompt_id, abort_id = %id, "aborted in-flight stream");
inf.cancel.cancel();
Some(inf.handle)
} else {
None
}
};
if let Some(handle) = handle_opt {
if let Err(e) = handle.await {
tracing::warn!(error = ?e, "streaming task panicked during abort");
}
}
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "abort".to_string(),
body: serde_json::json!({ "ok": true }),
})
.await;
}
async fn handle_get_session_stats(
id: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
let body = {
let st = state.lock().await;
serde_json::json!({
"input_tokens": st.total_input_tokens,
"output_tokens": st.total_output_tokens,
"cost": st.session_cost,
"message_count": st.api_messages.len(),
"model": st.runtime.model(),
"session_id": st.session.id,
})
};
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "get_session_stats".to_string(),
body,
})
.await;
}
async fn handle_get_state(
id: String,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
let body = {
let st = state.lock().await;
serde_json::json!({
"streaming": st.is_streaming(),
"model": st.runtime.model(),
"session_id": st.session.id,
"message_count": st.api_messages.len(),
})
};
let _ = writer_tx
.send(RpcEvent::Response {
id,
command: "get_state".to_string(),
body,
})
.await;
}
async fn handle_tools_list(
id: Option<String>,
state: Arc<Mutex<RpcState>>,
writer_tx: mpsc::Sender<RpcEvent>,
) {
let schema = {
let st = state.lock().await;
let registry = st.runtime.tools_shared();
let guard = registry.read().await;
guard.tools_schema().as_ref().clone()
};
let body = build_tools_list_body(&schema);
let response_id = id.unwrap_or_default();
let _ = writer_tx
.send(RpcEvent::Response {
id: response_id,
command: "tools_list".to_string(),
body,
})
.await;
}
pub async fn run(
continue_id: Option<String>,
system: Option<String>,
model: Option<String>,
profile: Option<String>,
) -> anyhow::Result<()> {
let boot = setup::boot(EngineOpts {
continue_session: continue_id.map(Some),
system,
prompt_manifest: None,
profile,
no_extensions: false,
})
.await
.context("engine boot failed")?;
let session_lock = setup::lock_session(&boot.session.id, boot.continued, "rpc")
.context("cannot continue this session")?;
let mut runtime = boot.runtime;
let (session, initial_messages) = if boot.continued && session_lock.is_some() {
let mut conv = synaps_cli::engine::session::ConversationState::from_resumed(boot.session);
conv.api_messages = boot.api_messages;
setup::recover_turn_draft(&mut conv).await;
(conv.session, conv.api_messages)
} else {
(boot.session, boot.api_messages)
};
let initial_in = boot.total_input_tokens;
let initial_out = boot.total_output_tokens;
let initial_cost = boot.session_cost;
let ext_manager = boot.ext_manager;
let background = boot.background;
if let Some(ref m) = model {
runtime.set_model(m.clone());
}
let session_id_for_hook = session.id.clone();
let (loader_tx, mut loader_rx) = mpsc::unbounded_channel();
synaps_cli::extensions::loader::spawn_discover_and_load(
Arc::clone(&ext_manager),
loader_tx,
Some(session_id_for_hook.clone()),
);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
while let Some(ev) = loader_rx.recv().await {
if matches!(
ev,
synaps_cli::extensions::loader::ExtensionLoaderEvent::Finished { .. }
) {
break;
}
}
})
.await;
let ready_session_id = session.id.clone();
let ready_model = runtime.model().to_string();
let boot_config = load_config();
let events_auto_turn = boot_config.events.auto_turn;
let auto_turn_cap = boot_config.events.auto_turn_cap;
let state = Arc::new(Mutex::new(RpcState {
runtime,
session,
context_head: Default::default(),
api_messages: initial_messages,
total_input_tokens: initial_in,
total_output_tokens: initial_out,
session_cost: initial_cost,
in_flight: None,
pending_events: Vec::new(),
consecutive_auto_turns: 0,
auto_turn_pending: false,
events_auto_turn,
auto_turn_cap,
save_owed: false,
session_lock,
}));
let (writer_tx, writer_rx) = mpsc::channel::<RpcEvent>(WRITER_CHAN_CAP);
let writer_handle = spawn_writer(writer_rx);
let (auto_turn_tx, mut auto_turn_rx) = mpsc::unbounded_channel::<String>();
{
let state_d = Arc::clone(&state);
let writer_d = writer_tx.clone();
let auto_turn_tx_d = auto_turn_tx.clone();
tokio::spawn(async move {
let eq = {
let st = state_d.lock().await;
st.runtime.event_queue().clone()
};
loop {
eq.notified().await;
let (frames, auto_turn_id): (Vec<RpcEvent>, Option<String>) = {
let mut st = state_d.lock().await;
let busy = st.is_busy();
let events_auto_turn = st.events_auto_turn;
let auto_turn_cap = st.auto_turn_cap;
let consecutive = st.consecutive_auto_turns;
let drained = {
let RpcState {
ref mut api_messages,
ref mut pending_events,
..
} = *st;
drain_event_queue(
&eq,
api_messages,
pending_events,
busy,
None, )
};
let frames: Vec<RpcEvent> = drained
.iter()
.map(|d| RpcEvent::Event {
payload: Box::new(event_payload_from_drained(d)),
})
.collect();
let auto_id = if !busy && events_auto_turn {
let action = wake_action_with_cap(
&drained,
&st.api_messages,
false,
true,
consecutive,
auto_turn_cap,
);
if action == WakeAction::RunTurn {
if claim_auto_turn_with_cap(
&mut st.consecutive_auto_turns,
auto_turn_cap,
) {
st.auto_turn_pending = true;
let first_id = drained
.first()
.map(|d| d.event.id.clone())
.unwrap_or_else(|| "unknown".to_string());
Some(format!("auto:{first_id}"))
} else {
None
}
} else {
None
}
} else {
None
};
(frames, auto_id)
};
for frame in frames {
if writer_d.send(frame).await.is_err() {
tracing::warn!(
"rpc: event drainer: writer channel closed — exiting drainer"
);
return;
}
}
if let Some(auto_id) = auto_turn_id {
tracing::debug!(auto_id, "rpc: scheduling auto-turn for runtime events");
let _ = auto_turn_tx_d.send(auto_id);
}
}
});
}
{
let state_s = Arc::clone(&state);
let writer_s = writer_tx.clone();
let auto_turn_tx_s = auto_turn_tx.clone();
tokio::spawn(async move {
while let Some(auto_id) = auto_turn_rx.recv().await {
{
let mut st = state_s.lock().await;
if !st.auto_turn_pending || st.in_flight.is_some() {
tracing::warn!(
auto_id,
auto_turn_pending = st.auto_turn_pending,
in_flight_live = st.in_flight.is_some(),
"rpc: scheduler: stale auto-turn reservation — dropping"
);
st.auto_turn_pending = false;
continue;
}
}
tracing::debug!(auto_id, "rpc: auto-turn scheduler: calling spawn_prompt");
spawn_prompt(
auto_id,
Arc::clone(&state_s),
writer_s.clone(),
auto_turn_tx_s.clone(),
)
.await;
}
});
}
writer_tx
.send(RpcEvent::Ready {
session_id: ready_session_id,
model: ready_model,
protocol_version: RPC_PROTOCOL_VERSION,
})
.await
.context("writer channel closed before Ready frame could be sent")?;
tracing::info!("synaps rpc ready");
let stdin = tokio::io::stdin();
let mut lines = BufReader::new(stdin).lines();
loop {
match lines.next_line().await {
Err(e) => {
tracing::error!(error = %e, "stdin read error; exiting");
break;
}
Ok(None) => {
tracing::info!("stdin EOF; saving session and exiting");
state.lock().await.save_session().await;
background.shutdown();
break;
}
Ok(Some(line)) => {
let line = line.trim_end_matches('\r'); if line.trim().is_empty() {
continue;
}
let cmd = match parse_frame(line, MAX_FRAME_BYTES) {
Ok(c) => c,
Err(err_ev) => {
tracing::warn!("frame parse error");
let _ = writer_tx.send(err_ev).await;
continue;
}
};
tracing::debug!(?cmd, "received RpcCommand");
match cmd {
RpcCommand::Prompt {
id,
message,
attachments,
} => {
handle_prompt(
id,
message,
attachments,
state.clone(),
writer_tx.clone(),
auto_turn_tx.clone(),
)
.await;
}
RpcCommand::FollowUp { id, message } => {
handle_prompt(
id,
message,
Vec::new(),
state.clone(),
writer_tx.clone(),
auto_turn_tx.clone(),
)
.await;
}
RpcCommand::Compact { id } => {
handle_compact(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::NewSession { id } => {
handle_new_session(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::GetMessages { id } => {
handle_get_messages(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::SetModel { id, model: m } => {
handle_set_model(id, m, state.clone(), writer_tx.clone()).await;
}
RpcCommand::GetAvailableModels { id } => {
handle_get_available_models(id, writer_tx.clone()).await;
}
RpcCommand::Abort { id } => {
handle_abort(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::GetSessionStats { id } => {
handle_get_session_stats(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::GetState { id } => {
handle_get_state(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::ToolsList { id } => {
handle_tools_list(id, state.clone(), writer_tx.clone()).await;
}
RpcCommand::Shutdown => {
tracing::info!("Shutdown received; draining and exiting");
let shutdown_poll = async {
loop {
let done = state.lock().await.in_flight.is_none();
if done {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
};
match tokio::time::timeout(
tokio::time::Duration::from_secs(30),
shutdown_poll,
)
.await
{
Ok(()) => {}
Err(_) => {
tracing::warn!(
"Shutdown: in-flight stream did not finish within 30 s; \
proceeding with shutdown anyway"
);
}
}
state.lock().await.save_session().await;
if let Some(outcome) = state
.lock()
.await
.runtime
.shutdown_observability_async(
synaps_cli::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
)
.await
{
if !outcome.is_flushed() {
tracing::warn!(
stats = ?outcome.stats(),
"observability flush timed out — detached worker keeps draining"
);
}
}
background.shutdown();
drop(writer_tx);
match tokio::time::timeout(
tokio::time::Duration::from_secs(1),
writer_handle,
)
.await
{
Ok(Ok(())) => {} Ok(Err(e)) => {
tracing::warn!(error = ?e, "writer task panicked during shutdown")
}
Err(_) => tracing::warn!(
"writer task did not drain within 1s; exiting anyway"
),
}
std::process::exit(0);
}
}
}
}
}
if let Some(outcome) = state
.lock()
.await
.runtime
.shutdown_observability_async(
synaps_cli::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
)
.await
{
if !outcome.is_flushed() {
tracing::warn!(
stats = ?outcome.stats(),
"observability flush timed out — detached worker keeps draining"
);
}
}
Ok(())
}
#[cfg(test)]
mod context_head_tests {
use super::*;
#[tokio::test]
async fn context_head_rejection_blocks_shutdown_save_and_auto_chain() {
let mut session = Session::new("synthetic", "medium", Some("host authority"));
session.id = "synthetic-rpc-context-head".into();
session.api_messages = vec![Arc::new(serde_json::json!({
"role":"user", "content":"original synthetic head"
}))];
let old_messages = session.api_messages.clone();
let mut st = RpcState {
runtime: Runtime::new_headless(),
session,
context_head: Default::default(),
api_messages: old_messages.clone(),
total_input_tokens: 0,
total_output_tokens: 0,
session_cost: 0.0,
in_flight: None,
pending_events: vec!["synthetic pending event".into()],
consecutive_auto_turns: 0,
auto_turn_pending: false,
events_auto_turn: true,
auto_turn_cap: 5,
save_owed: false,
session_lock: None,
};
let (receipt, acknowledged) = synaps_cli::core::context_head::ContextHeadReceipt::channel();
receipt.complete(st.persist_context_head("wrong-session", Vec::new()).await);
assert!(acknowledged.await.unwrap().is_err());
assert_eq!(st.api_messages, old_messages);
let state = Mutex::new(st);
assert!(terminal_flush(&state, true).await.is_none());
let mut st = state.lock().await;
assert!(!st.auto_turn_pending);
assert_eq!(st.consecutive_auto_turns, 0);
assert_eq!(st.api_messages.len(), 2); st.save_session().await; assert_eq!(st.session.api_messages, old_messages);
let (writer, mut frames) = mpsc::channel(8);
drop(st);
let state = Arc::new(state);
handle_compact("compact-test".into(), state, writer).await;
assert!(matches!(frames.recv().await, Some(RpcEvent::Error { .. })));
}
}
#[cfg(test)]
mod rpc_attachment_tests {
use super::*;
#[test]
fn rpc_attachment_paths_validates_absolute_and_no_parent() {
let good = vec![RpcAttachment {
path: "/tmp/image.png".into(),
name: None,
mime: None,
}];
assert!(rpc_attachment_paths(&good).is_ok());
let relative = vec![RpcAttachment {
path: "relative/image.png".into(),
name: None,
mime: None,
}];
assert!(rpc_attachment_paths(&relative).is_err());
let dotdot = vec![RpcAttachment {
path: "/tmp/../etc/passwd".into(),
name: None,
mime: None,
}];
assert!(rpc_attachment_paths(&dotdot).is_err());
assert!(rpc_attachment_paths(&[]).is_ok());
}
#[tokio::test]
async fn load_rpc_user_content_text_only() {
let (content, count) =
load_rpc_user_content("hello", &[], "claude-sonnet-4-20250514", &[])
.await
.unwrap();
assert_eq!(content, "hello");
assert_eq!(count, 0);
}
#[tokio::test]
async fn load_rpc_user_content_missing_file_fails() {
let attachments = vec![RpcAttachment {
path: "/tmp/nonexistent_rpc_test_file.png".into(),
name: None,
mime: None,
}];
let result =
load_rpc_user_content("hello", &attachments, "claude-sonnet-4-20250514", &[]).await;
assert!(result.is_err());
}
#[tokio::test]
async fn load_rpc_user_content_real_text_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "file body").unwrap();
let attachments = vec![RpcAttachment {
path: path.to_str().unwrap().into(),
name: None,
mime: None,
}];
let (content, count) = load_rpc_user_content(
"explain",
&attachments,
"claude-sonnet-4-20250514",
&[],
)
.await
.unwrap();
assert_eq!(count, 1);
let arr = content.as_array().unwrap();
assert_eq!(arr[0]["text"], "explain");
assert_eq!(arr[1]["type"], "document");
}
}