Skip to main content

leviath_cli/commands/serve/
types.rs

1//! Shared types: ServerEvent, AppState, request/response structs, error types.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use clap::Args;
8use serde::{Deserialize, Serialize};
9use tokio::sync::broadcast;
10
11use crate::config::Config;
12
13// ─── CLI ─────────────────────────────────────────────────────────────────────
14
15#[derive(Args)]
16pub struct ServeArgs {
17    /// Port to listen on
18    #[arg(short, long, default_value = "3000")]
19    pub port: u16,
20
21    /// Host to bind to
22    #[arg(short = 'H', long, default_value = "127.0.0.1")]
23    pub host: String,
24
25    /// Allow browser requests from this origin (e.g. `http://localhost:5173`).
26    ///
27    /// Defaults to **none**: the API is for programmatic clients, which are not
28    /// subject to CORS at all, so a browser-facing default of `*` gave nothing
29    /// to the normal case and widened the surface for the unusual one. A
30    /// dashboard served from another origin sets this explicitly.
31    ///
32    /// `*` is still accepted and still means "any origin". It is now a decision
33    /// someone typed rather than what you get by not thinking about it.
34    #[arg(long)]
35    pub cors: Option<String>,
36
37    /// API token clients must present (`Authorization: Bearer <token>`, or
38    /// `?token=` for WebSockets). Overrides the LEVIATH_API_TOKEN env var; the
39    /// server refuses to start if neither is set.
40    ///
41    /// Prefer the environment variable: an argument is visible in `ps` to every
42    /// local user for the lifetime of the process.
43    #[arg(long)]
44    pub token: Option<String>,
45
46    /// Enable the MCP administration endpoints (`POST`/`DELETE
47    /// /api/mcp/servers`).
48    ///
49    /// **Off by default, because they are remote code execution by
50    /// construction.** Adding an MCP server writes a `command` and `args` into
51    /// `~/.leviath/config.toml`, and Leviath then spawns exactly that - so any
52    /// token holder could run an arbitrary process, persistently, for every
53    /// future run. The rest of the API can only run agents the user already
54    /// installed; this one adds new executables to the machine.
55    #[arg(long)]
56    pub allow_admin: bool,
57
58    /// Restrict agent working directories to this root.
59    ///
60    /// Without it, `POST /api/agents` accepts any `workdir` - including `/` -
61    /// so a token holder can point a tool-executing agent at the whole
62    /// filesystem. Set this to the directory the API is meant to work in.
63    #[arg(long)]
64    pub workdir_root: Option<PathBuf>,
65
66    /// Refuse `"yolo": true` on spawn requests, so an API caller cannot waive
67    /// every approval prompt for an agent running on the host.
68    #[arg(long)]
69    pub no_remote_yolo: bool,
70}
71
72// ─── Shared state ────────────────────────────────────────────────────────────
73
74/// Events broadcast to WebSocket subscribers.
75#[derive(Debug, Clone, Serialize)]
76#[serde(tag = "type", rename_all = "snake_case")]
77pub enum ServerEvent {
78    AgentStatus {
79        agent_id: String,
80        run_id: String,
81        status: String,
82        stage: String,
83        iteration: usize,
84        #[serde(default)]
85        tool_calls: usize,
86        accepts_messages: bool,
87    },
88    ContextUpdate {
89        agent_id: String,
90        run_id: String,
91        total_tokens: usize,
92        max_tokens: usize,
93    },
94    Log {
95        agent_id: String,
96        run_id: String,
97        line: String,
98    },
99    InteractionNeeded {
100        agent_id: String,
101        run_id: String,
102        request: serde_json::Value,
103    },
104    AgentSpawned {
105        agent_id: String,
106        run_id: String,
107        parent_id: Option<String>,
108        blueprint: String,
109    },
110    AgentCompleted {
111        agent_id: String,
112        run_id: String,
113        status: String,
114        result: Option<String>,
115    },
116    Tokens {
117        agent_id: String,
118        run_id: String,
119        prompt_tokens: usize,
120        completion_tokens: usize,
121        #[serde(default)]
122        cached_tokens: usize,
123        #[serde(default)]
124        cache_write_tokens: usize,
125    },
126    /// A world event with no dedicated WebSocket translation (stage
127    /// transitions, tool call start/finish, and whatever the runtime adds
128    /// next), forwarded verbatim. `event` is the runtime's own serde-tagged
129    /// [`WorldEvent`](leviath_runtime::host::WorldEvent) JSON, so clients get
130    /// new event kinds without a server release.
131    World { event: serde_json::Value },
132}
133
134impl ServerEvent {
135    /// The run id this event belongs to, for per-run subscription filtering.
136    /// `World` events read it from the wrapped JSON (every runtime event
137    /// carries one; an absent field filters as the empty string).
138    pub fn run_id(&self) -> &str {
139        match self {
140            ServerEvent::AgentStatus { run_id, .. }
141            | ServerEvent::ContextUpdate { run_id, .. }
142            | ServerEvent::Log { run_id, .. }
143            | ServerEvent::InteractionNeeded { run_id, .. }
144            | ServerEvent::AgentSpawned { run_id, .. }
145            | ServerEvent::AgentCompleted { run_id, .. }
146            | ServerEvent::Tokens { run_id, .. } => run_id,
147            ServerEvent::World { event } => event
148                .get("run_id")
149                .and_then(|v| v.as_str())
150                .unwrap_or_default(),
151        }
152    }
153}
154
155#[derive(Clone)]
156pub struct AppState {
157    pub(super) config: Arc<Config>,
158    pub(super) event_tx: broadcast::Sender<ServerEvent>,
159    /// Client for the shared-world daemon's control socket. Agent actions
160    /// (spawn/cancel/message/interactions) go through this; read endpoints still
161    /// observe the runs dir the daemon persists to.
162    pub(super) control: leviath_runtime::control_socket::ControlClient,
163    /// Paths + seams for the MCP management endpoints.
164    pub(super) mcp: super::mcp::McpAdmin,
165    /// The spawn-request restrictions from [`ServeArgs`], resolved once at
166    /// startup so every handler reads the same decision.
167    pub(super) limits: Arc<ServeLimits>,
168}
169
170/// What this server refuses regardless of who is asking.
171///
172/// A valid API token proves the caller is allowed to *use* the server; it does
173/// not mean they should be able to reconfigure the machine or point an agent at
174/// the filesystem root. These are the operator's answers to that, fixed at
175/// startup rather than negotiable per request.
176///
177/// `--allow-admin` is deliberately absent: it decides whether a route is
178/// *mounted at all*, so it is consumed once at router construction rather than
179/// carried here for a handler to consult. An unmounted route 404s; a mounted one
180/// guarded by a field is one refactor away from being reachable.
181#[derive(Debug, Clone, Default)]
182pub(super) struct ServeLimits {
183    /// `--workdir-root`: the directory agent workdirs must sit under.
184    pub(super) workdir_root: Option<PathBuf>,
185    /// `--no-remote-yolo`: whether a spawn request may set `"yolo": true`.
186    pub(super) no_remote_yolo: bool,
187    /// `[security] allow_local_network`: whether a completion webhook may point
188    /// at loopback, private or link-local addresses.
189    pub(super) allow_local_network: bool,
190}
191
192impl ServeLimits {
193    /// Check a requested agent workdir against `--workdir-root`.
194    ///
195    /// Uses the same symlink-aware containment the file tools use, so a symlink
196    /// under the root cannot be used to point an agent outside it.
197    /// Check a requested completion webhook against the same SSRF policy every
198    /// model-supplied URL goes through.
199    ///
200    /// The URL arrives in a `POST /api/agents` body, is persisted, and is POSTed
201    /// to when the run finishes - from inside the trust boundary, and with
202    /// retries. Unchecked, `"callback_url": "http://169.254.169.254/…"` made the
203    /// daemon a repeatable request primitive against the cloud metadata service
204    /// and anything else on the local network, on behalf of a caller that
205    /// `--workdir-root` and `--no-remote-yolo` exist to keep at arm's length.
206    ///
207    /// `allow_local_network` mirrors the config setting: an operator who
208    /// deliberately points webhooks at a service on the same host can, and
209    /// everyone else cannot.
210    pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
211        let parsed = url
212            .parse::<url::Url>()
213            .map_err(|e| format!("callback_url is not a URL: {e}"))?;
214        leviath_core::check_url(&parsed, self.allow_local_network)
215            .map_err(|e| format!("callback_url is not allowed: {e}"))
216    }
217
218    pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
219        let Some(root) = &self.workdir_root else {
220            return Ok(());
221        };
222        match leviath_core::resolves_within(workdir, root) {
223            true => Ok(()),
224            false => Err(format!(
225                "workdir '{}' is outside the configured --workdir-root '{}'",
226                workdir.display(),
227                root.display()
228            )),
229        }
230    }
231}
232
233// ─── Error response ─────────────────────────────────────────────────────────
234
235#[derive(Debug, Serialize)]
236pub(super) struct ErrorResponse {
237    pub(super) error: String,
238}
239
240/// Build a `(status, JSON error)` response tuple.
241pub(super) fn err(
242    code: axum::http::StatusCode,
243    message: String,
244) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
245    (code, axum::response::Json(ErrorResponse { error: message }))
246}
247
248// ─── Blueprint types ────────────────────────────────────────────────────────
249
250#[derive(Debug, Serialize)]
251pub(super) struct BlueprintInfo {
252    pub(super) name: String,
253    pub(super) version: String,
254    pub(super) description: String,
255    pub(super) path: String,
256    pub(super) stages: Vec<String>,
257}
258
259#[derive(Deserialize)]
260pub(super) struct CreateBlueprintReq {
261    pub(super) name: String,
262    pub(super) manifest: String,
263}
264
265#[derive(Deserialize)]
266pub(super) struct UpdateBlueprintReq {
267    pub(super) manifest: String,
268}
269
270#[derive(Deserialize)]
271pub(super) struct ValidateBlueprintReq {
272    pub(super) manifest: String,
273}
274
275#[derive(Serialize, Deserialize)]
276pub(super) struct ValidateResponse {
277    pub(super) valid: bool,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub(super) errors: Option<Vec<String>>,
280    /// Lint findings that do not make the blueprint invalid: fields left to a
281    /// default, a stage that can block on a human, a broad `[read_paths]`
282    /// entry. Absent when there are none.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub(super) warnings: Option<Vec<String>>,
285}
286
287impl ValidateResponse {
288    /// The response for a manifest that did not parse or did not validate.
289    pub(super) fn invalid(errors: Vec<String>) -> Self {
290        Self {
291            valid: false,
292            errors: Some(errors),
293            warnings: None,
294        }
295    }
296}
297
298// ─── Agent types ────────────────────────────────────────────────────────────
299
300#[derive(Default, Deserialize)]
301pub(super) struct SpawnAgentReq {
302    pub(super) blueprint: String,
303    pub(super) task: String,
304    pub(super) model: Option<String>,
305    /// Override the blueprint's max sub-agent tree depth.
306    pub(super) max_depth: Option<usize>,
307    /// Approve every tool call for this run.
308    #[serde(default)]
309    pub(super) yolo: bool,
310    /// Tools to allow outright for this run.
311    #[serde(default)]
312    pub(super) allow: Vec<String>,
313    /// Refuse this run's `seed = { command = ... }` regions, which would
314    /// otherwise execute at spawn before any approval prompt.
315    #[serde(default)]
316    pub(super) no_seed_commands: bool,
317    pub(super) workdir: Option<String>,
318    /// Literal seed content for named caller-input regions, keyed by region name.
319    #[serde(default)]
320    pub(super) regions: HashMap<String, String>,
321    #[serde(default)]
322    pub(super) metadata: HashMap<String, String>,
323    pub(super) callback_url: Option<String>,
324    /// Optional shared secret; when set, completion webhooks carry an
325    /// `X-Leviath-Signature: sha256=<hex>` HMAC of the body keyed on this secret.
326    pub(super) callback_secret: Option<String>,
327}
328
329#[derive(Serialize, Debug)]
330pub(super) struct SpawnAgentResp {
331    pub(super) agent_id: String,
332    pub(super) run_id: String,
333}
334
335#[derive(Deserialize)]
336pub(super) struct ListAgentsQuery {
337    pub(super) status: Option<String>,
338}
339
340#[derive(Serialize)]
341pub(super) struct AgentResultResp {
342    pub(super) run_id: String,
343    pub(super) status: String,
344    pub(super) output: String,
345    pub(super) error: Option<String>,
346    pub(super) prompt_tokens: usize,
347    pub(super) completion_tokens: usize,
348}
349
350#[derive(Deserialize)]
351pub(super) struct LogsQuery {
352    pub(super) tail: Option<u64>,
353}
354
355/// Query for `GET /api/agents/{id}/files`: the file to read. Relative paths
356/// resolve against the run's workdir; absolute paths are accepted but must
357/// still land inside it.
358#[derive(Deserialize)]
359pub(super) struct FileQuery {
360    pub(super) path: String,
361}
362
363/// Response of `GET /api/agents/{id}/files`: one file the run wrote, as text.
364#[derive(Debug, Serialize, Deserialize)]
365pub(super) struct FileContentResp {
366    /// The resolved absolute path that was read.
367    pub(super) path: String,
368    /// The file's full size in bytes - larger than `content` when `truncated`.
369    pub(super) size: u64,
370    /// The file's bytes as UTF-8, capped at
371    /// [`MAX_FILE_READ_BYTES`](super::agents::MAX_FILE_READ_BYTES).
372    pub(super) content: String,
373    /// Whether `content` is only the first
374    /// [`MAX_FILE_READ_BYTES`](super::agents::MAX_FILE_READ_BYTES) of the file.
375    pub(super) truncated: bool,
376}
377
378// ─── Doctor types ───────────────────────────────────────────────────────────
379
380/// Response of `GET /api/doctor`: the `lev doctor` checks, as data.
381#[derive(Debug, Serialize, Deserialize)]
382pub(super) struct DoctorResp {
383    pub(super) checks: Vec<DoctorCheck>,
384}
385
386/// One `lev doctor` layer's verdict, reshaped for the browser: the enum
387/// status becomes a plain `ok` bool so the client never parses labels.
388#[derive(Debug, Serialize, Deserialize)]
389pub(super) struct DoctorCheck {
390    /// The layer's short name: `config`, `resolve`, `inference`, or `daemon`.
391    pub(super) name: String,
392    /// Whether the layer works. A failure is reported here, never as an
393    /// HTTP error - the endpoint answering at all is not what is diagnosed.
394    pub(super) ok: bool,
395    pub(super) detail: String,
396    /// Wall-clock cost, present for the checks that make a network call.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub(super) elapsed_ms: Option<u64>,
399}
400
401// ─── Filesystem types ───────────────────────────────────────────────────────
402
403/// Query for `GET /api/fs/dirs`: the directory to list. Must be absolute when
404/// given; absent means the server's own working directory (clamped to
405/// `--workdir-root` when the cwd falls outside it).
406#[derive(Deserialize)]
407pub(super) struct DirsQuery {
408    pub(super) path: Option<String>,
409    /// Include dot-prefixed directories (hidden on Unix). Off by default so a
410    /// first-run picker isn't a wall of config noise.
411    #[serde(default)]
412    pub(super) hidden: bool,
413}
414
415/// Response of `GET /api/fs/dirs`: one directory level of the host filesystem,
416/// enough for the browser's folder picker to walk without shell access.
417#[derive(Debug, Serialize, Deserialize)]
418pub(super) struct DirsResp {
419    /// The absolute directory that was listed.
420    pub(super) path: String,
421    /// Where "up one level" goes. `null` at the filesystem root, and also when
422    /// `path` *is* the workdir-root - the picker is never led above the fence.
423    pub(super) parent: Option<String>,
424    /// The user's home directory, for the picker's "home" shortcut.
425    pub(super) home: String,
426    /// The serve process's working directory, for the picker's "here" shortcut.
427    pub(super) cwd: String,
428    /// The configured `--workdir-root`, or `null` when the server has none.
429    pub(super) root: Option<String>,
430    /// The immediate subdirectories, name-sorted. Dotted names are excluded,
431    /// and with a root set, so is any symlink that resolves outside it.
432    pub(super) dirs: Vec<DirEntry>,
433}
434
435/// One subdirectory in a [`DirsResp`] listing.
436#[derive(Debug, Serialize, Deserialize)]
437pub(super) struct DirEntry {
438    pub(super) name: String,
439    pub(super) path: String,
440}
441
442// ─── Tree types ─────────────────────────────────────────────────────────────
443
444#[derive(Serialize)]
445pub(super) struct AgentTreeNode {
446    pub(super) run_id: String,
447    pub(super) agent_name: String,
448    pub(super) status: String,
449    pub(super) stage: String,
450    pub(super) iteration: usize,
451    pub(super) prompt_tokens: usize,
452    pub(super) completion_tokens: usize,
453    pub(super) children: Vec<AgentTreeNode>,
454}
455
456#[derive(Debug, Serialize)]
457pub(super) struct TreeStatusNode {
458    pub(super) run_id: String,
459    pub(super) agent_name: String,
460    pub(super) status: String,
461    pub(super) stage: String,
462    pub(super) prompt_tokens: usize,
463    pub(super) completion_tokens: usize,
464    pub(super) subtree_prompt_tokens: usize,
465    pub(super) subtree_completion_tokens: usize,
466    pub(super) children: Vec<TreeStatusNode>,
467}
468
469// ─── Interaction types ──────────────────────────────────────────────────────
470
471#[derive(Deserialize)]
472pub(super) struct SubmitInteractionReq {
473    pub(super) request_id: String,
474    pub(super) value: Option<String>,
475    pub(super) choice_index: Option<usize>,
476    pub(super) approved: Option<bool>,
477    pub(super) scope: Option<String>,
478}
479
480#[derive(Deserialize)]
481pub(super) struct SendMessageReq {
482    pub(super) message: String,
483    #[serde(default)]
484    pub(super) target_region: Option<String>,
485}
486
487// ─── Config types ───────────────────────────────────────────────────────────
488
489#[derive(Serialize, Deserialize)]
490pub(super) struct RedactedConfig {
491    pub(super) default_provider: String,
492    pub(super) has_anthropic_key: bool,
493    pub(super) has_openai_key: bool,
494    pub(super) has_google_key: bool,
495    pub(super) has_openrouter_key: bool,
496    pub(super) ollama_base_url: Option<String>,
497    pub(super) agent_paths: Vec<PathBuf>,
498    pub(super) mcp_server_count: usize,
499}
500
501/// Body of `PUT /api/config` (admin-only). Every field is optional; a present
502/// field is written, an absent one is left untouched. Mirrors what `lev setup`
503/// writes, so a newcomer can configure providers entirely from the browser.
504#[derive(Debug, Default, Deserialize)]
505pub(super) struct WriteConfigReq {
506    pub(super) default_provider: Option<String>,
507    pub(super) default_model: Option<String>,
508    pub(super) anthropic_key: Option<String>,
509    pub(super) openai_key: Option<String>,
510    pub(super) google_key: Option<String>,
511    pub(super) openrouter_key: Option<String>,
512    pub(super) ollama_base_url: Option<String>,
513}
514
515/// Body of `POST /api/config/validate` — a format-only key check (no network,
516/// no persistence), mirroring the `lev setup` wizard's inline validation.
517#[derive(Debug, Deserialize)]
518pub(super) struct ValidateKeyReq {
519    pub(super) provider: String,
520    pub(super) key: String,
521}
522
523#[derive(Debug, Serialize, Deserialize)]
524pub(super) struct ValidateKeyResp {
525    pub(super) valid: bool,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub(super) message: Option<String>,
528}
529
530#[derive(Serialize)]
531pub(super) struct ModelEntry {
532    pub(super) id: String,
533    pub(super) provider: String,
534    pub(super) display_name: Option<String>,
535    pub(super) max_context_tokens: usize,
536    pub(super) max_output_tokens: usize,
537    pub(super) supports_tools: bool,
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn server_event_agent_status_serialization() {
546        let event = ServerEvent::AgentStatus {
547            agent_id: "coder".to_string(),
548            run_id: "run-123".to_string(),
549            status: "running".to_string(),
550            stage: "implement".to_string(),
551            iteration: 5,
552            tool_calls: 12,
553            accepts_messages: true,
554        };
555        let json = serde_json::to_string(&event).unwrap();
556        assert!(json.contains("\"type\":\"agent_status\""));
557        assert!(json.contains("\"agent_id\":\"coder\""));
558        assert!(json.contains("\"iteration\":5"));
559        assert!(json.contains("\"tool_calls\":12"));
560    }
561
562    #[test]
563    fn server_event_tokens_serialization() {
564        let event = ServerEvent::Tokens {
565            agent_id: "coder".to_string(),
566            run_id: "run-123".to_string(),
567            prompt_tokens: 5000,
568            completion_tokens: 1200,
569            cached_tokens: 200,
570            cache_write_tokens: 100,
571        };
572        let json = serde_json::to_string(&event).unwrap();
573        assert!(json.contains("\"type\":\"tokens\""));
574        assert!(json.contains("\"prompt_tokens\":5000"));
575        assert!(json.contains("\"cached_tokens\":200"));
576        assert!(json.contains("\"cache_write_tokens\":100"));
577    }
578
579    #[test]
580    fn server_event_agent_spawned_serialization() {
581        let event = ServerEvent::AgentSpawned {
582            agent_id: "coder".to_string(),
583            run_id: "run-456".to_string(),
584            parent_id: Some("run-123".to_string()),
585            blueprint: "coder".to_string(),
586        };
587        let json = serde_json::to_string(&event).unwrap();
588        assert!(json.contains("\"type\":\"agent_spawned\""));
589        assert!(json.contains("\"parent_id\":\"run-123\""));
590    }
591
592    #[test]
593    fn server_event_agent_completed_serialization() {
594        let event = ServerEvent::AgentCompleted {
595            agent_id: "coder".to_string(),
596            run_id: "run-123".to_string(),
597            status: "complete".to_string(),
598            result: Some("success".to_string()),
599        };
600        let json = serde_json::to_string(&event).unwrap();
601        assert!(json.contains("\"type\":\"agent_completed\""));
602    }
603
604    #[test]
605    fn server_event_context_update_serialization() {
606        let event = ServerEvent::ContextUpdate {
607            agent_id: "coder".to_string(),
608            run_id: "run-123".to_string(),
609            total_tokens: 10000,
610            max_tokens: 200000,
611        };
612        let json = serde_json::to_string(&event).unwrap();
613        assert!(json.contains("\"type\":\"context_update\""));
614        assert!(json.contains("\"total_tokens\":10000"));
615    }
616
617    #[test]
618    fn server_event_interaction_needed_serialization() {
619        let event = ServerEvent::InteractionNeeded {
620            agent_id: "coder".to_string(),
621            run_id: "run-123".to_string(),
622            request: serde_json::json!({"prompt": "approve?"}),
623        };
624        let json = serde_json::to_string(&event).unwrap();
625        assert!(json.contains("\"type\":\"interaction_needed\""));
626    }
627
628    #[test]
629    fn server_event_log_serialization() {
630        let event = ServerEvent::Log {
631            agent_id: "coder".to_string(),
632            run_id: "run-123".to_string(),
633            line: "doing work".to_string(),
634        };
635        let json = serde_json::to_string(&event).unwrap();
636        assert!(json.contains("\"type\":\"log\""));
637        assert!(json.contains("\"line\":\"doing work\""));
638    }
639
640    #[test]
641    fn validate_response_serde_roundtrip() {
642        let resp = ValidateResponse {
643            valid: true,
644            errors: None,
645            warnings: None,
646        };
647        let json = serde_json::to_string(&resp).unwrap();
648        // Neither list appears at all when there is nothing in it.
649        assert_eq!(json, r#"{"valid":true}"#);
650        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
651        assert!(parsed.valid);
652        assert!(parsed.errors.is_none());
653        assert!(parsed.warnings.is_none());
654    }
655
656    #[test]
657    fn validate_response_with_errors_roundtrip() {
658        let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
659        let json = serde_json::to_string(&resp).unwrap();
660        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
661        assert!(!parsed.valid);
662        assert_eq!(parsed.errors.unwrap().len(), 1);
663        assert!(parsed.warnings.is_none());
664    }
665
666    /// A blueprint can be valid and still have something worth saying about it.
667    #[test]
668    fn validate_response_with_warnings_roundtrip() {
669        let resp = ValidateResponse {
670            valid: true,
671            errors: None,
672            warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
673        };
674        let json = serde_json::to_string(&resp).unwrap();
675        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
676        assert!(parsed.valid);
677        assert_eq!(parsed.warnings.unwrap().len(), 1);
678    }
679
680    #[test]
681    fn redacted_config_serde_roundtrip() {
682        let config = RedactedConfig {
683            default_provider: "anthropic".to_string(),
684            has_anthropic_key: true,
685            has_openai_key: false,
686            has_google_key: false,
687            has_openrouter_key: false,
688            ollama_base_url: None,
689            agent_paths: vec![],
690            mcp_server_count: 0,
691        };
692        let json = serde_json::to_string(&config).unwrap();
693        let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
694        assert_eq!(parsed.default_provider, "anthropic");
695        assert!(parsed.has_anthropic_key);
696        assert!(!parsed.has_openai_key);
697    }
698
699    #[test]
700    fn error_response_serialization() {
701        let err = ErrorResponse {
702            error: "not found".to_string(),
703        };
704        let json = serde_json::to_string(&err).unwrap();
705        assert!(json.contains("\"error\":\"not found\""));
706    }
707
708    #[test]
709    fn file_content_resp_serde_roundtrip() {
710        let resp = FileContentResp {
711            path: "/work/report.md".to_string(),
712            size: 9,
713            content: "# Report\n".to_string(),
714            truncated: false,
715        };
716        let json = serde_json::to_string(&resp).unwrap();
717        let parsed: FileContentResp = serde_json::from_str(&json).unwrap();
718        assert_eq!(parsed.path, "/work/report.md");
719        assert_eq!(parsed.size, 9);
720        assert_eq!(parsed.content, "# Report\n");
721        assert!(!parsed.truncated);
722    }
723
724    #[test]
725    fn dirs_resp_serde_roundtrip() {
726        let resp = DirsResp {
727            path: "/work".to_string(),
728            parent: None,
729            home: "/Users/someone".to_string(),
730            cwd: "/work/project".to_string(),
731            root: Some("/work".to_string()),
732            dirs: vec![DirEntry {
733                name: "src".to_string(),
734                path: "/work/src".to_string(),
735            }],
736        };
737        let json = serde_json::to_string(&resp).unwrap();
738        // An absent parent/root is an explicit `null`, never an omitted field -
739        // the TypeScript client reads both unconditionally.
740        assert!(json.contains("\"parent\":null"));
741        assert!(json.contains(r#"{"name":"src","path":"/work/src"}"#));
742        let parsed: DirsResp = serde_json::from_str(&json).unwrap();
743        assert_eq!(parsed.path, "/work");
744        assert!(parsed.parent.is_none());
745        assert_eq!(parsed.root.as_deref(), Some("/work"));
746        assert_eq!(parsed.dirs.len(), 1);
747        assert_eq!(parsed.dirs[0].name, "src");
748    }
749
750    #[test]
751    fn doctor_resp_serde_roundtrip() {
752        let resp = DoctorResp {
753            checks: vec![
754                DoctorCheck {
755                    name: "config".to_string(),
756                    ok: true,
757                    detail: "default_provider=anthropic".to_string(),
758                    elapsed_ms: None,
759                },
760                DoctorCheck {
761                    name: "inference".to_string(),
762                    ok: false,
763                    detail: "HTTP 401: bad key".to_string(),
764                    elapsed_ms: Some(1200),
765                },
766            ],
767        };
768        let json = serde_json::to_string(&resp).unwrap();
769        // An untimed check omits the field entirely rather than sending null.
770        assert!(
771            json.contains(r#"{"name":"config","ok":true,"detail":"default_provider=anthropic"}"#)
772        );
773        assert!(json.contains("\"elapsed_ms\":1200"));
774        let parsed: DoctorResp = serde_json::from_str(&json).unwrap();
775        assert_eq!(parsed.checks.len(), 2);
776        assert!(parsed.checks[0].ok);
777        assert!(parsed.checks[0].elapsed_ms.is_none());
778        assert!(!parsed.checks[1].ok);
779        assert_eq!(parsed.checks[1].elapsed_ms, Some(1200));
780    }
781
782    #[test]
783    fn server_event_run_id_covers_every_variant() {
784        let cases: Vec<(ServerEvent, &str)> = vec![
785            (
786                ServerEvent::AgentStatus {
787                    agent_id: "a".to_string(),
788                    run_id: "r1".to_string(),
789                    status: "active".to_string(),
790                    stage: "s".to_string(),
791                    iteration: 0,
792                    tool_calls: 0,
793                    accepts_messages: false,
794                },
795                "r1",
796            ),
797            (
798                ServerEvent::ContextUpdate {
799                    agent_id: "a".to_string(),
800                    run_id: "r2".to_string(),
801                    total_tokens: 1,
802                    max_tokens: 2,
803                },
804                "r2",
805            ),
806            (
807                ServerEvent::Log {
808                    agent_id: "a".to_string(),
809                    run_id: "r3".to_string(),
810                    line: "l".to_string(),
811                },
812                "r3",
813            ),
814            (
815                ServerEvent::InteractionNeeded {
816                    agent_id: "a".to_string(),
817                    run_id: "r4".to_string(),
818                    request: serde_json::Value::Null,
819                },
820                "r4",
821            ),
822            (
823                ServerEvent::AgentSpawned {
824                    agent_id: "a".to_string(),
825                    run_id: "r5".to_string(),
826                    parent_id: None,
827                    blueprint: "b".to_string(),
828                },
829                "r5",
830            ),
831            (
832                ServerEvent::AgentCompleted {
833                    agent_id: "a".to_string(),
834                    run_id: "r6".to_string(),
835                    status: "complete".to_string(),
836                    result: None,
837                },
838                "r6",
839            ),
840            (
841                ServerEvent::Tokens {
842                    agent_id: "a".to_string(),
843                    run_id: "r7".to_string(),
844                    prompt_tokens: 0,
845                    completion_tokens: 0,
846                    cached_tokens: 0,
847                    cache_write_tokens: 0,
848                },
849                "r7",
850            ),
851            (
852                ServerEvent::World {
853                    event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
854                },
855                "r8",
856            ),
857            // A wrapped event with no run_id filters as the empty string
858            // rather than panicking (real world events always carry one).
859            (
860                ServerEvent::World {
861                    event: serde_json::Value::Null,
862                },
863                "",
864            ),
865        ];
866        for (ev, want) in cases {
867            assert_eq!(ev.run_id(), want);
868        }
869    }
870}