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}/stages`: the run's per-stage ledger.
767///
768/// The one thing the runtime records per run that no route served. Everything
769/// here is already on disk in `stages.json` and already read by `lev stages`;
770/// a client over HTTP had to reconstruct the interesting part by diffing
771/// `context/history` snapshots, which is expensive and cannot see a stage that
772/// ran and wrote nothing (#388).
773///
774/// Not paginated. The list is bounded by the blueprint's stage count - a dozen
775/// at the top end - so a cursor would be ceremony over a short array. The
776/// records themselves are open-ended in width because `region_tokens` has one
777/// entry per region, which is the reason this is its own route rather than a
778/// field on the run listing.
779#[derive(Debug, Serialize)]
780pub(super) struct RunStagesResp {
781    /// The run these stages belong to, echoed so a response is self-describing
782    /// when it has been passed around.
783    pub(super) run_id: String,
784    /// Per-stage records in blueprint order, exactly as recorded.
785    pub(super) stages: Vec<leviath_core::run_meta::StageRecord>,
786}
787
788/// Response of `GET /api/agents/{id}/files` with no file named.
789#[derive(Debug, Serialize)]
790pub(super) struct RunFileListing {
791    /// Always `"listing"`. What a client checks to tell the two shapes apart.
792    pub(super) kind: &'static str,
793    /// Which question this answers: `modified` or `workdir`.
794    pub(super) source: &'static str,
795    /// The directory listed, or the workdir for a `modified` listing.
796    pub(super) path: String,
797    /// Where "up one level" goes, or `null` at the workdir root.
798    pub(super) parent: Option<String>,
799    /// The run's working directory, which paths are relative to.
800    pub(super) workdir: String,
801    pub(super) entries: Vec<RunFileEntry>,
802    /// Whether `entries` stops short of the directory's real contents.
803    pub(super) truncated: bool,
804    /// Whether the run hit the tracked-modified-files cap, so its recorded list
805    /// is a prefix and the remaining names were never stored anywhere.
806    ///
807    /// Exposed because the alternative - a client subtracting
808    /// `modifying_tool_calls` from `entries.len()` - is wrong, and was the
809    /// original "+N more" bug. Use `source=workdir` for ground truth about what
810    /// is actually on disk.
811    pub(super) modified_files_truncated: bool,
812    /// Successful **modifying tool calls**, which is not a file count: a run
813    /// that edits one file three times records three. Named for what it counts.
814    pub(super) modifying_tool_calls: usize,
815}
816
817/// One entry in a [`RunFileListing`].
818#[derive(Debug, Serialize)]
819pub(super) struct RunFileEntry {
820    pub(super) name: String,
821    /// Relative to the run's workdir where possible, so a client can pass it
822    /// straight back as `?path=`.
823    pub(super) path: String,
824    pub(super) is_dir: bool,
825    /// `null` when the entry could not be stat-ed.
826    pub(super) size: Option<u64>,
827    /// False for a recorded path that has since been deleted.
828    pub(super) exists: bool,
829    /// True for a recorded path that resolves outside the workdir - possible
830    /// when a tool was handed an absolute path. Reported rather than hidden.
831    pub(super) outside_workdir: bool,
832}
833
834/// Response of `GET /api/agents/{id}/files`: one file the run wrote, as text.
835#[derive(Debug, Serialize, Deserialize)]
836pub(super) struct FileContentResp {
837    /// The resolved absolute path that was read.
838    pub(super) path: String,
839    /// The file's full size in bytes - larger than `content` when `truncated`.
840    pub(super) size: u64,
841    /// Where this window starts, in bytes. Not always the `offset` that was
842    /// asked for: an offset landing mid-character is moved forward to the next
843    /// character boundary, so the pages of a file line up.
844    ///
845    /// Omitted when it is zero, which keeps a whole-file read serializing
846    /// exactly as it did before paging existed. Adding a key to that response
847    /// would be harmless for most clients and is still not worth doing to all
848    /// of them for a field only a paging caller reads.
849    #[serde(default, skip_serializing_if = "is_zero")]
850    pub(super) offset: u64,
851    /// Where to start the next request to continue reading. `null` when this
852    /// window reached the end of the file.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub(super) next_offset: Option<u64>,
855    /// This window's bytes as UTF-8, capped at
856    /// [`MAX_FILE_READ_BYTES`](super::agents::MAX_FILE_READ_BYTES).
857    pub(super) content: String,
858    /// Whether the file continues past this window. Read on from `next_offset`.
859    pub(super) truncated: bool,
860}
861
862// ─── Doctor types ───────────────────────────────────────────────────────────
863
864/// Response of `GET /api/doctor`: the `lev doctor` checks, as data.
865#[derive(Debug, Serialize, Deserialize)]
866pub(super) struct DoctorResp {
867    pub(super) checks: Vec<DoctorCheck>,
868}
869
870/// One `lev doctor` layer's verdict, reshaped for the browser: the enum
871/// status becomes a plain `ok` bool so the client never parses labels.
872#[derive(Debug, Serialize, Deserialize)]
873pub(super) struct DoctorCheck {
874    /// The layer's short name: `config`, `resolve`, `inference`, or `daemon`.
875    pub(super) name: String,
876    /// Whether the layer works. A failure is reported here, never as an
877    /// HTTP error - the endpoint answering at all is not what is diagnosed.
878    pub(super) ok: bool,
879    pub(super) detail: String,
880    /// Wall-clock cost, present for the checks that make a network call.
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub(super) elapsed_ms: Option<u64>,
883}
884
885// ─── Filesystem types ───────────────────────────────────────────────────────
886
887/// Query for `GET /api/fs/dirs`: the directory to list. Must be absolute when
888/// given; absent means the server's own working directory (clamped to
889/// `--workdir-root` when the cwd falls outside it).
890#[derive(Deserialize)]
891pub(super) struct DirsQuery {
892    pub(super) path: Option<String>,
893    /// Include dot-prefixed directories (hidden on Unix). Off by default so a
894    /// first-run picker isn't a wall of config noise.
895    #[serde(default)]
896    pub(super) hidden: bool,
897}
898
899/// Response of `GET /api/fs/dirs`: one directory level of the host filesystem,
900/// enough for the browser's folder picker to walk without shell access.
901#[derive(Debug, Serialize, Deserialize)]
902pub(super) struct DirsResp {
903    /// The absolute directory that was listed.
904    pub(super) path: String,
905    /// Where "up one level" goes. `null` at the filesystem root, and also when
906    /// `path` *is* the workdir-root - the picker is never led above the fence.
907    pub(super) parent: Option<String>,
908    /// The user's home directory, for the picker's "home" shortcut.
909    pub(super) home: String,
910    /// The serve process's working directory, for the picker's "here" shortcut.
911    pub(super) cwd: String,
912    /// The configured `--workdir-root`, or `null` when the server has none.
913    pub(super) root: Option<String>,
914    /// The immediate subdirectories, name-sorted. Dotted names are excluded,
915    /// and with a root set, so is any symlink that resolves outside it.
916    pub(super) dirs: Vec<DirEntry>,
917}
918
919/// One subdirectory in a [`DirsResp`] listing.
920#[derive(Debug, Serialize, Deserialize)]
921pub(super) struct DirEntry {
922    pub(super) name: String,
923    pub(super) path: String,
924}
925
926// ─── Tree types ─────────────────────────────────────────────────────────────
927
928#[derive(Serialize)]
929pub(super) struct AgentTreeNode {
930    pub(super) run_id: String,
931    pub(super) agent_name: String,
932    pub(super) status: String,
933    pub(super) stage: String,
934    pub(super) iteration: usize,
935    pub(super) prompt_tokens: usize,
936    pub(super) completion_tokens: usize,
937    pub(super) children: Vec<AgentTreeNode>,
938}
939
940#[derive(Debug, Serialize)]
941pub(super) struct TreeStatusNode {
942    pub(super) run_id: String,
943    pub(super) agent_name: String,
944    pub(super) status: String,
945    pub(super) stage: String,
946    pub(super) prompt_tokens: usize,
947    pub(super) completion_tokens: usize,
948    pub(super) subtree_prompt_tokens: usize,
949    pub(super) subtree_completion_tokens: usize,
950    pub(super) children: Vec<TreeStatusNode>,
951}
952
953// ─── Interaction types ──────────────────────────────────────────────────────
954
955#[derive(Deserialize)]
956pub(super) struct SubmitInteractionReq {
957    pub(super) request_id: String,
958    pub(super) value: Option<String>,
959    pub(super) choice_index: Option<usize>,
960    pub(super) approved: Option<bool>,
961    pub(super) scope: Option<String>,
962}
963
964#[derive(Deserialize)]
965pub(super) struct SendMessageReq {
966    pub(super) message: String,
967    #[serde(default)]
968    pub(super) target_region: Option<String>,
969}
970
971// ─── Config types ───────────────────────────────────────────────────────────
972
973#[derive(Serialize, Deserialize)]
974pub(super) struct RedactedConfig {
975    pub(super) default_provider: String,
976    pub(super) has_anthropic_key: bool,
977    pub(super) has_openai_key: bool,
978    pub(super) has_google_key: bool,
979    pub(super) has_openrouter_key: bool,
980    pub(super) ollama_base_url: Option<String>,
981    pub(super) agent_paths: Vec<PathBuf>,
982    pub(super) mcp_server_count: usize,
983    /// The API contract this server implements, matching `info.version` in
984    /// `docs/schema/openapi.json`. A test holds the two together.
985    pub(super) api_version: String,
986    /// What this server can do, so a client can light up features in one call.
987    ///
988    /// Before this, the console feature-detected by calling a route and reading
989    /// a 404 as "unsupported" - fragile, because a 404 also means "no such run",
990    /// and one round trip per feature.
991    pub(super) capabilities: Vec<String>,
992    pub(super) limits: ApiLimits,
993}
994
995/// The API contract version. Held equal to the OpenAPI spec's `info.version` by
996/// a test, because a version that can silently disagree with the document it
997/// names is worse than no version at all.
998pub(super) const API_VERSION: &str = "0.3.0";
999
1000/// Every capability a client may check for.
1001pub(super) const API_CAPABILITIES: &[&str] = &[
1002    "runs.envelope",
1003    "runs.cursor",
1004    "runs.search",
1005    "runs.search.context",
1006    "runs.search.logs",
1007    "runs.search.journal",
1008    "runs.fields",
1009    "runs.ids",
1010    "runs.since",
1011    "runs.files.listing",
1012    "runs.files.workdir",
1013    "runs.stages",
1014    "logs.stage",
1015    "logs.stream",
1016    "context.history.page",
1017    "blueprints.envelope",
1018    "blueprints.query",
1019];
1020
1021/// The server's numeric limits.
1022///
1023/// This is what makes capability discovery useful rather than decorative: a
1024/// client that knows the feature exists still has to guess the page cap, the
1025/// file-size cap and the tracked-file cap, and every one of those guesses would
1026/// be hardcoded and eventually wrong.
1027#[derive(Debug, Serialize, Deserialize)]
1028pub(super) struct ApiLimits {
1029    /// Largest `limit` on `GET /api/runs`; larger values are clamped.
1030    pub(super) max_limit: usize,
1031    /// Most ids one `ids=` batch may name.
1032    pub(super) max_ids: usize,
1033    /// Largest file body `?path=` returns.
1034    pub(super) max_file_bytes: u64,
1035    /// Most entries one directory listing returns.
1036    pub(super) max_listing_entries: usize,
1037    /// How many runs a filesystem-reading search examines before reporting
1038    /// `scan_truncated`.
1039    pub(super) max_search_scan: usize,
1040    /// How much of each stage log a search reads, from the end.
1041    pub(super) search_log_tail_bytes: u64,
1042    /// Largest `limit` on the context-history route.
1043    pub(super) max_history_limit: usize,
1044    /// How many distinct modified paths a run records before
1045    /// `modified_files_truncated` is set.
1046    pub(super) max_tracked_modified_files: usize,
1047}
1048
1049impl ApiLimits {
1050    /// Read from the constants the handlers actually use, so the two cannot
1051    /// drift into disagreeing.
1052    pub(super) fn current() -> Self {
1053        Self {
1054            max_limit: super::runs::MAX_LIMIT,
1055            max_ids: super::runs::MAX_IDS,
1056            max_file_bytes: super::agents::MAX_FILE_READ_BYTES,
1057            max_listing_entries: super::agents::MAX_LISTING_ENTRIES,
1058            max_search_scan: super::runs::MAX_SEARCH_SCAN,
1059            search_log_tail_bytes: super::runs::SEARCH_LOG_TAIL_BYTES,
1060            max_history_limit: super::agents::HISTORY_MAX_LIMIT,
1061            max_tracked_modified_files: leviath_core::run_meta::MAX_TRACKED_MODIFIED_FILES,
1062        }
1063    }
1064}
1065
1066/// Body of `PUT /api/config` (admin-only). Every field is optional; a present
1067/// field is written, an absent one is left untouched. Mirrors what `lev setup`
1068/// writes, so a newcomer can configure providers entirely from the browser.
1069#[derive(Debug, Default, Deserialize)]
1070pub(super) struct WriteConfigReq {
1071    pub(super) default_provider: Option<String>,
1072    pub(super) default_model: Option<String>,
1073    pub(super) anthropic_key: Option<String>,
1074    pub(super) openai_key: Option<String>,
1075    pub(super) google_key: Option<String>,
1076    pub(super) openrouter_key: Option<String>,
1077    pub(super) ollama_base_url: Option<String>,
1078}
1079
1080/// Body of `POST /api/config/validate` — a format-only key check (no network,
1081/// no persistence), mirroring the `lev setup` wizard's inline validation.
1082#[derive(Debug, Deserialize)]
1083pub(super) struct ValidateKeyReq {
1084    pub(super) provider: String,
1085    pub(super) key: String,
1086}
1087
1088#[derive(Debug, Serialize, Deserialize)]
1089pub(super) struct ValidateKeyResp {
1090    pub(super) valid: bool,
1091    #[serde(skip_serializing_if = "Option::is_none")]
1092    pub(super) message: Option<String>,
1093}
1094
1095#[derive(Serialize)]
1096pub(super) struct ModelEntry {
1097    pub(super) id: String,
1098    pub(super) provider: String,
1099    pub(super) display_name: Option<String>,
1100    pub(super) max_context_tokens: usize,
1101    pub(super) max_output_tokens: usize,
1102    pub(super) supports_tools: bool,
1103}
1104
1105#[cfg(test)]
1106mod status_matches_tests {
1107    use super::*;
1108    use crate::runstate::RunStatus;
1109
1110    /// The bug this function exists for: `WaitingInput` serializes as
1111    /// `waiting_input`, so that is the spelling a client has in hand - and the
1112    /// old `Display`-lowercased comparison rejected exactly that.
1113    #[test]
1114    fn the_serde_spelling_a_client_reads_back_is_accepted() {
1115        assert!(status_matches(&RunStatus::WaitingInput, "waiting_input"));
1116        assert!(status_matches(
1117            &RunStatus::CompleteInteractive,
1118            "complete_interactive"
1119        ));
1120    }
1121
1122    /// The spellings that worked before must keep working - this widens the
1123    /// filter, it does not move it.
1124    #[test]
1125    fn the_display_spelling_that_already_worked_still_does() {
1126        assert!(status_matches(&RunStatus::WaitingInput, "waitinginput"));
1127        assert!(status_matches(&RunStatus::Running, "running"));
1128        assert!(status_matches(&RunStatus::Running, "Running"));
1129    }
1130
1131    #[test]
1132    fn hyphens_and_mixed_case_are_accepted_too() {
1133        assert!(status_matches(&RunStatus::WaitingInput, "Waiting-Input"));
1134        assert!(status_matches(
1135            &RunStatus::CompleteInteractive,
1136            "COMPLETE-INTERACTIVE"
1137        ));
1138    }
1139
1140    /// Normalizing must not collapse genuinely different statuses into each
1141    /// other, or a filter would quietly return the wrong runs.
1142    #[test]
1143    fn a_different_status_still_does_not_match() {
1144        assert!(!status_matches(&RunStatus::Running, "complete"));
1145        assert!(!status_matches(
1146            &RunStatus::Complete,
1147            "complete_interactive"
1148        ));
1149        assert!(!status_matches(&RunStatus::CompleteInteractive, "complete"));
1150        assert!(!status_matches(&RunStatus::Running, ""));
1151    }
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    #[test]
1159    fn server_event_agent_status_serialization() {
1160        let event = ServerEvent::AgentStatus {
1161            agent_id: "coder".to_string(),
1162            run_id: "run-123".to_string(),
1163            status: "running".to_string(),
1164            stage: "implement".to_string(),
1165            iteration: 5,
1166            tool_calls: 12,
1167            accepts_messages: true,
1168        };
1169        let json = serde_json::to_string(&event).unwrap();
1170        assert!(json.contains("\"type\":\"agent_status\""));
1171        assert!(json.contains("\"agent_id\":\"coder\""));
1172        assert!(json.contains("\"iteration\":5"));
1173        assert!(json.contains("\"tool_calls\":12"));
1174    }
1175
1176    #[test]
1177    fn server_event_tokens_serialization() {
1178        let event = ServerEvent::Tokens {
1179            agent_id: "coder".to_string(),
1180            run_id: "run-123".to_string(),
1181            prompt_tokens: 5000,
1182            completion_tokens: 1200,
1183            cached_tokens: 200,
1184            cache_write_tokens: 100,
1185        };
1186        let json = serde_json::to_string(&event).unwrap();
1187        assert!(json.contains("\"type\":\"tokens\""));
1188        assert!(json.contains("\"prompt_tokens\":5000"));
1189        assert!(json.contains("\"cached_tokens\":200"));
1190        assert!(json.contains("\"cache_write_tokens\":100"));
1191    }
1192
1193    #[test]
1194    fn server_event_agent_spawned_serialization() {
1195        let event = ServerEvent::AgentSpawned {
1196            agent_id: "coder".to_string(),
1197            run_id: "run-456".to_string(),
1198            parent_id: Some("run-123".to_string()),
1199            blueprint: "coder".to_string(),
1200        };
1201        let json = serde_json::to_string(&event).unwrap();
1202        assert!(json.contains("\"type\":\"agent_spawned\""));
1203        assert!(json.contains("\"parent_id\":\"run-123\""));
1204    }
1205
1206    #[test]
1207    fn server_event_agent_completed_serialization() {
1208        let event = ServerEvent::AgentCompleted {
1209            agent_id: "coder".to_string(),
1210            run_id: "run-123".to_string(),
1211            status: "complete".to_string(),
1212            result: Some("success".to_string()),
1213            final_output: None,
1214        };
1215        let json = serde_json::to_string(&event).unwrap();
1216        assert!(json.contains("\"type\":\"agent_completed\""));
1217    }
1218
1219    #[test]
1220    fn server_event_context_update_serialization() {
1221        let event = ServerEvent::ContextUpdate {
1222            agent_id: "coder".to_string(),
1223            run_id: "run-123".to_string(),
1224            total_tokens: 10000,
1225            max_tokens: 200000,
1226        };
1227        let json = serde_json::to_string(&event).unwrap();
1228        assert!(json.contains("\"type\":\"context_update\""));
1229        assert!(json.contains("\"total_tokens\":10000"));
1230    }
1231
1232    #[test]
1233    fn server_event_interaction_needed_serialization() {
1234        let event = ServerEvent::InteractionNeeded {
1235            agent_id: "coder".to_string(),
1236            run_id: "run-123".to_string(),
1237            request: serde_json::json!({"prompt": "approve?"}),
1238        };
1239        let json = serde_json::to_string(&event).unwrap();
1240        assert!(json.contains("\"type\":\"interaction_needed\""));
1241    }
1242
1243    #[test]
1244    fn server_event_log_serialization() {
1245        let event = ServerEvent::Log {
1246            agent_id: "coder".to_string(),
1247            run_id: "run-123".to_string(),
1248            line: "doing work".to_string(),
1249        };
1250        let json = serde_json::to_string(&event).unwrap();
1251        assert!(json.contains("\"type\":\"log\""));
1252        assert!(json.contains("\"line\":\"doing work\""));
1253    }
1254
1255    #[test]
1256    fn validate_response_serde_roundtrip() {
1257        let resp = ValidateResponse {
1258            valid: true,
1259            errors: None,
1260            warnings: None,
1261        };
1262        let json = serde_json::to_string(&resp).unwrap();
1263        // Neither list appears at all when there is nothing in it.
1264        assert_eq!(json, r#"{"valid":true}"#);
1265        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
1266        assert!(parsed.valid);
1267        assert!(parsed.errors.is_none());
1268        assert!(parsed.warnings.is_none());
1269    }
1270
1271    #[test]
1272    fn validate_response_with_errors_roundtrip() {
1273        let resp = ValidateResponse::invalid(vec!["bad field".to_string()]);
1274        let json = serde_json::to_string(&resp).unwrap();
1275        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
1276        assert!(!parsed.valid);
1277        assert_eq!(parsed.errors.unwrap().len(), 1);
1278        assert!(parsed.warnings.is_none());
1279    }
1280
1281    /// A blueprint can be valid and still have something worth saying about it.
1282    #[test]
1283    fn validate_response_with_warnings_roundtrip() {
1284        let resp = ValidateResponse {
1285            valid: true,
1286            errors: None,
1287            warnings: Some(vec!["stage 'main': no max_iterations".to_string()]),
1288        };
1289        let json = serde_json::to_string(&resp).unwrap();
1290        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
1291        assert!(parsed.valid);
1292        assert_eq!(parsed.warnings.unwrap().len(), 1);
1293    }
1294
1295    #[test]
1296    fn redacted_config_serde_roundtrip() {
1297        let config = RedactedConfig {
1298            default_provider: "anthropic".to_string(),
1299            has_anthropic_key: true,
1300            has_openai_key: false,
1301            has_google_key: false,
1302            has_openrouter_key: false,
1303            ollama_base_url: None,
1304            agent_paths: vec![],
1305            mcp_server_count: 0,
1306            api_version: API_VERSION.to_string(),
1307            capabilities: API_CAPABILITIES.iter().map(|c| c.to_string()).collect(),
1308            limits: ApiLimits::current(),
1309        };
1310        let json = serde_json::to_string(&config).unwrap();
1311        let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
1312        assert_eq!(parsed.default_provider, "anthropic");
1313        assert!(parsed.has_anthropic_key);
1314        assert!(!parsed.has_openai_key);
1315    }
1316
1317    #[test]
1318    fn error_response_serialization() {
1319        let err = ErrorResponse {
1320            error: "not found".to_string(),
1321        };
1322        let json = serde_json::to_string(&err).unwrap();
1323        assert!(json.contains("\"error\":\"not found\""));
1324    }
1325
1326    #[test]
1327    fn file_content_resp_serde_roundtrip() {
1328        let resp = FileContentResp {
1329            path: "/work/report.md".to_string(),
1330            size: 9,
1331            offset: 0,
1332            next_offset: None,
1333            content: "# Report\n".to_string(),
1334            truncated: false,
1335        };
1336        let json = serde_json::to_string(&resp).unwrap();
1337        let parsed: FileContentResp = serde_json::from_str(&json).unwrap();
1338        assert_eq!(parsed.path, "/work/report.md");
1339        assert_eq!(parsed.size, 9);
1340        assert_eq!(parsed.content, "# Report\n");
1341        assert!(!parsed.truncated);
1342    }
1343
1344    #[test]
1345    fn dirs_resp_serde_roundtrip() {
1346        let resp = DirsResp {
1347            path: "/work".to_string(),
1348            parent: None,
1349            home: "/Users/someone".to_string(),
1350            cwd: "/work/project".to_string(),
1351            root: Some("/work".to_string()),
1352            dirs: vec![DirEntry {
1353                name: "src".to_string(),
1354                path: "/work/src".to_string(),
1355            }],
1356        };
1357        let json = serde_json::to_string(&resp).unwrap();
1358        // An absent parent/root is an explicit `null`, never an omitted field -
1359        // the TypeScript client reads both unconditionally.
1360        assert!(json.contains("\"parent\":null"));
1361        assert!(json.contains(r#"{"name":"src","path":"/work/src"}"#));
1362        let parsed: DirsResp = serde_json::from_str(&json).unwrap();
1363        assert_eq!(parsed.path, "/work");
1364        assert!(parsed.parent.is_none());
1365        assert_eq!(parsed.root.as_deref(), Some("/work"));
1366        assert_eq!(parsed.dirs.len(), 1);
1367        assert_eq!(parsed.dirs[0].name, "src");
1368    }
1369
1370    #[test]
1371    fn doctor_resp_serde_roundtrip() {
1372        let resp = DoctorResp {
1373            checks: vec![
1374                DoctorCheck {
1375                    name: "config".to_string(),
1376                    ok: true,
1377                    detail: "default_provider=anthropic".to_string(),
1378                    elapsed_ms: None,
1379                },
1380                DoctorCheck {
1381                    name: "inference".to_string(),
1382                    ok: false,
1383                    detail: "HTTP 401: bad key".to_string(),
1384                    elapsed_ms: Some(1200),
1385                },
1386            ],
1387        };
1388        let json = serde_json::to_string(&resp).unwrap();
1389        // An untimed check omits the field entirely rather than sending null.
1390        assert!(
1391            json.contains(r#"{"name":"config","ok":true,"detail":"default_provider=anthropic"}"#)
1392        );
1393        assert!(json.contains("\"elapsed_ms\":1200"));
1394        let parsed: DoctorResp = serde_json::from_str(&json).unwrap();
1395        assert_eq!(parsed.checks.len(), 2);
1396        assert!(parsed.checks[0].ok);
1397        assert!(parsed.checks[0].elapsed_ms.is_none());
1398        assert!(!parsed.checks[1].ok);
1399        assert_eq!(parsed.checks[1].elapsed_ms, Some(1200));
1400    }
1401
1402    #[test]
1403    fn server_event_run_id_covers_every_variant() {
1404        let cases: Vec<(ServerEvent, &str)> = vec![
1405            (
1406                ServerEvent::AgentStatus {
1407                    agent_id: "a".to_string(),
1408                    run_id: "r1".to_string(),
1409                    status: "active".to_string(),
1410                    stage: "s".to_string(),
1411                    iteration: 0,
1412                    tool_calls: 0,
1413                    accepts_messages: false,
1414                },
1415                "r1",
1416            ),
1417            (
1418                ServerEvent::ContextUpdate {
1419                    agent_id: "a".to_string(),
1420                    run_id: "r2".to_string(),
1421                    total_tokens: 1,
1422                    max_tokens: 2,
1423                },
1424                "r2",
1425            ),
1426            (
1427                ServerEvent::Log {
1428                    agent_id: "a".to_string(),
1429                    run_id: "r3".to_string(),
1430                    line: "l".to_string(),
1431                },
1432                "r3",
1433            ),
1434            (
1435                ServerEvent::InteractionNeeded {
1436                    agent_id: "a".to_string(),
1437                    run_id: "r4".to_string(),
1438                    request: serde_json::Value::Null,
1439                },
1440                "r4",
1441            ),
1442            (
1443                ServerEvent::AgentSpawned {
1444                    agent_id: "a".to_string(),
1445                    run_id: "r5".to_string(),
1446                    parent_id: None,
1447                    blueprint: "b".to_string(),
1448                },
1449                "r5",
1450            ),
1451            (
1452                ServerEvent::AgentCompleted {
1453                    agent_id: "a".to_string(),
1454                    run_id: "r6".to_string(),
1455                    status: "complete".to_string(),
1456                    result: None,
1457                    final_output: None,
1458                },
1459                "r6",
1460            ),
1461            (
1462                ServerEvent::Tokens {
1463                    agent_id: "a".to_string(),
1464                    run_id: "r7".to_string(),
1465                    prompt_tokens: 0,
1466                    completion_tokens: 0,
1467                    cached_tokens: 0,
1468                    cache_write_tokens: 0,
1469                },
1470                "r7",
1471            ),
1472            (
1473                ServerEvent::World {
1474                    event: serde_json::json!({"event": "stage_transition", "run_id": "r8"}),
1475                },
1476                "r8",
1477            ),
1478            // A wrapped event with no run_id filters as the empty string
1479            // rather than panicking (real world events always carry one).
1480            (
1481                ServerEvent::World {
1482                    event: serde_json::Value::Null,
1483                },
1484                "",
1485            ),
1486        ];
1487        for (ev, want) in cases {
1488            assert_eq!(ev.run_id(), want);
1489        }
1490    }
1491}