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}