use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Args;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::config::Config;
#[derive(Args, Clone)]
pub struct ServeArgs {
#[arg(short, long, default_value = "3000")]
pub port: u16,
#[arg(short = 'H', long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long)]
pub cors: Option<String>,
#[arg(long)]
pub token: Option<String>,
#[arg(long)]
pub allow_admin: bool,
#[arg(long)]
pub workdir_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub tls_cert: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub tls_key: Option<PathBuf>,
#[arg(long)]
pub no_remote_yolo: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerEvent {
AgentStatus {
agent_id: String,
run_id: String,
status: String,
stage: String,
iteration: usize,
#[serde(default)]
tool_calls: usize,
accepts_messages: bool,
},
ContextUpdate {
agent_id: String,
run_id: String,
total_tokens: usize,
max_tokens: usize,
},
Log {
agent_id: String,
run_id: String,
line: String,
},
InteractionNeeded {
agent_id: String,
run_id: String,
request: serde_json::Value,
},
AgentSpawned {
agent_id: String,
run_id: String,
parent_id: Option<String>,
blueprint: String,
},
AgentCompleted {
agent_id: String,
run_id: String,
status: String,
result: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
final_output: Option<FinalOutputResp>,
},
Tokens {
agent_id: String,
run_id: String,
prompt_tokens: usize,
completion_tokens: usize,
#[serde(default)]
cached_tokens: usize,
#[serde(default)]
cache_write_tokens: usize,
},
World {
event: serde_json::Value,
},
}
impl ServerEvent {
pub fn run_id(&self) -> &str {
match self {
ServerEvent::AgentStatus { run_id, .. }
| ServerEvent::ContextUpdate { run_id, .. }
| ServerEvent::Log { run_id, .. }
| ServerEvent::InteractionNeeded { run_id, .. }
| ServerEvent::AgentSpawned { run_id, .. }
| ServerEvent::AgentCompleted { run_id, .. }
| ServerEvent::Tokens { run_id, .. } => run_id,
ServerEvent::World { event } => event
.get("run_id")
.and_then(|v| v.as_str())
.unwrap_or_default(),
}
}
}
#[derive(Clone)]
pub struct AppState {
pub(super) config: Arc<Config>,
pub(super) event_tx: broadcast::Sender<ServerEvent>,
pub(super) control: leviath_runtime::control_socket::ControlClient,
pub(super) mcp: super::mcp::McpAdmin,
pub(super) limits: Arc<ServeLimits>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct ServeLimits {
pub(super) workdir_root: Option<PathBuf>,
pub(super) no_remote_yolo: bool,
pub(super) allow_local_network: bool,
}
impl ServeLimits {
pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
let parsed = url
.parse::<url::Url>()
.map_err(|e| format!("callback_url is not a URL: {e}"))?;
leviath_net::check_url(&parsed, self.allow_local_network)
.map_err(|e| format!("callback_url is not allowed: {e}"))
}
pub(super) fn check_launch_overrides(
&self,
yolo: bool,
allow: &[String],
) -> Result<(), String> {
if !self.no_remote_yolo {
return Ok(());
}
match (yolo, allow.is_empty()) {
(false, true) => Ok(()),
_ => Err(
"this server refuses `yolo` and `allow` on spawn requests (--no-remote-yolo)"
.to_string(),
),
}
}
pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
let Some(root) = &self.workdir_root else {
return Ok(());
};
match leviath_core::resolves_within(workdir, root) {
true => Ok(()),
false => Err(format!(
"workdir '{}' is outside the configured --workdir-root '{}'",
workdir.display(),
root.display()
)),
}
}
}
#[derive(Debug, Serialize)]
pub(super) struct ErrorResponse {
pub(super) error: String,
}
pub(super) fn err(
code: axum::http::StatusCode,
message: String,
) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
(code, axum::response::Json(ErrorResponse { error: message }))
}
#[derive(Debug, Serialize)]
pub(super) struct Page<T> {
pub(super) items: Vec<T>,
pub(super) next_cursor: Option<String>,
pub(super) total: Option<usize>,
pub(super) server_time: i64,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub(super) scan_truncated: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(super) missing: Vec<String>,
}
impl<T> Page<T> {
pub(super) fn new(
items: Vec<T>,
next_cursor: Option<String>,
total: Option<usize>,
server_time: i64,
) -> Self {
Self {
items,
next_cursor,
total,
server_time,
scan_truncated: false,
missing: Vec::new(),
}
}
}
#[derive(Debug, Serialize)]
pub(super) struct RunItem {
pub(super) meta: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(super) highlights: Vec<Highlight>,
}
#[derive(Debug, Serialize)]
pub(super) struct Highlight {
pub(super) field: String,
pub(super) snippet: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) stage: Option<usize>,
}
#[derive(Debug, Serialize)]
pub(super) struct BlueprintInfo {
pub(super) name: String,
pub(super) version: String,
pub(super) description: String,
pub(super) path: String,
pub(super) stages: Vec<String>,
}
#[derive(Deserialize, Default)]
pub(super) struct BlueprintsQuery {
pub(super) limit: Option<usize>,
pub(super) cursor: Option<String>,
pub(super) q: Option<String>,
pub(super) sort: Option<String>,
pub(super) order: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct CreateBlueprintReq {
pub(super) name: String,
pub(super) manifest: String,
}
#[derive(Deserialize)]
pub(super) struct UpdateBlueprintReq {
pub(super) manifest: String,
}
#[derive(Deserialize)]
pub(super) struct ValidateBlueprintReq {
pub(super) manifest: String,
}
#[derive(Serialize, Deserialize)]
pub(super) struct ValidateResponse {
pub(super) valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) errors: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) warnings: Option<Vec<String>>,
}
impl ValidateResponse {
pub(super) fn invalid(errors: Vec<String>) -> Self {
Self {
valid: false,
errors: Some(errors),
warnings: None,
}
}
}
#[derive(Default, Deserialize)]
pub(super) struct SpawnAgentReq {
pub(super) blueprint: String,
pub(super) task: String,
pub(super) model: Option<String>,
pub(super) max_depth: Option<usize>,
#[serde(default)]
pub(super) yolo: bool,
#[serde(default)]
pub(super) allow: Vec<String>,
#[serde(default)]
pub(super) no_seed_commands: bool,
pub(super) workdir: Option<String>,
#[serde(default)]
pub(super) regions: HashMap<String, String>,
#[serde(default)]
pub(super) metadata: HashMap<String, String>,
pub(super) callback_url: Option<String>,
pub(super) callback_secret: Option<String>,
pub(super) output_format: Option<String>,
pub(super) output_instructions: Option<String>,
pub(super) output_schema: Option<serde_json::Value>,
}
#[derive(Serialize, Debug, Clone)]
pub struct FinalOutputResp {
pub content: String,
pub format: Option<String>,
pub stage: String,
pub submitted_at: i64,
pub truncated: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<String>,
}
impl From<leviath_core::output::FinalOutput> for FinalOutputResp {
fn from(o: leviath_core::output::FinalOutput) -> Self {
Self {
content: o.content,
format: o.format,
stage: o.stage,
submitted_at: o.submitted_at,
truncated: o.truncated,
artifacts: o.artifacts,
}
}
}
#[derive(Serialize, Debug)]
pub(super) struct SpawnAgentResp {
pub(super) agent_id: String,
pub(super) run_id: String,
}
#[derive(Deserialize)]
pub(super) struct ListAgentsQuery {
pub(super) status: Option<String>,
}
pub(super) fn status_matches(status: &crate::runstate::RunStatus, filter: &str) -> bool {
fn normalize(s: &str) -> String {
s.chars()
.filter(|c| *c != '_' && *c != '-')
.flat_map(char::to_lowercase)
.collect()
}
normalize(&format!("{status}")) == normalize(filter)
}
#[derive(Serialize)]
pub(super) struct AgentResultResp {
pub(super) run_id: String,
pub(super) status: String,
pub(super) output: String,
pub(super) final_output: Option<FinalOutputResp>,
pub(super) error: Option<String>,
pub(super) prompt_tokens: usize,
pub(super) completion_tokens: usize,
}
#[derive(Deserialize, Default)]
pub(super) struct LogsQuery {
pub(super) tail: Option<u64>,
pub(super) stage: Option<String>,
pub(super) stream: Option<String>,
}
impl LogsQuery {
pub(super) fn selector(&self) -> Result<crate::runstate::StageSelector, String> {
use crate::runstate::StageSelector;
match self.stage.as_deref() {
None => Ok(StageSelector::Current),
Some("all") => Ok(StageSelector::All),
Some(other) => other
.parse::<usize>()
.map(StageSelector::Index)
.map_err(|_| format!("Invalid stage '{other}': expected a stage index or 'all'")),
}
}
pub(super) fn log_stream(&self) -> Result<crate::runstate::LogStream, String> {
use crate::runstate::LogStream;
match self.stream.as_deref() {
None | Some("output") => Ok(LogStream::Output),
Some("logs") => Ok(LogStream::Operational),
Some(other) => Err(format!(
"Invalid stream '{other}': expected 'output' or 'logs'"
)),
}
}
}
#[derive(Deserialize, Default)]
pub(super) struct HistoryQuery {
pub(super) limit: Option<usize>,
pub(super) cursor: Option<String>,
pub(super) order: Option<String>,
}
#[derive(Deserialize, Default)]
pub(super) struct FileQuery {
pub(super) path: Option<String>,
pub(super) source: Option<String>,
#[serde(default)]
pub(super) hidden: bool,
pub(super) offset: Option<u64>,
}
fn is_zero(n: &u64) -> bool {
*n == 0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FileSource {
Modified,
Workdir,
}
impl FileQuery {
pub(super) fn file_source(&self) -> Result<FileSource, String> {
match self.source.as_deref() {
None | Some("modified") => Ok(FileSource::Modified),
Some("workdir") => Ok(FileSource::Workdir),
Some(other) => Err(format!(
"Invalid source '{other}': expected 'modified' or 'workdir'"
)),
}
}
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(super) enum FileOrListing {
File(FileContentResp),
Listing(Box<RunFileListing>),
}
#[derive(Debug, Serialize)]
pub(super) struct RunStagesResp {
pub(super) run_id: String,
pub(super) stages: Vec<leviath_core::run_meta::StageRecord>,
}
#[derive(Debug, Serialize)]
pub(super) struct RunFileListing {
pub(super) kind: &'static str,
pub(super) source: &'static str,
pub(super) path: String,
pub(super) parent: Option<String>,
pub(super) workdir: String,
pub(super) entries: Vec<RunFileEntry>,
pub(super) truncated: bool,
pub(super) modified_files_truncated: bool,
pub(super) modifying_tool_calls: usize,
}
#[derive(Debug, Serialize)]
pub(super) struct RunFileEntry {
pub(super) name: String,
pub(super) path: String,
pub(super) is_dir: bool,
pub(super) size: Option<u64>,
pub(super) exists: bool,
pub(super) outside_workdir: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct FileContentResp {
pub(super) path: String,
pub(super) size: u64,
#[serde(default, skip_serializing_if = "is_zero")]
pub(super) offset: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) next_offset: Option<u64>,
pub(super) content: String,
pub(super) truncated: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DoctorResp {
pub(super) checks: Vec<DoctorCheck>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DoctorCheck {
pub(super) name: String,
pub(super) ok: bool,
pub(super) detail: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) elapsed_ms: Option<u64>,
}
#[derive(Deserialize)]
pub(super) struct DirsQuery {
pub(super) path: Option<String>,
#[serde(default)]
pub(super) hidden: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DirsResp {
pub(super) path: String,
pub(super) parent: Option<String>,
pub(super) home: String,
pub(super) cwd: String,
pub(super) root: Option<String>,
pub(super) dirs: Vec<DirEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct DirEntry {
pub(super) name: String,
pub(super) path: String,
}
#[derive(Serialize)]
pub(super) struct AgentTreeNode {
pub(super) run_id: String,
pub(super) agent_name: String,
pub(super) status: String,
pub(super) stage: String,
pub(super) iteration: usize,
pub(super) prompt_tokens: usize,
pub(super) completion_tokens: usize,
pub(super) children: Vec<AgentTreeNode>,
}
#[derive(Debug, Serialize)]
pub(super) struct TreeStatusNode {
pub(super) run_id: String,
pub(super) agent_name: String,
pub(super) status: String,
pub(super) stage: String,
pub(super) prompt_tokens: usize,
pub(super) completion_tokens: usize,
pub(super) subtree_prompt_tokens: usize,
pub(super) subtree_completion_tokens: usize,
pub(super) children: Vec<TreeStatusNode>,
}
#[derive(Deserialize)]
pub(super) struct SubmitInteractionReq {
pub(super) request_id: String,
pub(super) value: Option<String>,
pub(super) choice_index: Option<usize>,
pub(super) approved: Option<bool>,
pub(super) scope: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct SendMessageReq {
pub(super) message: String,
#[serde(default)]
pub(super) target_region: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub(super) struct RedactedConfig {
pub(super) default_provider: String,
pub(super) has_anthropic_key: bool,
pub(super) has_openai_key: bool,
pub(super) has_google_key: bool,
pub(super) has_openrouter_key: bool,
pub(super) ollama_base_url: Option<String>,
pub(super) agent_paths: Vec<PathBuf>,
pub(super) mcp_server_count: usize,
pub(super) api_version: String,
pub(super) capabilities: Vec<String>,
pub(super) limits: ApiLimits,
}
pub(super) const API_VERSION: &str = "0.3.0";
pub(super) const API_CAPABILITIES: &[&str] = &[
"runs.envelope",
"runs.cursor",
"runs.search",
"runs.search.context",
"runs.search.logs",
"runs.search.journal",
"runs.fields",
"runs.ids",
"runs.since",
"runs.files.listing",
"runs.files.workdir",
"runs.stages",
"logs.stage",
"logs.stream",
"context.history.page",
"blueprints.envelope",
"blueprints.query",
];
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ApiLimits {
pub(super) max_limit: usize,
pub(super) max_ids: usize,
pub(super) max_file_bytes: u64,
pub(super) max_listing_entries: usize,
pub(super) max_search_scan: usize,
pub(super) search_log_tail_bytes: u64,
pub(super) max_history_limit: usize,
pub(super) max_tracked_modified_files: usize,
}
impl ApiLimits {
pub(super) fn current() -> Self {
Self {
max_limit: super::runs::MAX_LIMIT,
max_ids: super::runs::MAX_IDS,
max_file_bytes: super::agents::MAX_FILE_READ_BYTES,
max_listing_entries: super::agents::MAX_LISTING_ENTRIES,
max_search_scan: super::runs::MAX_SEARCH_SCAN,
search_log_tail_bytes: super::runs::SEARCH_LOG_TAIL_BYTES,
max_history_limit: super::agents::HISTORY_MAX_LIMIT,
max_tracked_modified_files: leviath_core::run_meta::MAX_TRACKED_MODIFIED_FILES,
}
}
}
#[derive(Debug, Default, Deserialize)]
pub(super) struct WriteConfigReq {
pub(super) default_provider: Option<String>,
pub(super) default_model: Option<String>,
pub(super) anthropic_key: Option<String>,
pub(super) openai_key: Option<String>,
pub(super) google_key: Option<String>,
pub(super) openrouter_key: Option<String>,
pub(super) ollama_base_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct ValidateKeyReq {
pub(super) provider: String,
pub(super) key: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct ValidateKeyResp {
pub(super) valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) message: Option<String>,
}
#[derive(Serialize)]
pub(super) struct ModelEntry {
pub(super) id: String,
pub(super) provider: String,
pub(super) display_name: Option<String>,
pub(super) max_context_tokens: usize,
pub(super) max_output_tokens: usize,
pub(super) supports_tools: bool,
}
#[cfg(test)]
mod status_matches_tests {
use super::*;
use crate::runstate::RunStatus;
#[test]
fn the_serde_spelling_a_client_reads_back_is_accepted() {
assert!(status_matches(&RunStatus::WaitingInput, "waiting_input"));
assert!(status_matches(
&RunStatus::CompleteInteractive,
"complete_interactive"
));
}
#[test]
fn the_display_spelling_that_already_worked_still_does() {
assert!(status_matches(&RunStatus::WaitingInput, "waitinginput"));
assert!(status_matches(&RunStatus::Running, "running"));
assert!(status_matches(&RunStatus::Running, "Running"));
}
#[test]
fn hyphens_and_mixed_case_are_accepted_too() {
assert!(status_matches(&RunStatus::WaitingInput, "Waiting-Input"));
assert!(status_matches(
&RunStatus::CompleteInteractive,
"COMPLETE-INTERACTIVE"
));
}
#[test]
fn a_different_status_still_does_not_match() {
assert!(!status_matches(&RunStatus::Running, "complete"));
assert!(!status_matches(
&RunStatus::Complete,
"complete_interactive"
));
assert!(!status_matches(&RunStatus::CompleteInteractive, "complete"));
assert!(!status_matches(&RunStatus::Running, ""));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_event_agent_status_serialization() {
let event = ServerEvent::AgentStatus {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
status: "running".to_string(),
stage: "implement".to_string(),
iteration: 5,
tool_calls: 12,
accepts_messages: true,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"agent_status\""));
assert!(json.contains("\"agent_id\":\"coder\""));
assert!(json.contains("\"iteration\":5"));
assert!(json.contains("\"tool_calls\":12"));
}
#[test]
fn server_event_tokens_serialization() {
let event = ServerEvent::Tokens {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
prompt_tokens: 5000,
completion_tokens: 1200,
cached_tokens: 200,
cache_write_tokens: 100,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"tokens\""));
assert!(json.contains("\"prompt_tokens\":5000"));
assert!(json.contains("\"cached_tokens\":200"));
assert!(json.contains("\"cache_write_tokens\":100"));
}
#[test]
fn server_event_agent_spawned_serialization() {
let event = ServerEvent::AgentSpawned {
agent_id: "coder".to_string(),
run_id: "run-456".to_string(),
parent_id: Some("run-123".to_string()),
blueprint: "coder".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"agent_spawned\""));
assert!(json.contains("\"parent_id\":\"run-123\""));
}
#[test]
fn server_event_agent_completed_serialization() {
let event = ServerEvent::AgentCompleted {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
status: "complete".to_string(),
result: Some("success".to_string()),
final_output: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"agent_completed\""));
}
#[test]
fn server_event_context_update_serialization() {
let event = ServerEvent::ContextUpdate {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
total_tokens: 10000,
max_tokens: 200000,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"context_update\""));
assert!(json.contains("\"total_tokens\":10000"));
}
#[test]
fn server_event_interaction_needed_serialization() {
let event = ServerEvent::InteractionNeeded {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
request: serde_json::json!({"prompt": "approve?"}),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"interaction_needed\""));
}
#[test]
fn server_event_log_serialization() {
let event = ServerEvent::Log {
agent_id: "coder".to_string(),
run_id: "run-123".to_string(),
line: "doing work".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"log\""));
assert!(json.contains("\"line\":\"doing work\""));
}
#[test]
fn validate_response_serde_roundtrip() {
let resp = ValidateResponse {
valid: true,
errors: None,
warnings: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, r#"{"valid":true}"#);
let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.valid);
assert!(parsed.errors.is_none());
assert!(parsed.warnings.is_none());
}
#[test]
fn validate_response_with_errors_roundtrip() {
let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
let json = serde_json::to_string(&resp).unwrap();
let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
assert!(!parsed.valid);
assert_eq!(parsed.errors.unwrap().len(), 1);
assert!(parsed.warnings.is_none());
}
#[test]
fn validate_response_with_warnings_roundtrip() {
let resp = ValidateResponse {
valid: true,
errors: None,
warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.valid);
assert_eq!(parsed.warnings.unwrap().len(), 1);
}
#[test]
fn redacted_config_serde_roundtrip() {
let config = RedactedConfig {
default_provider: "anthropic".to_string(),
has_anthropic_key: true,
has_openai_key: false,
has_google_key: false,
has_openrouter_key: false,
ollama_base_url: None,
agent_paths: vec![],
mcp_server_count: 0,
api_version: API_VERSION.to_string(),
capabilities: API_CAPABILITIES.iter().map(|c| c.to_string()).collect(),
limits: ApiLimits::current(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.default_provider, "anthropic");
assert!(parsed.has_anthropic_key);
assert!(!parsed.has_openai_key);
}
#[test]
fn error_response_serialization() {
let err = ErrorResponse {
error: "not found".to_string(),
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"error\":\"not found\""));
}
#[test]
fn file_content_resp_serde_roundtrip() {
let resp = FileContentResp {
path: "/work/report.md".to_string(),
size: 9,
offset: 0,
next_offset: None,
content: "# Report\n".to_string(),
truncated: false,
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: FileContentResp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.path, "/work/report.md");
assert_eq!(parsed.size, 9);
assert_eq!(parsed.content, "# Report\n");
assert!(!parsed.truncated);
}
#[test]
fn dirs_resp_serde_roundtrip() {
let resp = DirsResp {
path: "/work".to_string(),
parent: None,
home: "/Users/someone".to_string(),
cwd: "/work/project".to_string(),
root: Some("/work".to_string()),
dirs: vec![DirEntry {
name: "src".to_string(),
path: "/work/src".to_string(),
}],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"parent\":null"));
assert!(json.contains(r#"{"name":"src","path":"/work/src"}"#));
let parsed: DirsResp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.path, "/work");
assert!(parsed.parent.is_none());
assert_eq!(parsed.root.as_deref(), Some("/work"));
assert_eq!(parsed.dirs.len(), 1);
assert_eq!(parsed.dirs[0].name, "src");
}
#[test]
fn doctor_resp_serde_roundtrip() {
let resp = DoctorResp {
checks: vec![
DoctorCheck {
name: "config".to_string(),
ok: true,
detail: "default_provider=anthropic".to_string(),
elapsed_ms: None,
},
DoctorCheck {
name: "inference".to_string(),
ok: false,
detail: "HTTP 401: bad key".to_string(),
elapsed_ms: Some(1200),
},
],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(
json.contains(r#"{"name":"config","ok":true,"detail":"default_provider=anthropic"}"#)
);
assert!(json.contains("\"elapsed_ms\":1200"));
let parsed: DoctorResp = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.checks.len(), 2);
assert!(parsed.checks[0].ok);
assert!(parsed.checks[0].elapsed_ms.is_none());
assert!(!parsed.checks[1].ok);
assert_eq!(parsed.checks[1].elapsed_ms, Some(1200));
}
#[test]
fn server_event_run_id_covers_every_variant() {
let cases: Vec<(ServerEvent, &str)> = vec![
(
ServerEvent::AgentStatus {
agent_id: "a".to_string(),
run_id: "r1".to_string(),
status: "active".to_string(),
stage: "s".to_string(),
iteration: 0,
tool_calls: 0,
accepts_messages: false,
},
"r1",
),
(
ServerEvent::ContextUpdate {
agent_id: "a".to_string(),
run_id: "r2".to_string(),
total_tokens: 1,
max_tokens: 2,
},
"r2",
),
(
ServerEvent::Log {
agent_id: "a".to_string(),
run_id: "r3".to_string(),
line: "l".to_string(),
},
"r3",
),
(
ServerEvent::InteractionNeeded {
agent_id: "a".to_string(),
run_id: "r4".to_string(),
request: serde_json::Value::Null,
},
"r4",
),
(
ServerEvent::AgentSpawned {
agent_id: "a".to_string(),
run_id: "r5".to_string(),
parent_id: None,
blueprint: "b".to_string(),
},
"r5",
),
(
ServerEvent::AgentCompleted {
agent_id: "a".to_string(),
run_id: "r6".to_string(),
status: "complete".to_string(),
result: None,
final_output: None,
},
"r6",
),
(
ServerEvent::Tokens {
agent_id: "a".to_string(),
run_id: "r7".to_string(),
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
cache_write_tokens: 0,
},
"r7",
),
(
ServerEvent::World {
event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
},
"r8",
),
(
ServerEvent::World {
event: serde_json::Value::Null,
},
"",
),
];
for (ev, want) in cases {
assert_eq!(ev.run_id(), want);
}
}
}