2026-04-27 21:22:55 +00:00
|
|
|
//! RPC method registry for the `/crdt-sync` WebSocket multiplexer.
|
|
|
|
|
//!
|
|
|
|
|
//! Incoming [`RpcFrame::RpcRequest`] frames are dispatched through this
|
2026-05-13 04:43:48 +00:00
|
|
|
//! registry. Each method handler is an async function that accepts a
|
2026-04-27 21:22:55 +00:00
|
|
|
//! `serde_json::Value` parameter bag and returns a `serde_json::Value` result.
|
|
|
|
|
//!
|
|
|
|
|
//! # Registering handlers
|
|
|
|
|
//!
|
|
|
|
|
//! Add a new entry to the `HANDLERS` static slice:
|
|
|
|
|
//!
|
|
|
|
|
//! ```rust,ignore
|
2026-05-13 04:43:48 +00:00
|
|
|
//! ("my.method", |p| Box::pin(handle_my_method(p))),
|
2026-04-27 21:22:55 +00:00
|
|
|
//! ```
|
|
|
|
|
//!
|
|
|
|
|
//! # Unknown methods
|
|
|
|
|
//!
|
|
|
|
|
//! [`dispatch`] returns `Err("NOT_FOUND")` for any method not present in the
|
|
|
|
|
//! registry. The caller should translate this into an
|
|
|
|
|
//! [`RpcFrame::RpcResponse`] with `ok: false, code: "NOT_FOUND"`.
|
2026-05-13 04:43:48 +00:00
|
|
|
//!
|
|
|
|
|
//! # Global context
|
|
|
|
|
//!
|
|
|
|
|
//! Many handlers need access to project state (session root, store, workflow).
|
|
|
|
|
//! Call [`init_rpc_context`] once at server startup to register these.
|
|
|
|
|
//! Handlers that require context return an error result when it has not been
|
|
|
|
|
//! set.
|
|
|
|
|
|
|
|
|
|
use std::future::Future;
|
|
|
|
|
use std::pin::Pin;
|
|
|
|
|
use std::sync::{Arc, OnceLock};
|
2026-04-27 21:22:55 +00:00
|
|
|
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
2026-05-13 07:10:00 +00:00
|
|
|
use super::rpc_contract::{
|
|
|
|
|
BotConfigPayload, EditorSettingsResult, ForgetProjectParams, OkResult, OpenFileParams,
|
|
|
|
|
OpenProjectParams, OpenProjectResult, ProjectSettingsPayload, PutEditorParams,
|
|
|
|
|
SetAnthropicApiKeyParams, SetModelPreferenceParams,
|
|
|
|
|
};
|
2026-04-27 21:22:55 +00:00
|
|
|
use super::wire::RpcFrame;
|
2026-05-13 04:43:48 +00:00
|
|
|
use crate::state::SessionState;
|
|
|
|
|
use crate::store::JsonFileStore;
|
|
|
|
|
use crate::workflow::WorkflowState;
|
|
|
|
|
|
|
|
|
|
/// Future returned by an RPC handler.
|
|
|
|
|
type HandlerFuture = Pin<Box<dyn Future<Output = Value> + Send>>;
|
|
|
|
|
|
|
|
|
|
/// Signature for an async RPC method handler.
|
|
|
|
|
pub(super) type Handler = fn(Value) -> HandlerFuture;
|
2026-04-27 21:22:55 +00:00
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
/// Shared state made available to all RPC handlers.
|
|
|
|
|
pub struct RpcState {
|
|
|
|
|
pub state: Arc<SessionState>,
|
|
|
|
|
pub store: Arc<JsonFileStore>,
|
|
|
|
|
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Global RPC context, initialised once at server startup via [`init_rpc_context`].
|
|
|
|
|
static RPC_CTX: OnceLock<RpcState> = OnceLock::new();
|
|
|
|
|
|
|
|
|
|
/// Register the global RPC context.
|
|
|
|
|
///
|
|
|
|
|
/// Must be called before any handler that accesses project state is invoked.
|
|
|
|
|
/// Subsequent calls are silently ignored (OnceLock semantics).
|
|
|
|
|
pub fn init_rpc_context(
|
|
|
|
|
state: Arc<SessionState>,
|
|
|
|
|
store: Arc<JsonFileStore>,
|
|
|
|
|
workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
|
|
|
|
) {
|
|
|
|
|
let _ = RPC_CTX.set(RpcState {
|
|
|
|
|
state,
|
|
|
|
|
store,
|
|
|
|
|
workflow,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-27 21:22:55 +00:00
|
|
|
|
|
|
|
|
/// Static registry mapping method names to handlers.
|
|
|
|
|
///
|
|
|
|
|
/// Add new handlers here. The registry is a plain slice — linear scan is
|
|
|
|
|
/// fine for the small number of methods expected.
|
2026-04-28 15:31:29 +00:00
|
|
|
static HANDLERS: &[(&str, Handler)] = &[
|
2026-05-13 04:43:48 +00:00
|
|
|
("health.check", |p| Box::pin(handle_health_check(p))),
|
|
|
|
|
("active_agents.list", |p| {
|
|
|
|
|
Box::pin(handle_active_agents_list(p))
|
|
|
|
|
}),
|
|
|
|
|
("agent_config.list", |p| {
|
|
|
|
|
Box::pin(handle_agent_config_list(p))
|
|
|
|
|
}),
|
|
|
|
|
("settings.get_project", |p| {
|
|
|
|
|
Box::pin(handle_settings_get_project(p))
|
|
|
|
|
}),
|
|
|
|
|
("settings.get_editor", |p| {
|
|
|
|
|
Box::pin(handle_settings_get_editor(p))
|
|
|
|
|
}),
|
|
|
|
|
("model.get_preference", |p| {
|
|
|
|
|
Box::pin(handle_model_get_preference(p))
|
|
|
|
|
}),
|
|
|
|
|
("project.current", |p| Box::pin(handle_project_current(p))),
|
|
|
|
|
("project.known", |p| Box::pin(handle_project_known(p))),
|
|
|
|
|
("anthropic.key_exists", |p| {
|
|
|
|
|
Box::pin(handle_anthropic_key_exists(p))
|
|
|
|
|
}),
|
|
|
|
|
("anthropic.list_models", |p| {
|
|
|
|
|
Box::pin(handle_anthropic_list_models(p))
|
|
|
|
|
}),
|
|
|
|
|
("ollama.list_models", |p| {
|
|
|
|
|
Box::pin(handle_ollama_list_models(p))
|
|
|
|
|
}),
|
|
|
|
|
("io.home_directory", |p| {
|
|
|
|
|
Box::pin(handle_io_home_directory(p))
|
|
|
|
|
}),
|
|
|
|
|
("io.list_project_files", |p| {
|
|
|
|
|
Box::pin(handle_io_list_project_files(p))
|
|
|
|
|
}),
|
|
|
|
|
("work_items.get", |p| Box::pin(handle_work_items_get(p))),
|
|
|
|
|
("work_items.test_results", |p| {
|
|
|
|
|
Box::pin(handle_work_items_test_results(p))
|
|
|
|
|
}),
|
|
|
|
|
("work_items.token_cost", |p| {
|
|
|
|
|
Box::pin(handle_work_items_token_cost(p))
|
|
|
|
|
}),
|
|
|
|
|
("token_usage.all", |p| Box::pin(handle_token_usage_all(p))),
|
|
|
|
|
("oauth.status", |p| Box::pin(handle_oauth_status(p))),
|
|
|
|
|
("bot_config.get", |p| Box::pin(handle_bot_config_get(p))),
|
|
|
|
|
("agents.get_output", |p| {
|
|
|
|
|
Box::pin(handle_agents_get_output(p))
|
|
|
|
|
}),
|
2026-05-13 07:10:00 +00:00
|
|
|
// ── typed write methods ──────────────────────────────────────────────
|
|
|
|
|
("model.set_preference", |p| {
|
|
|
|
|
Box::pin(handle_model_set_preference(p))
|
|
|
|
|
}),
|
|
|
|
|
("anthropic.set_api_key", |p| {
|
|
|
|
|
Box::pin(handle_anthropic_set_api_key(p))
|
|
|
|
|
}),
|
|
|
|
|
("settings.put_editor", |p| {
|
|
|
|
|
Box::pin(handle_settings_put_editor(p))
|
|
|
|
|
}),
|
|
|
|
|
("settings.open_file", |p| {
|
|
|
|
|
Box::pin(handle_settings_open_file(p))
|
|
|
|
|
}),
|
|
|
|
|
("settings.put_project", |p| {
|
|
|
|
|
Box::pin(handle_settings_put_project(p))
|
|
|
|
|
}),
|
|
|
|
|
("project.open", |p| Box::pin(handle_project_open(p))),
|
|
|
|
|
("project.close", |p| Box::pin(handle_project_close(p))),
|
|
|
|
|
("project.forget", |p| Box::pin(handle_project_forget(p))),
|
|
|
|
|
("bot_config.save", |p| Box::pin(handle_bot_config_save(p))),
|
|
|
|
|
("chat.cancel", |p| Box::pin(handle_chat_cancel(p))),
|
2026-04-28 15:31:29 +00:00
|
|
|
];
|
2026-04-27 21:22:55 +00:00
|
|
|
|
2026-05-13 07:10:00 +00:00
|
|
|
// ── typed-write helper macros ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Parse the incoming JSON params into a typed struct or return a typed error
|
|
|
|
|
/// response shaped as `{"error": "..."}`.
|
|
|
|
|
macro_rules! parse_params {
|
|
|
|
|
($params:expr, $ty:ty) => {
|
|
|
|
|
match serde_json::from_value::<$ty>($params) {
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(e) => return serde_json::json!({"error": format!("invalid params: {e}")}),
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ok_json() -> Value {
|
|
|
|
|
serde_json::to_value(OkResult { ok: true }).unwrap_or(Value::Null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn err_json(msg: impl Into<String>) -> Value {
|
|
|
|
|
serde_json::json!({"error": msg.into()})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
// ── handlers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-27 21:22:55 +00:00
|
|
|
/// Handler for the `health.check` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `{"status": "ok"}` unconditionally. Used as a smoke test to
|
|
|
|
|
/// verify that the RPC multiplexer is wired up correctly.
|
2026-05-13 04:43:48 +00:00
|
|
|
async fn handle_health_check(_params: Value) -> Value {
|
2026-04-27 21:22:55 +00:00
|
|
|
serde_json::json!({"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 15:31:29 +00:00
|
|
|
/// Handler for the `active_agents.list` method.
|
|
|
|
|
///
|
|
|
|
|
/// Reads the `active_agents` collection from the CRDT and returns an array
|
|
|
|
|
/// matching the shape formerly served by `GET /api/agents`. Each entry
|
|
|
|
|
/// contains `story_id`, `agent_name`, `status`, `session_id`, and
|
|
|
|
|
/// `worktree_path`.
|
2026-05-13 04:43:48 +00:00
|
|
|
async fn handle_active_agents_list(_params: Value) -> Value {
|
2026-04-28 15:31:29 +00:00
|
|
|
let entries = crate::crdt_state::read_all_active_agents().unwrap_or_default();
|
|
|
|
|
let list: Vec<Value> = entries
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|view| {
|
|
|
|
|
let (story_id, agent_name) = view
|
|
|
|
|
.agent_id
|
|
|
|
|
.rsplit_once(':')
|
|
|
|
|
.map(|(s, a)| (s.to_string(), a.to_string()))
|
|
|
|
|
.unwrap_or_else(|| (view.story_id.unwrap_or_default(), view.agent_id.clone()));
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"story_id": story_id,
|
|
|
|
|
"agent_name": agent_name,
|
|
|
|
|
"status": "running",
|
|
|
|
|
"session_id": null,
|
|
|
|
|
"worktree_path": null,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
Value::Array(list)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
/// Handler for the `agent_config.list` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the configured agent roster from `project.toml`, matching the
|
|
|
|
|
/// shape formerly served by `GET /api/agents/config`.
|
|
|
|
|
async fn handle_agent_config_list(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"error": "RPC context not initialised"});
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return Value::Array(vec![]);
|
|
|
|
|
};
|
|
|
|
|
let entries = crate::service::agents::get_agent_config(&root).unwrap_or_default();
|
|
|
|
|
let list: Vec<Value> = entries
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|e| {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"name": e.name,
|
|
|
|
|
"role": e.role,
|
|
|
|
|
"stage": e.stage,
|
|
|
|
|
"model": e.model,
|
|
|
|
|
"allowed_tools": e.allowed_tools,
|
|
|
|
|
"max_turns": e.max_turns,
|
|
|
|
|
"max_budget_usd": e.max_budget_usd,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
Value::Array(list)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `settings.get_project` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the current `project.toml` scalar settings, matching the shape
|
|
|
|
|
/// formerly served by `GET /api/settings`.
|
|
|
|
|
async fn handle_settings_get_project(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"error": "RPC context not initialised"});
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return serde_json::json!({"error": "No project open"});
|
|
|
|
|
};
|
|
|
|
|
match crate::service::settings::load_project_settings(&root) {
|
|
|
|
|
Ok(s) => serde_json::json!({
|
|
|
|
|
"default_qa": s.default_qa,
|
|
|
|
|
"default_coder_model": s.default_coder_model,
|
|
|
|
|
"max_coders": s.max_coders,
|
|
|
|
|
"max_retries": s.max_retries,
|
|
|
|
|
"base_branch": s.base_branch,
|
|
|
|
|
"rate_limit_notifications": s.rate_limit_notifications,
|
|
|
|
|
"timezone": s.timezone,
|
|
|
|
|
"rendezvous": s.rendezvous,
|
|
|
|
|
"watcher_sweep_interval_secs": s.watcher_sweep_interval_secs,
|
|
|
|
|
"watcher_done_retention_secs": s.watcher_done_retention_secs,
|
|
|
|
|
}),
|
|
|
|
|
Err(e) => serde_json::json!({"error": e.to_string()}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `settings.get_editor` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the configured editor command from the store, matching the shape
|
|
|
|
|
/// formerly served by `GET /api/settings/editor`.
|
|
|
|
|
async fn handle_settings_get_editor(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"editor_command": null});
|
|
|
|
|
};
|
|
|
|
|
let cmd = crate::service::settings::get_editor_command(ctx.store.as_ref());
|
|
|
|
|
serde_json::json!({"editor_command": cmd})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `model.get_preference` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the user's saved LLM model name from the store, matching the
|
|
|
|
|
/// shape formerly served by `GET /api/model`.
|
|
|
|
|
async fn handle_model_get_preference(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return Value::Null;
|
|
|
|
|
};
|
|
|
|
|
match crate::io::fs::get_model_preference(ctx.store.as_ref()) {
|
|
|
|
|
Ok(pref) => serde_json::to_value(pref).unwrap_or(Value::Null),
|
|
|
|
|
Err(_) => Value::Null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `project.current` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the currently open project path (or null), matching the shape
|
|
|
|
|
/// formerly served by `GET /api/project`.
|
|
|
|
|
async fn handle_project_current(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return Value::Null;
|
|
|
|
|
};
|
|
|
|
|
match crate::service::project::get_current_project(&ctx.state, ctx.store.as_ref()) {
|
|
|
|
|
Ok(path) => serde_json::to_value(path).unwrap_or(Value::Null),
|
|
|
|
|
Err(_) => Value::Null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `project.known` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the list of previously-opened project paths from the store,
|
|
|
|
|
/// matching the shape formerly served by `GET /api/projects`.
|
|
|
|
|
async fn handle_project_known(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return Value::Array(vec![]);
|
|
|
|
|
};
|
|
|
|
|
match crate::service::project::get_known_projects(ctx.store.as_ref()) {
|
|
|
|
|
Ok(paths) => serde_json::to_value(paths).unwrap_or(Value::Array(vec![])),
|
|
|
|
|
Err(_) => Value::Array(vec![]),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `anthropic.key_exists` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns true when an Anthropic API key is stored, matching the shape
|
|
|
|
|
/// formerly served by `GET /api/anthropic/key/exists`.
|
|
|
|
|
async fn handle_anthropic_key_exists(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return Value::Bool(false);
|
|
|
|
|
};
|
|
|
|
|
match crate::service::anthropic::get_api_key_exists(ctx.store.as_ref()) {
|
|
|
|
|
Ok(exists) => Value::Bool(exists),
|
|
|
|
|
Err(_) => Value::Bool(false),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `anthropic.list_models` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the available Anthropic models, matching the shape formerly
|
|
|
|
|
/// served by `GET /api/anthropic/models`. Surfaces upstream errors as a
|
|
|
|
|
/// JSON object `{"error": "..."}`.
|
|
|
|
|
async fn handle_anthropic_list_models(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"error": "RPC context not initialised"});
|
|
|
|
|
};
|
|
|
|
|
match crate::service::anthropic::list_models(ctx.store.as_ref()).await {
|
|
|
|
|
Ok(models) => serde_json::to_value(models).unwrap_or(Value::Array(vec![])),
|
|
|
|
|
Err(e) => serde_json::json!({"error": e.to_string()}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `ollama.list_models` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the available Ollama models for the configured base URL,
|
|
|
|
|
/// matching the shape formerly served by `GET /api/ollama/models`.
|
|
|
|
|
///
|
|
|
|
|
/// Parameters: `{ "base_url"?: string }`.
|
|
|
|
|
async fn handle_ollama_list_models(params: Value) -> Value {
|
|
|
|
|
let base_url = params
|
|
|
|
|
.get("base_url")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.map(str::to_string);
|
|
|
|
|
match crate::llm::chat::get_ollama_models(base_url).await {
|
|
|
|
|
Ok(models) => serde_json::to_value(models).unwrap_or(Value::Array(vec![])),
|
|
|
|
|
Err(_) => Value::Array(vec![]),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `io.home_directory` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the user's home directory path, matching the shape formerly
|
|
|
|
|
/// served by `GET /api/io/fs/home`.
|
|
|
|
|
async fn handle_io_home_directory(_params: Value) -> Value {
|
|
|
|
|
match crate::service::file_io::get_home_directory() {
|
|
|
|
|
Ok(home) => Value::String(home),
|
|
|
|
|
Err(_) => Value::Null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `io.list_project_files` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the list of files in the currently open project, matching the
|
|
|
|
|
/// shape formerly served by `GET /api/io/fs/files`.
|
|
|
|
|
async fn handle_io_list_project_files(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return Value::Array(vec![]);
|
|
|
|
|
};
|
|
|
|
|
match crate::service::file_io::list_project_files(&ctx.state).await {
|
|
|
|
|
Ok(files) => serde_json::to_value(files).unwrap_or(Value::Array(vec![])),
|
|
|
|
|
Err(_) => Value::Array(vec![]),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `work_items.get` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the markdown content and metadata for a work item, matching the
|
|
|
|
|
/// shape formerly served by `GET /api/work-items/{story_id}`.
|
|
|
|
|
///
|
|
|
|
|
/// Parameters: `{ "story_id": string }`.
|
|
|
|
|
async fn handle_work_items_get(params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"error": "RPC context not initialised"});
|
|
|
|
|
};
|
|
|
|
|
let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
|
|
|
|
|
return serde_json::json!({"error": "missing story_id"});
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return serde_json::json!({"error": "No project open"});
|
|
|
|
|
};
|
|
|
|
|
match crate::service::agents::get_work_item_content(&root, story_id) {
|
|
|
|
|
Ok(c) => serde_json::json!({
|
|
|
|
|
"content": c.content,
|
|
|
|
|
"stage": c.stage,
|
|
|
|
|
"name": c.name,
|
|
|
|
|
"agent": c.agent,
|
|
|
|
|
}),
|
|
|
|
|
Err(e) => serde_json::json!({"error": e.to_string()}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `work_items.test_results` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the most recent test-suite results for a story, matching the
|
|
|
|
|
/// shape formerly served by `GET /api/work-items/{story_id}/test-results`.
|
|
|
|
|
///
|
|
|
|
|
/// Parameters: `{ "story_id": string }`.
|
|
|
|
|
async fn handle_work_items_test_results(params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return Value::Null;
|
|
|
|
|
};
|
|
|
|
|
let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
|
|
|
|
|
return Value::Null;
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return Value::Null;
|
|
|
|
|
};
|
|
|
|
|
let workflow = ctx.workflow.lock().unwrap();
|
|
|
|
|
match crate::service::agents::get_test_results(&root, story_id, &workflow) {
|
|
|
|
|
Some(results) => serde_json::to_value(results).unwrap_or(Value::Null),
|
|
|
|
|
None => Value::Null,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `work_items.token_cost` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the aggregated LLM token cost for a story, matching the shape
|
|
|
|
|
/// formerly served by `GET /api/work-items/{story_id}/token-cost`.
|
|
|
|
|
///
|
|
|
|
|
/// Parameters: `{ "story_id": string }`.
|
|
|
|
|
async fn handle_work_items_token_cost(params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"error": "RPC context not initialised"});
|
|
|
|
|
};
|
|
|
|
|
let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
|
|
|
|
|
return serde_json::json!({"error": "missing story_id"});
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return serde_json::json!({"error": "No project open"});
|
|
|
|
|
};
|
|
|
|
|
match crate::service::agents::get_work_item_token_cost(&root, story_id) {
|
|
|
|
|
Ok(summary) => serde_json::json!({
|
|
|
|
|
"total_cost_usd": summary.total_cost_usd,
|
|
|
|
|
"agents": summary.agents.into_iter().map(|a| serde_json::json!({
|
|
|
|
|
"agent_name": a.agent_name,
|
|
|
|
|
"model": a.model,
|
|
|
|
|
"input_tokens": a.input_tokens,
|
|
|
|
|
"output_tokens": a.output_tokens,
|
|
|
|
|
"cache_creation_input_tokens": a.cache_creation_input_tokens,
|
|
|
|
|
"cache_read_input_tokens": a.cache_read_input_tokens,
|
|
|
|
|
"total_cost_usd": a.total_cost_usd,
|
|
|
|
|
})).collect::<Vec<_>>(),
|
|
|
|
|
}),
|
|
|
|
|
Err(e) => serde_json::json!({"error": e.to_string()}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `token_usage.all` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns every token-usage record for the project, matching the shape
|
|
|
|
|
/// formerly served by `GET /api/token-usage`.
|
|
|
|
|
async fn handle_token_usage_all(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"records": []});
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return serde_json::json!({"records": []});
|
|
|
|
|
};
|
|
|
|
|
let records = crate::service::agents::get_all_token_usage(&root).unwrap_or_default();
|
|
|
|
|
serde_json::json!({"records": records})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `oauth.status` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the status of every stored OAuth account in the login pool,
|
|
|
|
|
/// matching the shape formerly served by `GET /oauth/status`.
|
|
|
|
|
async fn handle_oauth_status(_params: Value) -> Value {
|
|
|
|
|
let accounts = crate::service::oauth::check_all_accounts();
|
|
|
|
|
serde_json::json!({"accounts": accounts})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `bot_config.get` method.
|
|
|
|
|
///
|
|
|
|
|
/// Reads the credentials stored in `.huskies/bot.toml`, matching the shape
|
|
|
|
|
/// formerly served by `GET /api/bot/config`.
|
|
|
|
|
async fn handle_bot_config_get(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return bot_config_default();
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return bot_config_default();
|
|
|
|
|
};
|
|
|
|
|
let path = root.join(".huskies").join("bot.toml");
|
|
|
|
|
match std::fs::read_to_string(&path) {
|
|
|
|
|
Ok(s) => match toml::from_str::<Value>(&s) {
|
|
|
|
|
Ok(v) => merge_bot_config_defaults(v),
|
|
|
|
|
Err(_) => bot_config_default(),
|
|
|
|
|
},
|
|
|
|
|
Err(_) => bot_config_default(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Default bot config payload — every key present, every value null.
|
|
|
|
|
fn bot_config_default() -> Value {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"transport": null,
|
|
|
|
|
"enabled": null,
|
|
|
|
|
"homeserver": null,
|
|
|
|
|
"username": null,
|
|
|
|
|
"password": null,
|
|
|
|
|
"room_ids": null,
|
|
|
|
|
"slack_bot_token": null,
|
|
|
|
|
"slack_signing_secret": null,
|
|
|
|
|
"slack_channel_ids": null,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fill in missing keys with `null` so the frontend always sees the full shape.
|
|
|
|
|
fn merge_bot_config_defaults(mut v: Value) -> Value {
|
|
|
|
|
let obj = v.as_object_mut();
|
|
|
|
|
let keys = [
|
|
|
|
|
"transport",
|
|
|
|
|
"enabled",
|
|
|
|
|
"homeserver",
|
|
|
|
|
"username",
|
|
|
|
|
"password",
|
|
|
|
|
"room_ids",
|
|
|
|
|
"slack_bot_token",
|
|
|
|
|
"slack_signing_secret",
|
|
|
|
|
"slack_channel_ids",
|
|
|
|
|
];
|
|
|
|
|
if let Some(map) = obj {
|
|
|
|
|
for k in keys {
|
|
|
|
|
map.entry(k.to_string()).or_insert(Value::Null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for the `agents.get_output` method.
|
|
|
|
|
///
|
|
|
|
|
/// Returns the concatenated output text for an agent's most recent session,
|
|
|
|
|
/// matching the shape formerly served by
|
|
|
|
|
/// `GET /api/agents/{story_id}/{agent_name}/output`.
|
|
|
|
|
///
|
|
|
|
|
/// Parameters: `{ "story_id": string, "agent_name": string }`.
|
|
|
|
|
async fn handle_agents_get_output(params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return serde_json::json!({"error": "RPC context not initialised"});
|
|
|
|
|
};
|
|
|
|
|
let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
|
|
|
|
|
return serde_json::json!({"error": "missing story_id"});
|
|
|
|
|
};
|
|
|
|
|
let Some(agent_name) = params.get("agent_name").and_then(|v| v.as_str()) else {
|
|
|
|
|
return serde_json::json!({"error": "missing agent_name"});
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return serde_json::json!({"error": "no project open"});
|
|
|
|
|
};
|
|
|
|
|
match crate::service::agents::get_agent_output(&root, story_id, agent_name) {
|
|
|
|
|
Ok(output) => serde_json::json!({"output": output}),
|
|
|
|
|
Err(e) => serde_json::json!({"error": e.to_string()}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 07:10:00 +00:00
|
|
|
// ── typed write handlers ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Handler for `model.set_preference`. Persists the user's chosen LLM model.
|
|
|
|
|
async fn handle_model_set_preference(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, SetModelPreferenceParams);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
match crate::io::fs::set_model_preference(typed.model, ctx.store.as_ref()) {
|
|
|
|
|
Ok(()) => ok_json(),
|
|
|
|
|
Err(e) => err_json(e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `anthropic.set_api_key`. Stores an Anthropic API key.
|
|
|
|
|
async fn handle_anthropic_set_api_key(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, SetAnthropicApiKeyParams);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
match crate::service::anthropic::set_api_key(ctx.store.as_ref(), typed.api_key) {
|
|
|
|
|
Ok(()) => ok_json(),
|
|
|
|
|
Err(e) => err_json(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `settings.put_editor`. Updates the configured editor command.
|
|
|
|
|
async fn handle_settings_put_editor(params: Value) -> Value {
|
|
|
|
|
use crate::store::StoreOps;
|
|
|
|
|
let typed = parse_params!(params, PutEditorParams);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
let trimmed = typed
|
|
|
|
|
.editor_command
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
match trimmed {
|
|
|
|
|
Some(cmd) => {
|
|
|
|
|
ctx.store.set(
|
|
|
|
|
crate::service::settings::EDITOR_COMMAND_KEY,
|
|
|
|
|
serde_json::json!(cmd.clone()),
|
|
|
|
|
);
|
|
|
|
|
if let Err(e) = ctx.store.save() {
|
|
|
|
|
return err_json(e);
|
|
|
|
|
}
|
|
|
|
|
serde_json::to_value(EditorSettingsResult {
|
|
|
|
|
editor_command: Some(cmd),
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(Value::Null)
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
ctx.store
|
|
|
|
|
.delete(crate::service::settings::EDITOR_COMMAND_KEY);
|
|
|
|
|
if let Err(e) = ctx.store.save() {
|
|
|
|
|
return err_json(e);
|
|
|
|
|
}
|
|
|
|
|
serde_json::to_value(EditorSettingsResult {
|
|
|
|
|
editor_command: None,
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(Value::Null)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `settings.open_file`. Spawns the configured editor.
|
|
|
|
|
async fn handle_settings_open_file(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, OpenFileParams);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
match crate::service::settings::open_file_in_editor(ctx.store.as_ref(), &typed.path, typed.line)
|
|
|
|
|
{
|
|
|
|
|
Ok(()) => ok_json(),
|
|
|
|
|
Err(e) => err_json(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `settings.put_project`. Persists project.toml scalar settings.
|
|
|
|
|
async fn handle_settings_put_project(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, ProjectSettingsPayload);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return err_json("No project open");
|
|
|
|
|
};
|
|
|
|
|
let domain = crate::service::settings::ProjectSettings {
|
|
|
|
|
default_qa: typed.default_qa,
|
|
|
|
|
default_coder_model: typed.default_coder_model,
|
|
|
|
|
max_coders: typed.max_coders,
|
|
|
|
|
max_retries: typed.max_retries,
|
|
|
|
|
base_branch: typed.base_branch,
|
|
|
|
|
rate_limit_notifications: typed.rate_limit_notifications,
|
|
|
|
|
timezone: typed.timezone,
|
|
|
|
|
rendezvous: typed.rendezvous,
|
|
|
|
|
watcher_sweep_interval_secs: typed.watcher_sweep_interval_secs,
|
|
|
|
|
watcher_done_retention_secs: typed.watcher_done_retention_secs,
|
|
|
|
|
};
|
|
|
|
|
if let Err(e) = crate::service::settings::validate_project_settings(&domain) {
|
|
|
|
|
return err_json(e.to_string());
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = crate::service::settings::write_project_settings(&root, &domain) {
|
|
|
|
|
return err_json(e.to_string());
|
|
|
|
|
}
|
|
|
|
|
match crate::service::settings::load_project_settings(&root) {
|
|
|
|
|
Ok(s) => serde_json::to_value(ProjectSettingsPayload {
|
|
|
|
|
default_qa: s.default_qa,
|
|
|
|
|
default_coder_model: s.default_coder_model,
|
|
|
|
|
max_coders: s.max_coders,
|
|
|
|
|
max_retries: s.max_retries,
|
|
|
|
|
base_branch: s.base_branch,
|
|
|
|
|
rate_limit_notifications: s.rate_limit_notifications,
|
|
|
|
|
timezone: s.timezone,
|
|
|
|
|
rendezvous: s.rendezvous,
|
|
|
|
|
watcher_sweep_interval_secs: s.watcher_sweep_interval_secs,
|
|
|
|
|
watcher_done_retention_secs: s.watcher_done_retention_secs,
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or(Value::Null),
|
|
|
|
|
Err(e) => err_json(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `project.open`. Opens a project on disk and updates session state.
|
|
|
|
|
async fn handle_project_open(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, OpenProjectParams);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
let port = crate::http::resolve_port();
|
|
|
|
|
match crate::service::project::open_project(typed.path, &ctx.state, ctx.store.as_ref(), port)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(path) => serde_json::to_value(OpenProjectResult { path }).unwrap_or(Value::Null),
|
|
|
|
|
Err(e) => err_json(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `project.close`. Closes the currently open project.
|
|
|
|
|
async fn handle_project_close(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
match crate::service::project::close_project(&ctx.state, ctx.store.as_ref()) {
|
|
|
|
|
Ok(()) => ok_json(),
|
|
|
|
|
Err(e) => err_json(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `project.forget`. Removes a project from the known list.
|
|
|
|
|
async fn handle_project_forget(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, ForgetProjectParams);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
match crate::service::project::forget_known_project(typed.path, ctx.store.as_ref()) {
|
|
|
|
|
Ok(()) => ok_json(),
|
|
|
|
|
Err(e) => err_json(e.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `bot_config.save`. Writes `.huskies/bot.toml`.
|
|
|
|
|
async fn handle_bot_config_save(params: Value) -> Value {
|
|
|
|
|
let typed = parse_params!(params, BotConfigPayload);
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
let Ok(root) = ctx.state.get_project_root() else {
|
|
|
|
|
return err_json("No project open");
|
|
|
|
|
};
|
|
|
|
|
let bot_dir = root.join(".huskies");
|
|
|
|
|
if let Err(e) = std::fs::create_dir_all(&bot_dir) {
|
|
|
|
|
return err_json(format!("create .huskies dir: {e}"));
|
|
|
|
|
}
|
|
|
|
|
let bot_path = bot_dir.join("bot.toml");
|
|
|
|
|
match toml::to_string_pretty(&typed) {
|
|
|
|
|
Ok(s) => {
|
|
|
|
|
if let Err(e) = std::fs::write(&bot_path, s) {
|
|
|
|
|
return err_json(format!("write bot.toml: {e}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => return err_json(format!("serialise bot.toml: {e}")),
|
|
|
|
|
}
|
|
|
|
|
serde_json::to_value(typed).unwrap_or(Value::Null)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handler for `chat.cancel`. Cancels the in-flight LLM chat stream.
|
|
|
|
|
async fn handle_chat_cancel(_params: Value) -> Value {
|
|
|
|
|
let Some(ctx) = RPC_CTX.get() else {
|
|
|
|
|
return err_json("RPC context not initialised");
|
|
|
|
|
};
|
|
|
|
|
match crate::llm::chat::cancel_chat(&ctx.state) {
|
|
|
|
|
Ok(()) => ok_json(),
|
|
|
|
|
Err(e) => err_json(e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
// ── dispatch ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-27 21:22:55 +00:00
|
|
|
/// Dispatch an incoming RPC method call to the registered handler.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `Ok(result)` on success or `Err("NOT_FOUND")` if no handler is
|
|
|
|
|
/// registered for `method`.
|
2026-05-13 04:43:48 +00:00
|
|
|
pub(super) async fn dispatch(method: &str, params: Value) -> Result<Value, &'static str> {
|
2026-04-27 21:22:55 +00:00
|
|
|
for (name, handler) in HANDLERS {
|
|
|
|
|
if *name == method {
|
2026-05-13 04:43:48 +00:00
|
|
|
return Ok(handler(params).await);
|
2026-04-27 21:22:55 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err("NOT_FOUND")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Try to parse `text` as an [`RpcFrame::RpcRequest`] and, if successful,
|
|
|
|
|
/// dispatch it and return the corresponding [`RpcFrame::RpcResponse`].
|
|
|
|
|
///
|
|
|
|
|
/// Returns `None` if the text is not a valid `rpc_request` frame (i.e. it
|
|
|
|
|
/// should be forwarded to the CRDT sync handler instead).
|
2026-05-13 04:43:48 +00:00
|
|
|
pub(crate) async fn try_handle_rpc_text(text: &str) -> Option<RpcFrame> {
|
2026-04-27 21:22:55 +00:00
|
|
|
let frame: RpcFrame = serde_json::from_str(text).ok()?;
|
|
|
|
|
match frame {
|
|
|
|
|
RpcFrame::RpcRequest {
|
|
|
|
|
version,
|
|
|
|
|
correlation_id,
|
|
|
|
|
method,
|
|
|
|
|
params,
|
|
|
|
|
..
|
|
|
|
|
} => {
|
2026-05-13 04:43:48 +00:00
|
|
|
let response = match dispatch(&method, params).await {
|
2026-04-27 21:22:55 +00:00
|
|
|
Ok(result) => RpcFrame::RpcResponse {
|
|
|
|
|
version,
|
|
|
|
|
correlation_id,
|
|
|
|
|
ok: true,
|
|
|
|
|
result: Some(result),
|
|
|
|
|
error: None,
|
|
|
|
|
code: None,
|
|
|
|
|
},
|
|
|
|
|
Err(code) => RpcFrame::RpcResponse {
|
|
|
|
|
version,
|
|
|
|
|
correlation_id,
|
|
|
|
|
ok: false,
|
|
|
|
|
result: None,
|
|
|
|
|
error: Some(format!("unknown method: {method}")),
|
|
|
|
|
code: Some(code.to_string()),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
Some(response)
|
|
|
|
|
}
|
|
|
|
|
// Response frames arriving on our socket are not requests — nothing to send back.
|
|
|
|
|
RpcFrame::RpcResponse { .. } => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn health_check_returns_ok_status() {
|
|
|
|
|
let result = dispatch("health.check", serde_json::json!({})).await;
|
2026-04-27 21:22:55 +00:00
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert_eq!(val["status"], "ok");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn unknown_method_returns_not_found() {
|
|
|
|
|
let result = dispatch("nonexistent.method", serde_json::json!({})).await;
|
2026-04-27 21:22:55 +00:00
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert_eq!(result.unwrap_err(), "NOT_FOUND");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn try_handle_rpc_text_health_check() {
|
2026-04-27 21:22:55 +00:00
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"kind": "rpc_request",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "test-corr-1",
|
|
|
|
|
"ttl_ms": 5000,
|
|
|
|
|
"method": "health.check",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&req).unwrap();
|
2026-05-13 04:43:48 +00:00
|
|
|
let resp = try_handle_rpc_text(&text)
|
|
|
|
|
.await
|
|
|
|
|
.expect("must produce a response");
|
2026-04-27 21:22:55 +00:00
|
|
|
match resp {
|
|
|
|
|
RpcFrame::RpcResponse {
|
|
|
|
|
ok,
|
|
|
|
|
correlation_id,
|
|
|
|
|
result,
|
|
|
|
|
code,
|
|
|
|
|
..
|
|
|
|
|
} => {
|
|
|
|
|
assert!(ok, "health.check must succeed");
|
|
|
|
|
assert_eq!(correlation_id, "test-corr-1");
|
|
|
|
|
assert!(result.is_some());
|
|
|
|
|
assert_eq!(result.unwrap()["status"], "ok");
|
|
|
|
|
assert!(code.is_none());
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected RpcResponse"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn try_handle_rpc_text_unknown_method_returns_not_found() {
|
2026-04-27 21:22:55 +00:00
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"kind": "rpc_request",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "test-corr-2",
|
|
|
|
|
"ttl_ms": 1000,
|
|
|
|
|
"method": "no.such.method",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&req).unwrap();
|
2026-05-13 04:43:48 +00:00
|
|
|
let resp = try_handle_rpc_text(&text)
|
|
|
|
|
.await
|
|
|
|
|
.expect("must produce a response for unknown method");
|
2026-04-27 21:22:55 +00:00
|
|
|
match resp {
|
|
|
|
|
RpcFrame::RpcResponse { ok, code, .. } => {
|
|
|
|
|
assert!(!ok, "unknown method must not succeed");
|
|
|
|
|
assert_eq!(code.unwrap(), "NOT_FOUND");
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected RpcResponse"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn try_handle_rpc_text_ignores_non_rpc_frames() {
|
2026-04-27 21:22:55 +00:00
|
|
|
let bulk = r#"{"type":"bulk","ops":[]}"#;
|
2026-05-13 04:43:48 +00:00
|
|
|
assert!(try_handle_rpc_text(bulk).await.is_none());
|
2026-04-27 21:22:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn try_handle_rpc_text_ignores_rpc_response_frames() {
|
2026-04-27 21:22:55 +00:00
|
|
|
let resp = serde_json::json!({
|
|
|
|
|
"kind": "rpc_response",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "x",
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": {"status": "ok"}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&resp).unwrap();
|
2026-05-13 04:43:48 +00:00
|
|
|
assert!(try_handle_rpc_text(&text).await.is_none());
|
2026-04-27 21:22:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn try_handle_rpc_text_ignores_invalid_json() {
|
|
|
|
|
assert!(try_handle_rpc_text("not json at all").await.is_none());
|
2026-04-27 21:22:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 04:43:48 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn rpc_response_correlation_id_mirrors_request() {
|
2026-04-27 21:22:55 +00:00
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"kind": "rpc_request",
|
|
|
|
|
"version": 1,
|
|
|
|
|
"correlation_id": "mirror-me",
|
|
|
|
|
"ttl_ms": 500,
|
|
|
|
|
"method": "health.check",
|
|
|
|
|
"params": {}
|
|
|
|
|
});
|
|
|
|
|
let text = serde_json::to_string(&req).unwrap();
|
2026-05-13 04:43:48 +00:00
|
|
|
let resp = try_handle_rpc_text(&text).await.unwrap();
|
2026-04-27 21:22:55 +00:00
|
|
|
match resp {
|
|
|
|
|
RpcFrame::RpcResponse { correlation_id, .. } => {
|
|
|
|
|
assert_eq!(correlation_id, "mirror-me");
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected RpcResponse"),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 04:43:48 +00:00
|
|
|
|
|
|
|
|
// ── context-dependent handlers (no context set) ──────────────────────────
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn agent_config_list_returns_value_without_context() {
|
|
|
|
|
let result = dispatch("agent_config.list", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn settings_get_editor_returns_editor_command_key() {
|
|
|
|
|
let result = dispatch("settings.get_editor", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert!(val.get("editor_command").is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn model_get_preference_returns_a_value() {
|
|
|
|
|
let result = dispatch("model.get_preference", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn project_current_returns_a_value() {
|
|
|
|
|
let result = dispatch("project.current", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn project_known_returns_a_value() {
|
|
|
|
|
let result = dispatch("project.known", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn anthropic_key_exists_returns_a_value() {
|
|
|
|
|
let result = dispatch("anthropic.key_exists", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert!(val.is_boolean() || val.is_object());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn io_home_directory_returns_a_value() {
|
|
|
|
|
let result = dispatch("io.home_directory", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn token_usage_all_returns_records_envelope() {
|
|
|
|
|
let result = dispatch("token_usage.all", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert!(val.get("records").is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn oauth_status_returns_accounts_envelope() {
|
|
|
|
|
let result = dispatch("oauth.status", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert!(val.get("accounts").is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn bot_config_get_returns_full_shape() {
|
|
|
|
|
let result = dispatch("bot_config.get", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
for key in [
|
|
|
|
|
"transport",
|
|
|
|
|
"enabled",
|
|
|
|
|
"homeserver",
|
|
|
|
|
"username",
|
|
|
|
|
"password",
|
|
|
|
|
"room_ids",
|
|
|
|
|
"slack_bot_token",
|
|
|
|
|
"slack_signing_secret",
|
|
|
|
|
"slack_channel_ids",
|
|
|
|
|
] {
|
|
|
|
|
assert!(val.get(key).is_some(), "missing key {key}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn work_items_get_missing_story_id_returns_error() {
|
|
|
|
|
let result = dispatch("work_items.get", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert!(val.get("error").is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn agents_get_output_missing_story_id_returns_error() {
|
|
|
|
|
let result = dispatch("agents.get_output", serde_json::json!({})).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let val = result.unwrap();
|
|
|
|
|
assert!(val.get("error").is_some());
|
|
|
|
|
}
|
2026-04-27 21:22:55 +00:00
|
|
|
}
|