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