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