//! RPC method registry for the `/crdt-sync` WebSocket multiplexer. //! //! Incoming [`RpcFrame::RpcRequest`] frames are dispatched through this //! registry. Each method handler is an async function that accepts a //! `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 //! ("my.method", |p| Box::pin(handle_my_method(p))), //! ``` //! //! # 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"`. //! //! # 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}; use serde_json::Value; use super::rpc_contract::{ BotConfigPayload, EditorSettingsResult, ForgetProjectParams, OkResult, OpenFileParams, OpenProjectParams, OpenProjectResult, ProjectSettingsPayload, PutEditorParams, SetAnthropicApiKeyParams, SetModelPreferenceParams, }; use super::wire::RpcFrame; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; /// Future returned by an RPC handler. type HandlerFuture = Pin + Send>>; /// Signature for an async RPC method handler. pub(super) type Handler = fn(Value) -> HandlerFuture; /// Shared state made available to all RPC handlers. pub struct RpcState { pub state: Arc, pub store: Arc, pub workflow: Arc>, } /// Global RPC context, initialised once at server startup via [`init_rpc_context`]. static RPC_CTX: OnceLock = 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, store: Arc, workflow: Arc>, ) { let _ = RPC_CTX.set(RpcState { state, store, workflow, }); } /// 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. static HANDLERS: &[(&str, Handler)] = &[ ("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)) }), // ── 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))), ]; // ── 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) -> Value { serde_json::json!({"error": msg.into()}) } // ── handlers ───────────────────────────────────────────────────────────────── /// 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. async fn handle_health_check(_params: Value) -> Value { serde_json::json!({"status": "ok"}) } /// 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`. async fn handle_active_agents_list(_params: Value) -> Value { let entries = crate::crdt_state::read_all_active_agents().unwrap_or_default(); let list: Vec = 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) } /// 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 = 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::>(), }), 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::(&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()}), } } // ── 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), } } // ── dispatch ────────────────────────────────────────────────────────────────── /// 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`. pub(super) async fn dispatch(method: &str, params: Value) -> Result { for (name, handler) in HANDLERS { if *name == method { return Ok(handler(params).await); } } 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). pub(crate) async fn try_handle_rpc_text(text: &str) -> Option { let frame: RpcFrame = serde_json::from_str(text).ok()?; match frame { RpcFrame::RpcRequest { version, correlation_id, method, params, .. } => { let response = match dispatch(&method, params).await { 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::*; #[tokio::test] async fn health_check_returns_ok_status() { let result = dispatch("health.check", serde_json::json!({})).await; assert!(result.is_ok()); let val = result.unwrap(); assert_eq!(val["status"], "ok"); } #[tokio::test] async fn unknown_method_returns_not_found() { let result = dispatch("nonexistent.method", serde_json::json!({})).await; assert!(result.is_err()); assert_eq!(result.unwrap_err(), "NOT_FOUND"); } #[tokio::test] async fn try_handle_rpc_text_health_check() { 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(); let resp = try_handle_rpc_text(&text) .await .expect("must produce a response"); 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"), } } #[tokio::test] async fn try_handle_rpc_text_unknown_method_returns_not_found() { 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(); let resp = try_handle_rpc_text(&text) .await .expect("must produce a response for unknown method"); match resp { RpcFrame::RpcResponse { ok, code, .. } => { assert!(!ok, "unknown method must not succeed"); assert_eq!(code.unwrap(), "NOT_FOUND"); } _ => panic!("Expected RpcResponse"), } } #[tokio::test] async fn try_handle_rpc_text_ignores_non_rpc_frames() { let bulk = r#"{"type":"bulk","ops":[]}"#; assert!(try_handle_rpc_text(bulk).await.is_none()); } #[tokio::test] async fn try_handle_rpc_text_ignores_rpc_response_frames() { 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(); assert!(try_handle_rpc_text(&text).await.is_none()); } #[tokio::test] async fn try_handle_rpc_text_ignores_invalid_json() { assert!(try_handle_rpc_text("not json at all").await.is_none()); } #[tokio::test] async fn rpc_response_correlation_id_mirrors_request() { 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(); let resp = try_handle_rpc_text(&text).await.unwrap(); match resp { RpcFrame::RpcResponse { correlation_id, .. } => { assert_eq!(correlation_id, "mirror-me"); } _ => panic!("Expected RpcResponse"), } } // ── 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()); } }