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