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/// Arguments for `lev serve`.
16#[derive(Args, Clone)]
17pub struct ServeArgs {
18    /// Port to listen on
19    #[arg(short, long, default_value = "3000")]
20    pub port: u16,
21
22    /// Host to bind to
23    #[arg(short = 'H', long, default_value = "127.0.0.1")]
24    pub host: String,
25
26    /// Allow browser requests from this origin (e.g. `http://localhost:5173`).
27    ///
28    /// Defaults to **none**: the API is for programmatic clients, which are not
29    /// subject to CORS at all, so a browser-facing default of `*` gave nothing
30    /// to the normal case and widened the surface for the unusual one. A
31    /// dashboard served from another origin sets this explicitly.
32    ///
33    /// `*` is still accepted and still means "any origin". It is now a decision
34    /// someone typed rather than what you get by not thinking about it.
35    #[arg(long)]
36    pub cors: Option<String>,
37
38    /// API token clients must present (`Authorization: Bearer <token>`, or
39    /// `?token=` for WebSockets). Overrides the LEVIATH_API_TOKEN env var; the
40    /// server refuses to start if neither is set.
41    ///
42    /// Prefer the environment variable: an argument is visible in `ps` to every
43    /// local user for the lifetime of the process.
44    #[arg(long)]
45    pub token: Option<String>,
46
47    /// Enable the MCP administration endpoints (`POST`/`DELETE
48    /// /api/mcp/servers`).
49    ///
50    /// **Off by default, because they are remote code execution by
51    /// construction.** Adding an MCP server writes a `command` and `args` into
52    /// `~/.leviath/config.toml`, and Leviath then spawns exactly that - so any
53    /// token holder could run an arbitrary process, persistently, for every
54    /// future run. The rest of the API can only run agents the user already
55    /// installed; this one adds new executables to the machine.
56    #[arg(long)]
57    pub allow_admin: bool,
58
59    /// Restrict agent working directories to this root.
60    ///
61    /// Without it, `POST /api/agents` accepts any `workdir` - including `/` -
62    /// so a token holder can point a tool-executing agent at the whole
63    /// filesystem. Set this to the directory the API is meant to work in.
64    #[arg(long)]
65    pub workdir_root: Option<PathBuf>,
66
67    /// PEM certificate chain to serve HTTPS with. Needs `--tls-key` too.
68    ///
69    /// Bring your own; Leviath never generates one. Without HTTPS the browser
70    /// console cannot reach a `lev serve` that is not on loopback - the browser
71    /// blocks the request before sending it, so no server-side header and no
72    /// `--cors` value can help. A LAN address is blocked exactly like a public
73    /// one.
74    ///
75    /// `mkcert` and `tailscale cert` both produce certificates that work here.
76    /// See the "reaching a Leviath on another machine" section of the docs.
77    #[arg(long, value_name = "PATH")]
78    pub tls_cert: Option<PathBuf>,
79
80    /// PEM private key for `--tls-cert`. Needs `--tls-cert` too.
81    #[arg(long, value_name = "PATH")]
82    pub tls_key: Option<PathBuf>,
83
84    /// Refuse `"yolo": true` and `"allow": [...]` on spawn requests, so an API
85    /// caller cannot waive approval prompts for an agent running on the host.
86    ///
87    /// Both fields, because they are one lever: `"allow": ["*"]` reaches the
88    /// same wildcard override `"yolo": true` writes.
89    #[arg(long)]
90    pub no_remote_yolo: bool,
91}
92
93// ─── Shared state ────────────────────────────────────────────────────────────
94
95/// Events broadcast to WebSocket subscribers.
96#[derive(Debug, Clone, Serialize)]
97#[serde(tag = "type", rename_all = "snake_case")]
98pub enum ServerEvent {
99    /// Where a run stands, re-sent whenever any of it changes.
100    AgentStatus {
101        /// The agent's live id in the world.
102        agent_id: String,
103        /// The durable run id, and what a per-run subscription filters on.
104        run_id: String,
105        /// The run's status, as `RunStatus` renders it.
106        status: String,
107        /// The stage it is in, by name.
108        stage: String,
109        /// Inference turns taken in that stage, reset on entering a new one.
110        iteration: usize,
111        /// Tool calls made across the whole run.
112        #[serde(default)]
113        tool_calls: usize,
114        /// Whether a client may send this run a mid-run message. False for a
115        /// stage that declared `accepts_messages = false`, and for a run that
116        /// has finished.
117        accepts_messages: bool,
118    },
119    /// How full the run's context window is, after a turn changed it.
120    ContextUpdate {
121        /// The agent's live id in the world.
122        agent_id: String,
123        /// The durable run id.
124        run_id: String,
125        /// Tokens held across every region.
126        total_tokens: usize,
127        /// The whole window's budget.
128        max_tokens: usize,
129    },
130    /// One log line, as also written to the stage's log files.
131    Log {
132        /// The agent's live id in the world.
133        agent_id: String,
134        /// The durable run id.
135        run_id: String,
136        /// The line, without a trailing newline. Long lines are truncated for
137        /// the broadcast; the on-disk stage log keeps the whole thing.
138        line: String,
139    },
140    /// The run is blocked on a person, and this is what it is asking.
141    InteractionNeeded {
142        /// The agent's live id in the world.
143        agent_id: String,
144        /// The durable run id.
145        run_id: String,
146        /// The prompt, forwarded as the runtime serialized it, so a new kind of
147        /// request needs no server release.
148        request: serde_json::Value,
149    },
150    /// A run started, including one spawned as a child of another.
151    AgentSpawned {
152        /// The agent's live id in the world.
153        agent_id: String,
154        /// The durable run id.
155        run_id: String,
156        /// The parent's agent id when this is a sub-agent, `None` at the root.
157        parent_id: Option<String>,
158        /// The blueprint it was spawned from.
159        blueprint: String,
160    },
161    /// A run reached a terminal status.
162    AgentCompleted {
163        /// The agent's live id in the world.
164        agent_id: String,
165        /// The durable run id.
166        run_id: String,
167        /// The terminal status, as `RunStatus` renders it.
168        status: String,
169        /// The run's *error*, if it failed. Named `result` since before a run
170        /// could produce one; kept for the consumers that read it.
171        result: Option<String>,
172        /// What the run handed back. This is the answer.
173        #[serde(skip_serializing_if = "Option::is_none")]
174        final_output: Option<FinalOutputResp>,
175    },
176    /// Running token totals for the whole run, after an inference landed.
177    Tokens {
178        /// The agent's live id in the world.
179        agent_id: String,
180        /// The durable run id.
181        run_id: String,
182        /// Input tokens billed so far.
183        prompt_tokens: usize,
184        /// Output tokens billed so far.
185        completion_tokens: usize,
186        /// Input tokens served from the provider's prompt cache, counted within
187        /// `prompt_tokens` rather than on top of it.
188        #[serde(default)]
189        cached_tokens: usize,
190        /// Tokens written into the provider's prompt cache.
191        #[serde(default)]
192        cache_write_tokens: usize,
193    },
194    /// A world event with no dedicated WebSocket translation (stage
195    /// transitions, tool call start/finish, and whatever the runtime adds
196    /// next), forwarded verbatim. `event` is the runtime's own serde-tagged
197    /// [`WorldEvent`](leviath_runtime::host::WorldEvent) JSON, so clients get
198    /// new event kinds without a server release.
199    World {
200        /// The runtime's own serde-tagged event JSON, forwarded as-is.
201        event: serde_json::Value,
202    },
203}
204
205impl ServerEvent {
206    /// The run id this event belongs to, for per-run subscription filtering.
207    /// `World` events read it from the wrapped JSON (every runtime event
208    /// carries one; an absent field filters as the empty string).
209    pub fn run_id(&self) -> &str {
210        match self {
211            ServerEvent::AgentStatus { run_id, .. }
212            | ServerEvent::ContextUpdate { run_id, .. }
213            | ServerEvent::Log { run_id, .. }
214            | ServerEvent::InteractionNeeded { run_id, .. }
215            | ServerEvent::AgentSpawned { run_id, .. }
216            | ServerEvent::AgentCompleted { run_id, .. }
217            | ServerEvent::Tokens { run_id, .. } => run_id,
218            ServerEvent::World { event } => event
219                .get("run_id")
220                .and_then(|v| v.as_str())
221                .unwrap_or_default(),
222        }
223    }
224}
225
226/// What every request handler is given: the config, the event fan-out, and the
227/// control-socket client that reaches the daemon.
228#[derive(Clone)]
229pub struct AppState {
230    pub(super) config: Arc<Config>,
231    pub(super) event_tx: broadcast::Sender<ServerEvent>,
232    /// Client for the shared-world daemon's control socket. Agent actions
233    /// (spawn/cancel/message/interactions) go through this; read endpoints still
234    /// observe the runs dir the daemon persists to.
235    pub(super) control: leviath_runtime::control_socket::ControlClient,
236    /// Paths + seams for the MCP management endpoints.
237    pub(super) mcp: super::mcp::McpAdmin,
238    /// The spawn-request restrictions from [`ServeArgs`], resolved once at
239    /// startup so every handler reads the same decision.
240    pub(super) limits: Arc<ServeLimits>,
241}
242
243/// What this server refuses regardless of who is asking.
244///
245/// A valid API token proves the caller is allowed to *use* the server; it does
246/// not mean they should be able to reconfigure the machine or point an agent at
247/// the filesystem root. These are the operator's answers to that, fixed at
248/// startup rather than negotiable per request.
249///
250/// `--allow-admin` is deliberately absent: it decides whether a route is
251/// *mounted at all*, so it is consumed once at router construction rather than
252/// carried here for a handler to consult. An unmounted route 404s; a mounted one
253/// guarded by a field is one refactor away from being reachable.
254#[derive(Debug, Clone, Default)]
255pub(super) struct ServeLimits {
256    /// `--workdir-root`: the directory agent workdirs must sit under.
257    pub(super) workdir_root: Option<PathBuf>,
258    /// `--no-remote-yolo`: whether a spawn request may waive approvals, with
259    /// either `"yolo": true` or an `"allow"` list.
260    pub(super) no_remote_yolo: bool,
261    /// `[security] allow_local_network`: whether a completion webhook may point
262    /// at loopback, private or link-local addresses.
263    pub(super) allow_local_network: bool,
264}
265
266impl ServeLimits {
267    /// Check a requested agent workdir against `--workdir-root`.
268    ///
269    /// Uses the same symlink-aware containment the file tools use, so a symlink
270    /// under the root cannot be used to point an agent outside it.
271    /// Check a requested completion webhook against the same SSRF policy every
272    /// model-supplied URL goes through.
273    ///
274    /// The URL arrives in a `POST /api/agents` body, is persisted, and is POSTed
275    /// to when the run finishes - from inside the trust boundary, and with
276    /// retries. Unchecked, `"callback_url": "http://169.254.169.254/…"` made the
277    /// daemon a repeatable request primitive against the cloud metadata service
278    /// and anything else on the local network, on behalf of a caller that
279    /// `--workdir-root` and `--no-remote-yolo` exist to keep at arm's length.
280    ///
281    /// `allow_local_network` mirrors the config setting: an operator who
282    /// deliberately points webhooks at a service on the same host can, and
283    /// everyone else cannot.
284    pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
285        let parsed = url
286            .parse::<url::Url>()
287            .map_err(|e| format!("callback_url is not a URL: {e}"))?;
288        leviath_net::check_url(&parsed, self.allow_local_network)
289            .map_err(|e| format!("callback_url is not allowed: {e}"))
290    }
291
292    /// Check the approval waivers a spawn request asked for against
293    /// `--no-remote-yolo`.
294    ///
295    /// `yolo` and `allow` are the same lever. `{"allow": ["*"]}` is read by
296    /// `resolve_policy` through the same wildcard entry `--yolo` writes, so
297    /// guarding only the `yolo` field left the operator's refusal bypassable by
298    /// spelling it the other way. Any `allow` is refused rather than just the
299    /// wildcard: `{"allow": ["shell"]}` is not meaningfully weaker on a server
300    /// somebody deliberately hardened, and "allow is yolo" is a rule an
301    /// operator can hold in their head. A per-agent grant belongs in the
302    /// operator's own config, under `[agent_tool_permissions.<agent>]`.
303    pub(super) fn check_launch_overrides(
304        &self,
305        yolo: bool,
306        allow: &[String],
307    ) -> Result<(), String> {
308        if !self.no_remote_yolo {
309            return Ok(());
310        }
311        match (yolo, allow.is_empty()) {
312            (false, true) => Ok(()),
313            _ => Err(
314                "this server refuses `yolo` and `allow` on spawn requests (--no-remote-yolo)"
315                    .to_string(),
316            ),
317        }
318    }
319
320    pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
321        let Some(root) = &self.workdir_root else {
322            return Ok(());
323        };
324        match leviath_core::resolves_within(workdir, root) {
325            true => Ok(()),
326            false => Err(format!(
327                "workdir '{}' is outside the configured --workdir-root '{}'",
328                workdir.display(),
329                root.display()
330            )),
331        }
332    }
333}
334
335// ─── Error response ─────────────────────────────────────────────────────────
336
337#[derive(Debug, Serialize)]
338pub(super) struct ErrorResponse {
339    pub(super) error: String,
340}
341
342/// Build a `(status, JSON error)` response tuple.
343pub(super) fn err(
344    code: axum::http::StatusCode,
345    message: String,
346) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
347    (code, axum::response::Json(ErrorResponse { error: message }))
348}
349
350// ─── Pagination ─────────────────────────────────────────────────────────────
351
352/// One page of a collection: the shape every paginated route returns.
353///
354/// One envelope rather than one per route, so a client writes the paging loop
355/// once. The house rule for which routes get it: **collections that grow
356/// without bound are paginated; bounded catalogs stay bare arrays.** Runs
357/// accumulate forever and are never pruned, so they are paged; `/api/models`
358/// and `/api/mcp/servers` are sized by what the user configured, and
359/// `/api/agents/tree` is a tree, where `next_cursor` would not mean anything.
360#[derive(Debug, Serialize)]
361pub(super) struct Page<T> {
362    /// This page's items, in the requested order.
363    pub(super) items: Vec<T>,
364    /// Pass back as `cursor` for the next page. `null` means this was the last
365    /// one - clients loop until null rather than counting against `total`,
366    /// because `total` can move underneath a walk.
367    ///
368    /// Only ever emitted when a further item is known to exist: the handler
369    /// takes `limit + 1` and keeps `limit`.
370    pub(super) next_cursor: Option<String>,
371    /// How many items matched this query, at the moment of this request.
372    ///
373    /// `null` when the answer would be a guess - see `scan_truncated`. A count
374    /// derived from a partial scan is worse than no count, because a UI renders
375    /// it as fact and a user paginates against it.
376    pub(super) total: Option<usize>,
377    /// Unix **seconds** at which this page was built, on the server's clock.
378    ///
379    /// The watermark for a client's next `since=`: round-tripping the server's
380    /// own timestamp keeps a polling client from missing or re-fetching work
381    /// because its clock disagrees.
382    pub(super) server_time: i64,
383    /// Set when a filesystem-scanning filter gave up before examining every
384    /// candidate, so this page is a prefix of the truth rather than all of it.
385    #[serde(skip_serializing_if = "std::ops::Not::not")]
386    pub(super) scan_truncated: bool,
387    /// Ids from an `ids=` batch fetch that no longer exist. Absent otherwise.
388    ///
389    /// A missing id is reported rather than 404ing the whole request: a batch
390    /// refresh of ten runs should not fail because one was deleted.
391    #[serde(skip_serializing_if = "Vec::is_empty")]
392    pub(super) missing: Vec<String>,
393}
394
395impl<T> Page<T> {
396    /// A page with nothing unusual about it: no truncation, no missing ids.
397    pub(super) fn new(
398        items: Vec<T>,
399        next_cursor: Option<String>,
400        total: Option<usize>,
401        server_time: i64,
402    ) -> Self {
403        Self {
404            items,
405            next_cursor,
406            total,
407            server_time,
408            scan_truncated: false,
409            missing: Vec::new(),
410        }
411    }
412}
413
414/// One run in a `GET /api/runs` page.
415#[derive(Debug, Serialize)]
416pub(super) struct RunItem {
417    /// The run's metadata, redacted, and narrowed to the requested `fields`.
418    ///
419    /// A `serde_json::Value` rather than a second `RunSummary` struct: field
420    /// projection is a runtime choice, and serializing through `RunMeta`'s own
421    /// serde means there is no parallel shape that can drift away from it.
422    pub(super) meta: serde_json::Value,
423    /// Why this run matched, when the request carried a `q`. Empty otherwise.
424    #[serde(skip_serializing_if = "Vec::is_empty")]
425    pub(super) highlights: Vec<Highlight>,
426}
427
428/// Where a search matched, and enough text to show a user why.
429///
430/// The part of search that cannot be done in the browser: the console never has
431/// a run's transcript, so without this a deep match is an unexplained result.
432#[derive(Debug, Serialize)]
433pub(super) struct Highlight {
434    /// What matched: a `RunMeta` field name, `metadata.<key>`,
435    /// `modified_files`, `context.<region>`, `logs.output`, `logs.operational`,
436    /// or `journal.tool.<tool_name>`.
437    pub(super) field: String,
438    /// The matching text with a little either side, elided at any cut end.
439    pub(super) snippet: String,
440    /// Which stage the match came from, for the sources that have one. A client
441    /// can pass it straight to `GET /api/agents/{id}/logs?stage=`.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub(super) stage: Option<usize>,
444}
445
446// ─── Blueprint types ────────────────────────────────────────────────────────
447
448#[derive(Debug, Serialize)]
449pub(super) struct BlueprintInfo {
450    pub(super) name: String,
451    pub(super) version: String,
452    pub(super) description: String,
453    pub(super) path: String,
454    pub(super) stages: Vec<String>,
455}
456
457/// Query for `GET /api/blueprints`.
458#[derive(Deserialize, Default)]
459pub(super) struct BlueprintsQuery {
460    pub(super) limit: Option<usize>,
461    pub(super) cursor: Option<String>,
462    /// Case-insensitive substring over name, description and stage names.
463    pub(super) q: Option<String>,
464    /// `name` (default) or `version`.
465    pub(super) sort: Option<String>,
466    /// `asc` (default) or `desc`. Ascending by name is the catalog order a
467    /// person reads.
468    pub(super) order: Option<String>,
469}
470
471#[derive(Deserialize)]
472pub(super) struct CreateBlueprintReq {
473    pub(super) name: String,
474    pub(super) manifest: String,
475}
476
477#[derive(Deserialize)]
478pub(super) struct UpdateBlueprintReq {
479    pub(super) manifest: String,
480}
481
482#[derive(Deserialize)]
483pub(super) struct ValidateBlueprintReq {
484    pub(super) manifest: String,
485}
486
487#[derive(Serialize, Deserialize)]
488pub(super) struct ValidateResponse {
489    pub(super) valid: bool,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub(super) errors: Option<Vec<String>>,
492    /// Lint findings that do not make the blueprint invalid: fields left to a
493    /// default, a stage that can block on a human, a broad `[read_paths]`
494    /// entry. Absent when there are none.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub(super) warnings: Option<Vec<String>>,
497}
498
499impl ValidateResponse {
500    /// The response for a manifest that did not parse or did not validate.
501    pub(super) fn invalid(errors: Vec<String>) -> Self {
502        Self {
503            valid: false,
504            errors: Some(errors),
505            warnings: None,
506        }
507    }
508}
509
510// ─── Agent types ────────────────────────────────────────────────────────────
511
512#[derive(Default, Deserialize)]
513pub(super) struct SpawnAgentReq {
514    pub(super) blueprint: String,
515    pub(super) task: String,
516    pub(super) model: Option<String>,
517    /// Override the blueprint's max sub-agent tree depth.
518    pub(super) max_depth: Option<usize>,
519    /// Approve every tool call for this run.
520    #[serde(default)]
521    pub(super) yolo: bool,
522    /// Tools to allow outright for this run.
523    #[serde(default)]
524    pub(super) allow: Vec<String>,
525    /// Refuse this run's `seed = { command = ... }` regions, which would
526    /// otherwise execute at spawn before any approval prompt.
527    #[serde(default)]
528    pub(super) no_seed_commands: bool,
529    pub(super) workdir: Option<String>,
530    /// Literal seed content for named caller-input regions, keyed by region name.
531    #[serde(default)]
532    pub(super) regions: HashMap<String, String>,
533    #[serde(default)]
534    pub(super) metadata: HashMap<String, String>,
535    pub(super) callback_url: Option<String>,
536    /// Optional shared secret; when set, completion webhooks carry an
537    /// `X-Leviath-Signature: sha256=<hex>` HMAC of the body keyed on this secret.
538    pub(super) callback_secret: Option<String>,
539    /// Ask for the run's final output in a particular shape, overriding what the
540    /// blueprint declares. Any label works - `markdown`, `xml`, `a2ui`, a media
541    /// type, your own - because nothing converts between shapes: the label and
542    /// instructions are handed to the model, which produces the bytes.
543    pub(super) output_format: Option<String>,
544    /// Extra guidance about that shape. This is how an unusual format gets
545    /// explained to the model.
546    pub(super) output_instructions: Option<String>,
547    /// A JSON Schema the final output must satisfy. The only thing that ever
548    /// inspects the answer's contents, and only because you asked: a submission
549    /// that fails is refused back to the agent to correct.
550    ///
551    /// Naming `output_format` without this drops a schema the blueprint
552    /// declared, since a check written for one shape says nothing about another.
553    pub(super) output_schema: Option<serde_json::Value>,
554}
555
556/// A run's final output as the API serves it.
557///
558/// `content` is exactly what the agent submitted - nothing re-serializes or
559/// reformats it - and `format` is the label it was asked for. A UI that renders
560/// a2ui differently from markdown matches on that string; the server never does.
561#[derive(Serialize, Debug, Clone)]
562pub struct FinalOutputResp {
563    pub content: String,
564    pub format: Option<String>,
565    /// The stage that produced it.
566    pub stage: String,
567    /// Unix seconds at submission.
568    pub submitted_at: i64,
569    /// Whether the answer hit the size cap and was cut short.
570    pub truncated: bool,
571    /// Files the run produced, as workdir-relative paths. Fetch one with
572    /// `GET /api/agents/{id}/files?path=`.
573    #[serde(default, skip_serializing_if = "Vec::is_empty")]
574    pub artifacts: Vec<String>,
575}
576
577impl From<leviath_core::output::FinalOutput> for FinalOutputResp {
578    fn from(o: leviath_core::output::FinalOutput) -> Self {
579        Self {
580            content: o.content,
581            format: o.format,
582            stage: o.stage,
583            submitted_at: o.submitted_at,
584            truncated: o.truncated,
585            artifacts: o.artifacts,
586        }
587    }
588}
589
590#[derive(Serialize, Debug)]
591pub(super) struct SpawnAgentResp {
592    pub(super) agent_id: String,
593    pub(super) run_id: String,
594}
595
596#[derive(Deserialize)]
597pub(super) struct ListAgentsQuery {
598    pub(super) status: Option<String>,
599}
600
601/// Does `filter` name this run's status?
602///
603/// `RunStatus` reaches a client two different ways: `Json<RunMeta>` serializes
604/// it through serde, which is `snake_case` (`waiting_input`), while the status
605/// filter compared it through `Display`, which is PascalCase, lowercased
606/// (`waitinginput`). So a client that took a status out of one response and fed
607/// it back as a filter got nothing, on exactly the two multi-word variants where
608/// it is least obvious why.
609///
610/// Normalizing both sides - lowercase, and drop `_` and `-` - accepts every
611/// spelling of the same status, including the two that already worked. That
612/// makes this strictly wider than the old comparison, so nothing a client does
613/// today can start failing.
614pub(super) fn status_matches(status: &crate::runstate::RunStatus, filter: &str) -> bool {
615    fn normalize(s: &str) -> String {
616        s.chars()
617            .filter(|c| *c != '_' && *c != '-')
618            .flat_map(char::to_lowercase)
619            .collect()
620    }
621    normalize(&format!("{status}")) == normalize(filter)
622}
623
624#[derive(Serialize)]
625pub(super) struct AgentResultResp {
626    pub(super) run_id: String,
627    pub(super) status: String,
628    /// The tail of the last stage's log. Kept as-is: it predates
629    /// `final_output`, callers depend on it, and it answers a different
630    /// question - what the run *did*, rather than what it concluded.
631    pub(super) output: String,
632    /// What the agent handed back, when it submitted anything. This is the
633    /// run's answer; prefer it over `output` when present.
634    pub(super) final_output: Option<FinalOutputResp>,
635    pub(super) error: Option<String>,
636    pub(super) prompt_tokens: usize,
637    pub(super) completion_tokens: usize,
638}
639
640/// Query for `GET /api/agents/{id}/logs`.
641#[derive(Deserialize, Default)]
642pub(super) struct LogsQuery {
643    /// How many **bytes** from the end to return (default 32 KiB). Bytes, not
644    /// lines - the OpenAPI description said lines and was wrong.
645    pub(super) tail: Option<u64>,
646    /// Which stage: a numeric index, or `all` for every stage joined oldest
647    /// first. Absent means the stage the run is on now.
648    pub(super) stage: Option<String>,
649    /// `output` (default) for the assistant's readable output, or `logs` for
650    /// the operational stream (`[tool]`, `[Tokens: …]`, `[error]`).
651    ///
652    /// Kept as two separate streams rather than interleaved: they have
653    /// different audiences and no shared clock in the files, so merging them
654    /// would be presenting a guess at ordering as a fact.
655    pub(super) stream: Option<String>,
656}
657
658impl LogsQuery {
659    /// Resolve `stage` into a [`StageSelector`], or report the bad value.
660    pub(super) fn selector(&self) -> Result<crate::runstate::StageSelector, String> {
661        use crate::runstate::StageSelector;
662        match self.stage.as_deref() {
663            None => Ok(StageSelector::Current),
664            Some("all") => Ok(StageSelector::All),
665            Some(other) => other
666                .parse::<usize>()
667                .map(StageSelector::Index)
668                .map_err(|_| format!("Invalid stage '{other}': expected a stage index or 'all'")),
669        }
670    }
671
672    /// Resolve `stream`, or report the bad value.
673    pub(super) fn log_stream(&self) -> Result<crate::runstate::LogStream, String> {
674        use crate::runstate::LogStream;
675        match self.stream.as_deref() {
676            None | Some("output") => Ok(LogStream::Output),
677            Some("logs") => Ok(LogStream::Operational),
678            Some(other) => Err(format!(
679                "Invalid stream '{other}': expected 'output' or 'logs'"
680            )),
681        }
682    }
683}
684
685/// Query for `GET /api/agents/{id}/context/history`.
686#[derive(Deserialize, Default)]
687pub(super) struct HistoryQuery {
688    /// How many points to return. Capped lower than the run listing's, because
689    /// each item carries a whole context window.
690    pub(super) limit: Option<usize>,
691    /// Continuation token from the previous page's `next_cursor`.
692    pub(super) cursor: Option<String>,
693    /// `asc` (default, chronological - and what the unpaged response gave) or
694    /// `desc` to start from the most recent point.
695    pub(super) order: Option<String>,
696}
697
698/// Query for `GET /api/agents/{id}/files`.
699///
700/// With `path`, reads that file. Without, lists - the same idiom `GET
701/// /api/fs/dirs` uses for the folder picker.
702#[derive(Deserialize, Default)]
703pub(super) struct FileQuery {
704    /// The file to read. Relative paths resolve against the run's workdir;
705    /// absolute paths are accepted but must still land inside it. Absent means
706    /// "list", and a directory lists rather than erroring.
707    pub(super) path: Option<String>,
708    /// `modified` (default) or `workdir`. See [`FileSource`].
709    pub(super) source: Option<String>,
710    /// Include dot-prefixed entries when listing a directory. Off by default,
711    /// mirroring `DirsQuery`.
712    #[serde(default)]
713    pub(super) hidden: bool,
714    /// Byte offset to start reading at. Absent means the beginning.
715    ///
716    /// A run's artifact can be far larger than one response, so a caller pages
717    /// through it: read a window, then ask again from the `next_offset` the
718    /// response carries.
719    pub(super) offset: Option<u64>,
720}
721
722/// Whether a count is zero, for `skip_serializing_if`.
723fn is_zero(n: &u64) -> bool {
724    *n == 0
725}
726
727/// Which question a listing answers.
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub(super) enum FileSource {
730    /// What the run recorded modifying. Free, but a claim about the run rather
731    /// than about the disk, and capped at record time.
732    Modified,
733    /// What is in the run's working directory now, one level at a time.
734    Workdir,
735}
736
737impl FileQuery {
738    /// Resolve `source`, or report the bad value.
739    pub(super) fn file_source(&self) -> Result<FileSource, String> {
740        match self.source.as_deref() {
741            None | Some("modified") => Ok(FileSource::Modified),
742            Some("workdir") => Ok(FileSource::Workdir),
743            Some(other) => Err(format!(
744                "Invalid source '{other}': expected 'modified' or 'workdir'"
745            )),
746        }
747    }
748}
749
750/// Response of `GET /api/agents/{id}/files`: either one file's contents, or a
751/// listing.
752///
753/// Untagged, with the listing carrying a literal `kind` field, so a client
754/// discriminates on a value rather than by trying parses until one fits.
755/// `FileContentResp` is unchanged and still serializes exactly as before, so
756/// existing readers of `?path=<file>` see no difference at all.
757#[derive(Debug, Serialize)]
758#[serde(untagged)]
759pub(super) enum FileOrListing {
760    File(FileContentResp),
761    /// Boxed because it is much the larger variant, and every file read would
762    /// otherwise pay for its size.
763    Listing(Box<RunFileListing>),
764}
765
766/// Response of `GET /api/agents/{id}/files` with no file named.
767#[derive(Debug, Serialize)]
768pub(super) struct RunFileListing {
769    /// Always `"listing"`. What a client checks to tell the two shapes apart.
770    pub(super) kind: &'static str,
771    /// Which question this answers: `modified` or `workdir`.
772    pub(super) source: &'static str,
773    /// The directory listed, or the workdir for a `modified` listing.
774    pub(super) path: String,
775    /// Where "up one level" goes, or `null` at the workdir root.
776    pub(super) parent: Option<String>,
777    /// The run's working directory, which paths are relative to.
778    pub(super) workdir: String,
779    pub(super) entries: Vec<RunFileEntry>,
780    /// Whether `entries` stops short of the directory's real contents.
781    pub(super) truncated: bool,
782    /// Whether the run hit the tracked-modified-files cap, so its recorded list
783    /// is a prefix and the remaining names were never stored anywhere.
784    ///
785    /// Exposed because the alternative - a client subtracting
786    /// `modifying_tool_calls` from `entries.len()` - is wrong, and was the
787    /// original "+N more" bug. Use `source=workdir` for ground truth about what
788    /// is actually on disk.
789    pub(super) modified_files_truncated: bool,
790    /// Successful **modifying tool calls**, which is not a file count: a run
791    /// that edits one file three times records three. Named for what it counts.
792    pub(super) modifying_tool_calls: usize,
793}
794
795/// One entry in a [`RunFileListing`].
796#[derive(Debug, Serialize)]
797pub(super) struct RunFileEntry {
798    pub(super) name: String,
799    /// Relative to the run's workdir where possible, so a client can pass it
800    /// straight back as `?path=`.
801    pub(super) path: String,
802    pub(super) is_dir: bool,
803    /// `null` when the entry could not be stat-ed.
804    pub(super) size: Option<u64>,
805    /// False for a recorded path that has since been deleted.
806    pub(super) exists: bool,
807    /// True for a recorded path that resolves outside the workdir - possible
808    /// when a tool was handed an absolute path. Reported rather than hidden.
809    pub(super) outside_workdir: bool,
810}
811
812/// Response of `GET /api/agents/{id}/files`: one file the run wrote, as text.
813#[derive(Debug, Serialize, Deserialize)]
814pub(super) struct FileContentResp {
815    /// The resolved absolute path that was read.
816    pub(super) path: String,
817    /// The file's full size in bytes - larger than `content` when `truncated`.
818    pub(super) size: u64,
819    /// Where this window starts, in bytes. Not always the `offset` that was
820    /// asked for: an offset landing mid-character is moved forward to the next
821    /// character boundary, so the pages of a file line up.
822    ///
823    /// Omitted when it is zero, which keeps a whole-file read serializing
824    /// exactly as it did before paging existed. Adding a key to that response
825    /// would be harmless for most clients and is still not worth doing to all
826    /// of them for a field only a paging caller reads.
827    #[serde(default, skip_serializing_if = "is_zero")]
828    pub(super) offset: u64,
829    /// Where to start the next request to continue reading. `null` when this
830    /// window reached the end of the file.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub(super) next_offset: Option<u64>,
833    /// This window's bytes as UTF-8, capped at
834    /// [`MAX_FILE_READ_BYTES`](super::agents::MAX_FILE_READ_BYTES).
835    pub(super) content: String,
836    /// Whether the file continues past this window. Read on from `next_offset`.
837    pub(super) truncated: bool,
838}
839
840// ─── Doctor types ───────────────────────────────────────────────────────────
841
842/// Response of `GET /api/doctor`: the `lev doctor` checks, as data.
843#[derive(Debug, Serialize, Deserialize)]
844pub(super) struct DoctorResp {
845    pub(super) checks: Vec<DoctorCheck>,
846}
847
848/// One `lev doctor` layer's verdict, reshaped for the browser: the enum
849/// status becomes a plain `ok` bool so the client never parses labels.
850#[derive(Debug, Serialize, Deserialize)]
851pub(super) struct DoctorCheck {
852    /// The layer's short name: `config`, `resolve`, `inference`, or `daemon`.
853    pub(super) name: String,
854    /// Whether the layer works. A failure is reported here, never as an
855    /// HTTP error - the endpoint answering at all is not what is diagnosed.
856    pub(super) ok: bool,
857    pub(super) detail: String,
858    /// Wall-clock cost, present for the checks that make a network call.
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub(super) elapsed_ms: Option<u64>,
861}
862
863// ─── Filesystem types ───────────────────────────────────────────────────────
864
865/// Query for `GET /api/fs/dirs`: the directory to list. Must be absolute when
866/// given; absent means the server's own working directory (clamped to
867/// `--workdir-root` when the cwd falls outside it).
868#[derive(Deserialize)]
869pub(super) struct DirsQuery {
870    pub(super) path: Option<String>,
871    /// Include dot-prefixed directories (hidden on Unix). Off by default so a
872    /// first-run picker isn't a wall of config noise.
873    #[serde(default)]
874    pub(super) hidden: bool,
875}
876
877/// Response of `GET /api/fs/dirs`: one directory level of the host filesystem,
878/// enough for the browser's folder picker to walk without shell access.
879#[derive(Debug, Serialize, Deserialize)]
880pub(super) struct DirsResp {
881    /// The absolute directory that was listed.
882    pub(super) path: String,
883    /// Where "up one level" goes. `null` at the filesystem root, and also when
884    /// `path` *is* the workdir-root - the picker is never led above the fence.
885    pub(super) parent: Option<String>,
886    /// The user's home directory, for the picker's "home" shortcut.
887    pub(super) home: String,
888    /// The serve process's working directory, for the picker's "here" shortcut.
889    pub(super) cwd: String,
890    /// The configured `--workdir-root`, or `null` when the server has none.
891    pub(super) root: Option<String>,
892    /// The immediate subdirectories, name-sorted. Dotted names are excluded,
893    /// and with a root set, so is any symlink that resolves outside it.
894    pub(super) dirs: Vec<DirEntry>,
895}
896
897/// One subdirectory in a [`DirsResp`] listing.
898#[derive(Debug, Serialize, Deserialize)]
899pub(super) struct DirEntry {
900    pub(super) name: String,
901    pub(super) path: String,
902}
903
904// ─── Tree types ─────────────────────────────────────────────────────────────
905
906#[derive(Serialize)]
907pub(super) struct AgentTreeNode {
908    pub(super) run_id: String,
909    pub(super) agent_name: String,
910    pub(super) status: String,
911    pub(super) stage: String,
912    pub(super) iteration: usize,
913    pub(super) prompt_tokens: usize,
914    pub(super) completion_tokens: usize,
915    pub(super) children: Vec<AgentTreeNode>,
916}
917
918#[derive(Debug, Serialize)]
919pub(super) struct TreeStatusNode {
920    pub(super) run_id: String,
921    pub(super) agent_name: String,
922    pub(super) status: String,
923    pub(super) stage: String,
924    pub(super) prompt_tokens: usize,
925    pub(super) completion_tokens: usize,
926    pub(super) subtree_prompt_tokens: usize,
927    pub(super) subtree_completion_tokens: usize,
928    pub(super) children: Vec<TreeStatusNode>,
929}
930
931// ─── Interaction types ──────────────────────────────────────────────────────
932
933#[derive(Deserialize)]
934pub(super) struct SubmitInteractionReq {
935    pub(super) request_id: String,
936    pub(super) value: Option<String>,
937    pub(super) choice_index: Option<usize>,
938    pub(super) approved: Option<bool>,
939    pub(super) scope: Option<String>,
940}
941
942#[derive(Deserialize)]
943pub(super) struct SendMessageReq {
944    pub(super) message: String,
945    #[serde(default)]
946    pub(super) target_region: Option<String>,
947}
948
949// ─── Config types ───────────────────────────────────────────────────────────
950
951#[derive(Serialize, Deserialize)]
952pub(super) struct RedactedConfig {
953    pub(super) default_provider: String,
954    pub(super) has_anthropic_key: bool,
955    pub(super) has_openai_key: bool,
956    pub(super) has_google_key: bool,
957    pub(super) has_openrouter_key: bool,
958    pub(super) ollama_base_url: Option<String>,
959    pub(super) agent_paths: Vec<PathBuf>,
960    pub(super) mcp_server_count: usize,
961    /// The API contract this server implements, matching `info.version` in
962    /// `docs/schema/openapi.json`. A test holds the two together.
963    pub(super) api_version: String,
964    /// What this server can do, so a client can light up features in one call.
965    ///
966    /// Before this, the console feature-detected by calling a route and reading
967    /// a 404 as "unsupported" - fragile, because a 404 also means "no such run",
968    /// and one round trip per feature.
969    pub(super) capabilities: Vec<String>,
970    pub(super) limits: ApiLimits,
971}
972
973/// The API contract version. Held equal to the OpenAPI spec's `info.version` by
974/// a test, because a version that can silently disagree with the document it
975/// names is worse than no version at all.
976pub(super) const API_VERSION: &str = "0.3.0";
977
978/// Every capability a client may check for.
979pub(super) const API_CAPABILITIES: &[&str] = &[
980    "runs.envelope",
981    "runs.cursor",
982    "runs.search",
983    "runs.search.context",
984    "runs.search.logs",
985    "runs.search.journal",
986    "runs.fields",
987    "runs.ids",
988    "runs.since",
989    "runs.files.listing",
990    "runs.files.workdir",
991    "logs.stage",
992    "logs.stream",
993    "context.history.page",
994    "blueprints.envelope",
995    "blueprints.query",
996];
997
998/// The server's numeric limits.
999///
1000/// This is what makes capability discovery useful rather than decorative: a
1001/// client that knows the feature exists still has to guess the page cap, the
1002/// file-size cap and the tracked-file cap, and every one of those guesses would
1003/// be hardcoded and eventually wrong.
1004#[derive(Debug, Serialize, Deserialize)]
1005pub(super) struct ApiLimits {
1006    /// Largest `limit` on `GET /api/runs`; larger values are clamped.
1007    pub(super) max_limit: usize,
1008    /// Most ids one `ids=` batch may name.
1009    pub(super) max_ids: usize,
1010    /// Largest file body `?path=` returns.
1011    pub(super) max_file_bytes: u64,
1012    /// Most entries one directory listing returns.
1013    pub(super) max_listing_entries: usize,
1014    /// How many runs a filesystem-reading search examines before reporting
1015    /// `scan_truncated`.
1016    pub(super) max_search_scan: usize,
1017    /// How much of each stage log a search reads, from the end.
1018    pub(super) search_log_tail_bytes: u64,
1019    /// Largest `limit` on the context-history route.
1020    pub(super) max_history_limit: usize,
1021    /// How many distinct modified paths a run records before
1022    /// `modified_files_truncated` is set.
1023    pub(super) max_tracked_modified_files: usize,
1024}
1025
1026impl ApiLimits {
1027    /// Read from the constants the handlers actually use, so the two cannot
1028    /// drift into disagreeing.
1029    pub(super) fn current() -> Self {
1030        Self {
1031            max_limit: super::runs::MAX_LIMIT,
1032            max_ids: super::runs::MAX_IDS,
1033            max_file_bytes: super::agents::MAX_FILE_READ_BYTES,
1034            max_listing_entries: super::agents::MAX_LISTING_ENTRIES,
1035            max_search_scan: super::runs::MAX_SEARCH_SCAN,
1036            search_log_tail_bytes: super::runs::SEARCH_LOG_TAIL_BYTES,
1037            max_history_limit: super::agents::HISTORY_MAX_LIMIT,
1038            max_tracked_modified_files: leviath_core::run_meta::MAX_TRACKED_MODIFIED_FILES,
1039        }
1040    }
1041}
1042
1043/// Body of `PUT /api/config` (admin-only). Every field is optional; a present
1044/// field is written, an absent one is left untouched. Mirrors what `lev setup`
1045/// writes, so a newcomer can configure providers entirely from the browser.
1046#[derive(Debug, Default, Deserialize)]
1047pub(super) struct WriteConfigReq {
1048    pub(super) default_provider: Option<String>,
1049    pub(super) default_model: Option<String>,
1050    pub(super) anthropic_key: Option<String>,
1051    pub(super) openai_key: Option<String>,
1052    pub(super) google_key: Option<String>,
1053    pub(super) openrouter_key: Option<String>,
1054    pub(super) ollama_base_url: Option<String>,
1055}
1056
1057/// Body of `POST /api/config/validate` — a format-only key check (no network,
1058/// no persistence), mirroring the `lev setup` wizard's inline validation.
1059#[derive(Debug, Deserialize)]
1060pub(super) struct ValidateKeyReq {
1061    pub(super) provider: String,
1062    pub(super) key: String,
1063}
1064
1065#[derive(Debug, Serialize, Deserialize)]
1066pub(super) struct ValidateKeyResp {
1067    pub(super) valid: bool,
1068    #[serde(skip_serializing_if = "Option::is_none")]
1069    pub(super) message: Option<String>,
1070}
1071
1072#[derive(Serialize)]
1073pub(super) struct ModelEntry {
1074    pub(super) id: String,
1075    pub(super) provider: String,
1076    pub(super) display_name: Option<String>,
1077    pub(super) max_context_tokens: usize,
1078    pub(super) max_output_tokens: usize,
1079    pub(super) supports_tools: bool,
1080}
1081
1082#[cfg(test)]
1083mod status_matches_tests {
1084    use super::*;
1085    use crate::runstate::RunStatus;
1086
1087    /// The bug this function exists for: `WaitingInput` serializes as
1088    /// `waiting_input`, so that is the spelling a client has in hand - and the
1089    /// old `Display`-lowercased comparison rejected exactly that.
1090    #[test]
1091    fn the_serde_spelling_a_client_reads_back_is_accepted() {
1092        assert!(status_matches(&RunStatus::WaitingInput, "waiting_input"));
1093        assert!(status_matches(
1094            &RunStatus::CompleteInteractive,
1095            "complete_interactive"
1096        ));
1097    }
1098
1099    /// The spellings that worked before must keep working - this widens the
1100    /// filter, it does not move it.
1101    #[test]
1102    fn the_display_spelling_that_already_worked_still_does() {
1103        assert!(status_matches(&RunStatus::WaitingInput, "waitinginput"));
1104        assert!(status_matches(&RunStatus::Running, "running"));
1105        assert!(status_matches(&RunStatus::Running, "Running"));
1106    }
1107
1108    #[test]
1109    fn hyphens_and_mixed_case_are_accepted_too() {
1110        assert!(status_matches(&RunStatus::WaitingInput, "Waiting-Input"));
1111        assert!(status_matches(
1112            &RunStatus::CompleteInteractive,
1113            "COMPLETE-INTERACTIVE"
1114        ));
1115    }
1116
1117    /// Normalizing must not collapse genuinely different statuses into each
1118    /// other, or a filter would quietly return the wrong runs.
1119    #[test]
1120    fn a_different_status_still_does_not_match() {
1121        assert!(!status_matches(&RunStatus::Running, "complete"));
1122        assert!(!status_matches(
1123            &RunStatus::Complete,
1124            "complete_interactive"
1125        ));
1126        assert!(!status_matches(&RunStatus::CompleteInteractive, "complete"));
1127        assert!(!status_matches(&RunStatus::Running, ""));
1128    }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134
1135    #[test]
1136    fn server_event_agent_status_serialization() {
1137        let event = ServerEvent::AgentStatus {
1138            agent_id: "coder".to_string(),
1139            run_id: "run-123".to_string(),
1140            status: "running".to_string(),
1141            stage: "implement".to_string(),
1142            iteration: 5,
1143            tool_calls: 12,
1144            accepts_messages: true,
1145        };
1146        let json = serde_json::to_string(&event).unwrap();
1147        assert!(json.contains("\"type\":\"agent_status\""));
1148        assert!(json.contains("\"agent_id\":\"coder\""));
1149        assert!(json.contains("\"iteration\":5"));
1150        assert!(json.contains("\"tool_calls\":12"));
1151    }
1152
1153    #[test]
1154    fn server_event_tokens_serialization() {
1155        let event = ServerEvent::Tokens {
1156            agent_id: "coder".to_string(),
1157            run_id: "run-123".to_string(),
1158            prompt_tokens: 5000,
1159            completion_tokens: 1200,
1160            cached_tokens: 200,
1161            cache_write_tokens: 100,
1162        };
1163        let json = serde_json::to_string(&event).unwrap();
1164        assert!(json.contains("\"type\":\"tokens\""));
1165        assert!(json.contains("\"prompt_tokens\":5000"));
1166        assert!(json.contains("\"cached_tokens\":200"));
1167        assert!(json.contains("\"cache_write_tokens\":100"));
1168    }
1169
1170    #[test]
1171    fn server_event_agent_spawned_serialization() {
1172        let event = ServerEvent::AgentSpawned {
1173            agent_id: "coder".to_string(),
1174            run_id: "run-456".to_string(),
1175            parent_id: Some("run-123".to_string()),
1176            blueprint: "coder".to_string(),
1177        };
1178        let json = serde_json::to_string(&event).unwrap();
1179        assert!(json.contains("\"type\":\"agent_spawned\""));
1180        assert!(json.contains("\"parent_id\":\"run-123\""));
1181    }
1182
1183    #[test]
1184    fn server_event_agent_completed_serialization() {
1185        let event = ServerEvent::AgentCompleted {
1186            agent_id: "coder".to_string(),
1187            run_id: "run-123".to_string(),
1188            status: "complete".to_string(),
1189            result: Some("success".to_string()),
1190            final_output: None,
1191        };
1192        let json = serde_json::to_string(&event).unwrap();
1193        assert!(json.contains("\"type\":\"agent_completed\""));
1194    }
1195
1196    #[test]
1197    fn server_event_context_update_serialization() {
1198        let event = ServerEvent::ContextUpdate {
1199            agent_id: "coder".to_string(),
1200            run_id: "run-123".to_string(),
1201            total_tokens: 10000,
1202            max_tokens: 200000,
1203        };
1204        let json = serde_json::to_string(&event).unwrap();
1205        assert!(json.contains("\"type\":\"context_update\""));
1206        assert!(json.contains("\"total_tokens\":10000"));
1207    }
1208
1209    #[test]
1210    fn server_event_interaction_needed_serialization() {
1211        let event = ServerEvent::InteractionNeeded {
1212            agent_id: "coder".to_string(),
1213            run_id: "run-123".to_string(),
1214            request: serde_json::json!({"prompt": "approve?"}),
1215        };
1216        let json = serde_json::to_string(&event).unwrap();
1217        assert!(json.contains("\"type\":\"interaction_needed\""));
1218    }
1219
1220    #[test]
1221    fn server_event_log_serialization() {
1222        let event = ServerEvent::Log {
1223            agent_id: "coder".to_string(),
1224            run_id: "run-123".to_string(),
1225            line: "doing work".to_string(),
1226        };
1227        let json = serde_json::to_string(&event).unwrap();
1228        assert!(json.contains("\"type\":\"log\""));
1229        assert!(json.contains("\"line\":\"doing work\""));
1230    }
1231
1232    #[test]
1233    fn validate_response_serde_roundtrip() {
1234        let resp = ValidateResponse {
1235            valid: true,
1236            errors: None,
1237            warnings: None,
1238        };
1239        let json = serde_json::to_string(&resp).unwrap();
1240        // Neither list appears at all when there is nothing in it.
1241        assert_eq!(json, r#"{"valid":true}"#);
1242        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
1243        assert!(parsed.valid);
1244        assert!(parsed.errors.is_none());
1245        assert!(parsed.warnings.is_none());
1246    }
1247
1248    #[test]
1249    fn validate_response_with_errors_roundtrip() {
1250        let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
1251        let json = serde_json::to_string(&resp).unwrap();
1252        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
1253        assert!(!parsed.valid);
1254        assert_eq!(parsed.errors.unwrap().len(), 1);
1255        assert!(parsed.warnings.is_none());
1256    }
1257
1258    /// A blueprint can be valid and still have something worth saying about it.
1259    #[test]
1260    fn validate_response_with_warnings_roundtrip() {
1261        let resp = ValidateResponse {
1262            valid: true,
1263            errors: None,
1264            warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
1265        };
1266        let json = serde_json::to_string(&resp).unwrap();
1267        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
1268        assert!(parsed.valid);
1269        assert_eq!(parsed.warnings.unwrap().len(), 1);
1270    }
1271
1272    #[test]
1273    fn redacted_config_serde_roundtrip() {
1274        let config = RedactedConfig {
1275            default_provider: "anthropic".to_string(),
1276            has_anthropic_key: true,
1277            has_openai_key: false,
1278            has_google_key: false,
1279            has_openrouter_key: false,
1280            ollama_base_url: None,
1281            agent_paths: vec![],
1282            mcp_server_count: 0,
1283            api_version: API_VERSION.to_string(),
1284            capabilities: API_CAPABILITIES.iter().map(|c| c.to_string()).collect(),
1285            limits: ApiLimits::current(),
1286        };
1287        let json = serde_json::to_string(&config).unwrap();
1288        let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
1289        assert_eq!(parsed.default_provider, "anthropic");
1290        assert!(parsed.has_anthropic_key);
1291        assert!(!parsed.has_openai_key);
1292    }
1293
1294    #[test]
1295    fn error_response_serialization() {
1296        let err = ErrorResponse {
1297            error: "not found".to_string(),
1298        };
1299        let json = serde_json::to_string(&err).unwrap();
1300        assert!(json.contains("\"error\":\"not found\""));
1301    }
1302
1303    #[test]
1304    fn file_content_resp_serde_roundtrip() {
1305        let resp = FileContentResp {
1306            path: "/work/report.md".to_string(),
1307            size: 9,
1308            offset: 0,
1309            next_offset: None,
1310            content: "# Report\n".to_string(),
1311            truncated: false,
1312        };
1313        let json = serde_json::to_string(&resp).unwrap();
1314        let parsed: FileContentResp = serde_json::from_str(&json).unwrap();
1315        assert_eq!(parsed.path, "/work/report.md");
1316        assert_eq!(parsed.size, 9);
1317        assert_eq!(parsed.content, "# Report\n");
1318        assert!(!parsed.truncated);
1319    }
1320
1321    #[test]
1322    fn dirs_resp_serde_roundtrip() {
1323        let resp = DirsResp {
1324            path: "/work".to_string(),
1325            parent: None,
1326            home: "/Users/someone".to_string(),
1327            cwd: "/work/project".to_string(),
1328            root: Some("/work".to_string()),
1329            dirs: vec![DirEntry {
1330                name: "src".to_string(),
1331                path: "/work/src".to_string(),
1332            }],
1333        };
1334        let json = serde_json::to_string(&resp).unwrap();
1335        // An absent parent/root is an explicit `null`, never an omitted field -
1336        // the TypeScript client reads both unconditionally.
1337        assert!(json.contains("\"parent\":null"));
1338        assert!(json.contains(r#"{"name":"src","path":"/work/src"}"#));
1339        let parsed: DirsResp = serde_json::from_str(&json).unwrap();
1340        assert_eq!(parsed.path, "/work");
1341        assert!(parsed.parent.is_none());
1342        assert_eq!(parsed.root.as_deref(), Some("/work"));
1343        assert_eq!(parsed.dirs.len(), 1);
1344        assert_eq!(parsed.dirs[0].name, "src");
1345    }
1346
1347    #[test]
1348    fn doctor_resp_serde_roundtrip() {
1349        let resp = DoctorResp {
1350            checks: vec![
1351                DoctorCheck {
1352                    name: "config".to_string(),
1353                    ok: true,
1354                    detail: "default_provider=anthropic".to_string(),
1355                    elapsed_ms: None,
1356                },
1357                DoctorCheck {
1358                    name: "inference".to_string(),
1359                    ok: false,
1360                    detail: "HTTP 401: bad key".to_string(),
1361                    elapsed_ms: Some(1200),
1362                },
1363            ],
1364        };
1365        let json = serde_json::to_string(&resp).unwrap();
1366        // An untimed check omits the field entirely rather than sending null.
1367        assert!(
1368            json.contains(r#"{"name":"config","ok":true,"detail":"default_provider=anthropic"}"#)
1369        );
1370        assert!(json.contains("\"elapsed_ms\":1200"));
1371        let parsed: DoctorResp = serde_json::from_str(&json).unwrap();
1372        assert_eq!(parsed.checks.len(), 2);
1373        assert!(parsed.checks[0].ok);
1374        assert!(parsed.checks[0].elapsed_ms.is_none());
1375        assert!(!parsed.checks[1].ok);
1376        assert_eq!(parsed.checks[1].elapsed_ms, Some(1200));
1377    }
1378
1379    #[test]
1380    fn server_event_run_id_covers_every_variant() {
1381        let cases: Vec<(ServerEvent, &str)> = vec![
1382            (
1383                ServerEvent::AgentStatus {
1384                    agent_id: "a".to_string(),
1385                    run_id: "r1".to_string(),
1386                    status: "active".to_string(),
1387                    stage: "s".to_string(),
1388                    iteration: 0,
1389                    tool_calls: 0,
1390                    accepts_messages: false,
1391                },
1392                "r1",
1393            ),
1394            (
1395                ServerEvent::ContextUpdate {
1396                    agent_id: "a".to_string(),
1397                    run_id: "r2".to_string(),
1398                    total_tokens: 1,
1399                    max_tokens: 2,
1400                },
1401                "r2",
1402            ),
1403            (
1404                ServerEvent::Log {
1405                    agent_id: "a".to_string(),
1406                    run_id: "r3".to_string(),
1407                    line: "l".to_string(),
1408                },
1409                "r3",
1410            ),
1411            (
1412                ServerEvent::InteractionNeeded {
1413                    agent_id: "a".to_string(),
1414                    run_id: "r4".to_string(),
1415                    request: serde_json::Value::Null,
1416                },
1417                "r4",
1418            ),
1419            (
1420                ServerEvent::AgentSpawned {
1421                    agent_id: "a".to_string(),
1422                    run_id: "r5".to_string(),
1423                    parent_id: None,
1424                    blueprint: "b".to_string(),
1425                },
1426                "r5",
1427            ),
1428            (
1429                ServerEvent::AgentCompleted {
1430                    agent_id: "a".to_string(),
1431                    run_id: "r6".to_string(),
1432                    status: "complete".to_string(),
1433                    result: None,
1434                    final_output: None,
1435                },
1436                "r6",
1437            ),
1438            (
1439                ServerEvent::Tokens {
1440                    agent_id: "a".to_string(),
1441                    run_id: "r7".to_string(),
1442                    prompt_tokens: 0,
1443                    completion_tokens: 0,
1444                    cached_tokens: 0,
1445                    cache_write_tokens: 0,
1446                },
1447                "r7",
1448            ),
1449            (
1450                ServerEvent::World {
1451                    event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
1452                },
1453                "r8",
1454            ),
1455            // A wrapped event with no run_id filters as the empty string
1456            // rather than panicking (real world events always carry one).
1457            (
1458                ServerEvent::World {
1459                    event: serde_json::Value::Null,
1460                },
1461                "",
1462            ),
1463        ];
1464        for (ev, want) in cases {
1465            assert_eq!(ev.run_id(), want);
1466        }
1467    }
1468}