Skip to main content

serve_agentcore/
lib.rs

1//! # serve-agentcore (experimental)
2//!
3//! Run a [serve](https://docs.rs/everruns-serve) app on
4//! [Amazon Bedrock AgentCore Runtime](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-agui-protocol-contract.html),
5//! part of the [Everruns](https://everruns.com) ecosystem.
6//!
7//! > **Experimental.** Like serve, this is a proof of concept with no
8//! > compatibility promise.
9//!
10//! AgentCore Runtime runs one container per session in its own microVM and
11//! talks to it over HTTP on port 8080. This crate serves that contract on
12//! top of serve's host:
13//!
14//! - `GET /ping`: `{"status":"Healthy"}`, or `HealthyBusy` while a turn
15//!   runs, so AgentCore keeps the session alive for background work.
16//! - `POST /invocations`: an AG-UI 1.0 run of the app's agent, streamed as
17//!   server-sent events. The body is AG-UI `RunAgentInput` (what the
18//!   AgentCore AG-UI protocol forwards), or `{"prompt": "..."}` (what
19//!   `InvokeAgentRuntime` callers usually send). The AG-UI thread defaults
20//!   to the AgentCore session id from
21//!   `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id`.
22//! - `GET /ws`: the same runs over AgentCore's WebSocket transport. Each text
23//!   message is one invocation body; the run's AG-UI events come back as one
24//!   text message each, so a connection can carry several runs in turn.
25//! - serve's own `/v1` wire API and `/health`, unchanged, for local use and
26//!   `eval --against`.
27//!
28//! Everything else is serve's: the app, its agents and tools, approvals,
29//! `ask_user`, and the durable SQLite session log, kept on AgentCore session
30//! storage (`/mnt/workspace`) when the runtime mounts it, so sessions survive
31//! the microVM stopping.
32//!
33//! ```no_run
34//! use serve::prelude::*;
35//!
36//! #[agent]
37//! fn assistant() -> Agent {
38//!     Agent::builder()
39//!         .model("anthropic/claude-sonnet-5")
40//!         .instructions("Be brief.")
41//!         .build()
42//! }
43//!
44//! #[tokio::main]
45//! async fn main() -> serve::Result {
46//!     // No command (what AgentCore runs) or `agentcore`: the AgentCore
47//!     // contract on :8080. `dev`, `start`, `eval`, `manifest`, `deploy`:
48//!     // serve's own commands.
49//!     serve_agentcore::start(App::builder().discover().build()).await
50//! }
51//! ```
52
53#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
54
55use std::ffi::OsString;
56use std::net::SocketAddr;
57use std::path::{Path, PathBuf};
58use std::sync::Arc;
59
60use anyhow::anyhow;
61use axum::body::{Body, Bytes};
62use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
63use axum::extract::{Request, State};
64use axum::http::{HeaderMap, StatusCode, header};
65use axum::response::{IntoResponse, Response};
66use axum::routing::{get, post};
67use axum::{Json, Router};
68use clap::Parser;
69use serde_json::{Value, json};
70use serve::{App, Mode, Server};
71use tokio::sync::OnceCell;
72use tower::ServiceExt;
73
74/// The header AgentCore Runtime sets on every request, naming the session
75/// (and so the microVM) it routed to.
76pub const SESSION_HEADER: &str = "x-amzn-bedrock-agentcore-runtime-session-id";
77
78/// The port the AgentCore HTTP and AG-UI protocol contracts require.
79pub const PORT: u16 = 8080;
80
81/// Where serve-agentcore looks for AgentCore session storage when
82/// `SERVE_DATA_DIR` is not set: the mount path the AgentCore docs use.
83pub const SESSION_STORAGE: &str = "/mnt/workspace";
84
85/// The `agentcore` command line.
86#[derive(Parser, Debug)]
87#[command(
88    about = "Serve this app on the Amazon Bedrock AgentCore Runtime contract (experimental)."
89)]
90struct Cli {
91    /// Port to listen on. AgentCore requires 8080.
92    #[arg(long, env = "PORT", default_value_t = PORT)]
93    port: u16,
94    /// The agent `/invocations` runs. Defaults to the app's default agent.
95    #[arg(long, env = "SERVE_AGENTCORE_AGENT")]
96    agent: Option<String>,
97    /// Run in serve's `dev` mode (simulator fallback, no secret check)
98    /// instead of `start`, to try the contract locally.
99    #[arg(long)]
100    dev: bool,
101}
102
103/// How [`router`] boots the app.
104#[derive(Clone, Debug)]
105pub struct Options {
106    /// serve's mode: [`Mode::Start`] in production.
107    pub mode: Mode,
108    /// The agent `/invocations` runs; the app's default agent when `None`.
109    pub agent: Option<String>,
110    /// Where serve keeps its session log. `None` resolves on the first
111    /// request (see [`Storage::resolve`]).
112    pub data_dir: Option<PathBuf>,
113    /// The root of the `[sandbox] kind = "microvm"` workspace. `None`
114    /// resolves with the data dir.
115    pub workspace: Option<PathBuf>,
116}
117
118impl Options {
119    /// Production defaults: `start` mode, everything else resolved.
120    pub fn new(mode: Mode) -> Self {
121        Self {
122            mode,
123            agent: None,
124            data_dir: None,
125            workspace: None,
126        }
127    }
128}
129
130/// Where a booted app keeps its state.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct Storage {
133    /// serve's SQLite session log.
134    pub data_dir: PathBuf,
135    /// The microVM workspace: the shell's working directory and the root
136    /// of the file tools.
137    pub workspace: PathBuf,
138    /// Whether this survives the microVM stopping.
139    pub persistent: bool,
140}
141
142impl Storage {
143    /// Resolve where state lives, in order:
144    ///
145    /// 1. `SERVE_DATA_DIR` or `DATABASE_URL` (serve's own settings), with the
146    ///    workspace in `SERVE_WORKSPACE` or beside it. Taken as persistent:
147    ///    the operator chose it.
148    /// 2. AgentCore session storage mounted at [`SESSION_STORAGE`]: the log
149    ///    in `/mnt/workspace/.serve`, the workspace at `/mnt/workspace`.
150    /// 3. Otherwise a temporary directory, lost when the microVM stops.
151    ///
152    /// Resolved on the first request, not at boot: AgentCore mounts session
153    /// storage only once the session is invoked.
154    pub fn resolve(options: &Options) -> serve::Result<Self> {
155        let workspace_env = std::env::var_os("SERVE_WORKSPACE").map(PathBuf::from);
156        let explicit = options.data_dir.clone().map(Ok).or_else(|| {
157            (env_set("SERVE_DATA_DIR") || env_set("DATABASE_URL")).then(serve::data_dir)
158        });
159        if let Some(data_dir) = explicit {
160            let data_dir = data_dir?;
161            let workspace = options
162                .workspace
163                .clone()
164                .or(workspace_env)
165                .unwrap_or_else(|| data_dir.join("workspace"));
166            return Ok(Self {
167                data_dir,
168                workspace,
169                persistent: true,
170            });
171        }
172        let mount = Path::new(SESSION_STORAGE);
173        if mount.is_dir() {
174            return Ok(Self {
175                data_dir: mount.join(".serve"),
176                workspace: options
177                    .workspace
178                    .clone()
179                    .or(workspace_env)
180                    .unwrap_or_else(|| mount.to_path_buf()),
181                persistent: true,
182            });
183        }
184        let scratch = std::env::temp_dir().join("serve-agentcore");
185        Ok(Self {
186            data_dir: scratch.join(".serve"),
187            workspace: options
188                .workspace
189                .clone()
190                .or(workspace_env)
191                .unwrap_or_else(|| scratch.join("workspace")),
192            persistent: false,
193        })
194    }
195}
196
197fn env_set(name: &str) -> bool {
198    std::env::var_os(name).is_some_and(|value| !value.is_empty())
199}
200
201/// Run the app: with no command, or with `agentcore`, serve the AgentCore
202/// Runtime contract; any other command is serve's (see [`serve::start`]).
203pub async fn start(app: App) -> serve::Result {
204    let mut args: Vec<OsString> = std::env::args_os().collect();
205    match args.get(1).and_then(|arg| arg.to_str()) {
206        None => {}
207        Some("agentcore") => {
208            args.remove(1);
209        }
210        Some(_) => return serve::start(app).await,
211    }
212    let cli = Cli::parse_from(args);
213    let mut options = Options::new(if cli.dev { Mode::Dev } else { Mode::Start });
214    options.agent = cli.agent;
215    let name = app.name().to_string();
216    sqlite_for_session_storage();
217    let (router, agent) = router(app, options.clone())?;
218
219    let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
220    let listener = tokio::net::TcpListener::bind(addr).await?;
221    println!(
222        "serve-agentcore · {name} · agent {agent} · {:?} · listening on {addr}",
223        options.mode
224    );
225    axum::serve(listener, router)
226        .with_graceful_shutdown(async {
227            let _ = tokio::signal::ctrl_c().await;
228        })
229        .await?;
230    Ok(())
231}
232
233/// Make SQLite usable on AgentCore session storage, for this process.
234///
235/// Decision: session storage is an NFSv4 mount whose POSIX byte-range locks
236/// fail, so SQLite's default `unix` VFS (and `unix-excl`) answers every open
237/// with "database is locked". `unix-dotfile` locks with a `.lock` file
238/// beside the database instead, which the mount supports (tried on a live
239/// runtime: `unix` and `unix-excl` fail, `unix-dotfile` and `unix-none`
240/// work). It cannot share a WAL index, so databases stay in rollback-journal
241/// mode: slower writes, the same durability. Only the binary entry calls
242/// this, since the default VFS is process-wide.
243fn sqlite_for_session_storage() {
244    static ONCE: std::sync::Once = std::sync::Once::new();
245    ONCE.call_once(|| {
246        DOTFILE_LOCKS.store(true, std::sync::atomic::Ordering::Relaxed);
247        // SAFETY: `sqlite3_vfs_find` takes a NUL-terminated name and returns
248        // a pointer to a VFS SQLite owns for the life of the process (or
249        // null); registering it as the default only changes which built-in
250        // VFS later `open` calls use. Both initialize SQLite as needed.
251        unsafe {
252            let vfs = rusqlite::ffi::sqlite3_vfs_find(c"unix-dotfile".as_ptr());
253            if !vfs.is_null() {
254                rusqlite::ffi::sqlite3_vfs_register(vfs, 1);
255            }
256        }
257    });
258}
259
260/// Whether this process locks SQLite with dot files (see
261/// [`sqlite_for_session_storage`]).
262static DOTFILE_LOCKS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
263
264/// Remove dot-file locks a stopped microVM left behind. A process killed
265/// mid-write never deletes its `<db>.lock`, and the next boot would wait on
266/// it forever. Safe at boot: no other process uses this session's storage.
267fn clear_stale_locks(dir: &Path) {
268    let Ok(entries) = std::fs::read_dir(dir) else {
269        return;
270    };
271    for entry in entries.flatten() {
272        let path = entry.path();
273        let stale = path
274            .file_name()
275            .and_then(|name| name.to_str())
276            .is_some_and(|name| name.ends_with(".db.lock"));
277        if stale {
278            eprintln!("serve-agentcore: removing stale lock {}", path.display());
279            let _ = std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path));
280        } else if path.is_dir() && entry.file_name() == "everruns" {
281            clear_stale_locks(&path);
282        }
283    }
284}
285
286/// The AgentCore contract around `app`: `/ping`, `/invocations`, and serve's
287/// `/v1` wire API. Returns the router and the agent `/invocations` runs.
288///
289/// The app is checked at once (secrets in `start`, every agent resolved), but
290/// the server that keeps state boots on the first request other than
291/// `/ping`, once AgentCore has mounted session storage.
292pub fn router(app: App, options: Options) -> serve::Result<(Router, String)> {
293    // A throwaway in-memory boot: fails fast on a bad app, names the agent.
294    let probe = boot(app.clone(), &options, None)?;
295    let agent = agent(&probe, options.agent.clone())?;
296    drop(probe);
297    let target = Target {
298        app,
299        options: Arc::new(options),
300        agent: agent.clone(),
301        booted: Arc::new(OnceCell::new()),
302    };
303    let router = Router::new()
304        .route("/ping", get(ping))
305        .route("/invocations", post(invocations))
306        .route("/ws", get(ws))
307        .fallback(forward)
308        .with_state(target);
309    Ok((router, agent))
310}
311
312/// Boot serve with the microVM adapter. `storage: None` boots in memory.
313fn boot(app: App, options: &Options, storage: Option<&Storage>) -> serve::Result<Server> {
314    let workspace = storage
315        .map(|storage| storage.workspace.clone())
316        .or_else(|| options.workspace.clone())
317        .unwrap_or_else(|| PathBuf::from(SESSION_STORAGE));
318    let mut builder =
319        Server::builder(app, options.mode).microvm(move |agent| microvm(agent, &workspace));
320    if let Some(storage) = storage {
321        builder = builder.data_dir(storage.data_dir.clone());
322    }
323    builder.build()
324}
325
326/// `[sandbox] kind = "microvm"` on AgentCore: a real shell and file tools
327/// over the session's workspace.
328///
329/// Decision: no kernel containment inside the microVM. AgentCore gives every
330/// session its own microVM and sanitizes it afterwards, so the VM is the
331/// boundary, the same one Harness and the AgentCore Code Interpreter rely
332/// on. A second, in-VM policy would only stop the agent installing packages
333/// in its own sandbox.
334fn microvm(agent: everruns::AgentBuilder, workspace: &Path) -> everruns::AgentBuilder {
335    agent
336        .workspace(workspace)
337        .workspace_policy(everruns::WorkspacePolicy::read_write())
338        .capability(everruns::HostShell::new().containment(everruns::ContainmentMode::FullAccess))
339}
340
341/// The agent `/invocations` runs: `requested` when it names a top-level
342/// agent, else the app's default agent.
343fn agent(server: &Server, requested: Option<String>) -> serve::Result<String> {
344    match requested {
345        Some(name) => {
346            let known = server
347                .app()
348                .manifest()
349                .agents
350                .iter()
351                .any(|agent| agent.name == name && !agent.sub);
352            if known {
353                Ok(name)
354            } else {
355                Err(anyhow!(
356                    "SERVE_AGENTCORE_AGENT `{name}` is not a top-level agent of this app"
357                ))
358            }
359        }
360        None => server.default_agent().ok_or_else(|| {
361            anyhow!(
362                "this app has several agents and none is the default; set SERVE_AGENTCORE_AGENT"
363            )
364        }),
365    }
366}
367
368#[derive(Clone)]
369struct Target {
370    app: App,
371    options: Arc<Options>,
372    agent: String,
373    booted: Arc<OnceCell<Booted>>,
374}
375
376struct Booted {
377    server: Server,
378    wire: Router,
379}
380
381impl Target {
382    /// The stateful server, booted on first use.
383    async fn booted(&self) -> Result<&Booted, Response> {
384        self.booted
385            .get_or_try_init(|| async {
386                let storage = Storage::resolve(&self.options)?;
387                if !storage.persistent {
388                    eprintln!(
389                        "serve-agentcore: no session storage at {SESSION_STORAGE} and no SERVE_DATA_DIR; \
390                         sessions live in {} and are lost when the microVM stops",
391                        storage.data_dir.display()
392                    );
393                }
394                std::fs::create_dir_all(&storage.data_dir)?;
395                if DOTFILE_LOCKS.load(std::sync::atomic::Ordering::Relaxed) {
396                    clear_stale_locks(&storage.data_dir);
397                }
398                let server = boot(self.app.clone(), &self.options, Some(&storage))?;
399                server.spawn_schedules();
400                let wire = server.router();
401                Ok::<_, serve::Error>(Booted { server, wire })
402            })
403            .await
404            .map_err(|err| {
405                // The platform shows callers only "error (500) from runtime";
406                // the cause has to reach the runtime's CloudWatch logs.
407                eprintln!("serve-agentcore: boot failed: {err:#}");
408                problem(StatusCode::INTERNAL_SERVER_ERROR, format!("{err:#}"))
409            })
410    }
411}
412
413/// `GET /ping`. No `time_of_last_update`: AgentCore tracks status changes
414/// itself, and a timestamp that moves on every ping would keep an idle
415/// session alive until its maximum lifetime. Never boots the server.
416async fn ping(State(target): State<Target>) -> Json<Value> {
417    let busy = target
418        .booted
419        .get()
420        .is_some_and(|booted| booted.server.busy());
421    Json(json!({ "status": if busy { "HealthyBusy" } else { "Healthy" } }))
422}
423
424/// `POST /invocations`: one AG-UI run of the target agent.
425async fn invocations(State(target): State<Target>, headers: HeaderMap, body: Bytes) -> Response {
426    let session = headers
427        .get(SESSION_HEADER)
428        .and_then(|value| value.to_str().ok())
429        .filter(|value| !value.is_empty());
430    let input = match run_agent_input(&body, session) {
431        Ok(input) => input,
432        Err(why) => return problem(StatusCode::BAD_REQUEST, why),
433    };
434    match target.booted().await {
435        Ok(booted) => logged(booted.server.ag_ui(&target.agent, &input).await),
436        Err(response) => response,
437    }
438}
439
440/// Log a run that never started: AgentCore hides the response from callers.
441fn logged(response: Response) -> Response {
442    if !response.status().is_success() {
443        eprintln!("serve-agentcore: invocation answered {}", response.status());
444    }
445    response
446}
447
448/// `GET /ws`: AgentCore's WebSocket transport for the same runs.
449///
450/// Decision: a text message is exactly an `/invocations` body, and the reply
451/// is that run's AG-UI events, one JSON event per text message (the SSE
452/// payloads without their framing). Runs on one connection are sequential,
453/// as on an AG-UI client. A body that cannot run gets a single `RUN_ERROR`
454/// instead of closing the socket, so the client can send the next one.
455async fn ws(
456    State(target): State<Target>,
457    headers: HeaderMap,
458    upgrade: WebSocketUpgrade,
459) -> Response {
460    let session = headers
461        .get(SESSION_HEADER)
462        .and_then(|value| value.to_str().ok())
463        .filter(|value| !value.is_empty())
464        .map(str::to_string);
465    upgrade.on_upgrade(move |socket| converse(target, session, socket))
466}
467
468async fn converse(target: Target, session: Option<String>, mut socket: WebSocket) {
469    while let Some(Ok(message)) = socket.recv().await {
470        let body = match message {
471            Message::Text(text) => Bytes::from(text.as_str().to_owned()),
472            Message::Binary(bytes) => bytes,
473            Message::Close(_) => break,
474            Message::Ping(_) | Message::Pong(_) => continue,
475        };
476        let response = match run_agent_input(&body, session.as_deref()) {
477            Ok(input) => match target.booted().await {
478                Ok(booted) => logged(booted.server.ag_ui(&target.agent, &input).await),
479                Err(response) => response,
480            },
481            Err(why) => problem(StatusCode::BAD_REQUEST, why),
482        };
483        if relay(response, &mut socket).await.is_err() {
484            break;
485        }
486    }
487}
488
489/// Send one run's AG-UI events over the socket. A problem response (the run
490/// never started) becomes one `RUN_ERROR`.
491async fn relay(response: Response, socket: &mut WebSocket) -> Result<(), axum::Error> {
492    use futures_util::StreamExt;
493
494    if !response.status().is_success() {
495        let status = response.status();
496        let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
497            .await
498            .unwrap_or_default();
499        let detail = serde_json::from_slice::<Value>(&body)
500            .ok()
501            .and_then(|problem| problem["detail"].as_str().map(str::to_string))
502            .unwrap_or_else(|| status.to_string());
503        let error =
504            json!({ "type": "RUN_ERROR", "message": detail, "code": status.as_u16().to_string() });
505        return socket.send(Message::Text(error.to_string().into())).await;
506    }
507    let mut stream = response.into_body().into_data_stream();
508    let mut pending = String::new();
509    while let Some(chunk) = stream.next().await {
510        let Ok(chunk) = chunk else { break };
511        pending.push_str(&String::from_utf8_lossy(&chunk));
512        while let Some(end) = pending.find("\n\n") {
513            let frame: String = pending.drain(..end + 2).collect();
514            if let Some(data) = sse_data(&frame) {
515                socket.send(Message::Text(data.into())).await?;
516            }
517        }
518    }
519    if let Some(data) = sse_data(&pending) {
520        socket.send(Message::Text(data.into())).await?;
521    }
522    Ok(())
523}
524
525/// The `data:` payload of an SSE frame, joined across lines.
526fn sse_data(frame: &str) -> Option<String> {
527    let lines: Vec<&str> = frame
528        .lines()
529        .filter_map(|line| line.strip_prefix("data:"))
530        .map(|data| data.strip_prefix(' ').unwrap_or(data))
531        .collect();
532    (!lines.is_empty()).then(|| lines.join("\n"))
533}
534
535/// Everything else: serve's `/v1` wire API and `/health`.
536async fn forward(State(target): State<Target>, request: Request<Body>) -> Response {
537    match target.booted().await {
538        Ok(booted) => match booted.wire.clone().oneshot(request).await {
539            Ok(response) => response,
540            Err(never) => match never {},
541        },
542        Err(response) => response,
543    }
544}
545
546/// The AG-UI `RunAgentInput` body for an invocation.
547///
548/// A body with `messages` is already `RunAgentInput` and passes through,
549/// with `threadId` (and `runId`) filled in when absent. A `{"prompt": ...}`
550/// body becomes a one-message run. The thread is the body's `threadId`, else
551/// the AgentCore session, so one AgentCore session is one serve session.
552fn run_agent_input(body: &[u8], session: Option<&str>) -> Result<Vec<u8>, String> {
553    let mut input: Value =
554        serde_json::from_slice(body).map_err(|err| format!("invalid JSON body: {err}"))?;
555    let Some(fields) = input.as_object_mut() else {
556        return Err("the body must be a JSON object".into());
557    };
558    if !fields.contains_key("messages") {
559        let Some(prompt) = fields.remove("prompt") else {
560            return Err(
561                "expected an AG-UI RunAgentInput (with `messages`) or `{\"prompt\": \"...\"}`"
562                    .into(),
563            );
564        };
565        let Some(prompt) = prompt.as_str().map(str::to_string) else {
566            return Err("`prompt` must be a string".into());
567        };
568        fields.insert(
569            "messages".into(),
570            json!([{ "id": new_id(), "role": "user", "content": prompt }]),
571        );
572        for key in ["tools", "context"] {
573            fields.entry(key).or_insert_with(|| json!([]));
574        }
575        for key in ["state", "forwardedProps"] {
576            fields.entry(key).or_insert_with(|| json!({}));
577        }
578    }
579    let has_thread = fields
580        .get("threadId")
581        .and_then(Value::as_str)
582        .is_some_and(|thread| !thread.trim().is_empty());
583    if !has_thread {
584        let Some(session) = session else {
585            return Err(format!(
586                "no `threadId` in the body and no `{SESSION_HEADER}` header"
587            ));
588        };
589        fields.insert("threadId".into(), json!(session));
590    }
591    fields.entry("runId").or_insert_with(|| json!(new_id()));
592    serde_json::to_vec(&input).map_err(|err| err.to_string())
593}
594
595fn new_id() -> String {
596    uuid::Uuid::now_v7().to_string()
597}
598
599/// RFC 9457 problem details, as serve's own routes answer.
600fn problem(status: StatusCode, detail: String) -> Response {
601    let body = json!({
602        "title": status.canonical_reason().unwrap_or("Error"),
603        "status": status.as_u16(),
604        "detail": detail,
605    });
606    (
607        status,
608        [(header::CONTENT_TYPE, "application/problem+json")],
609        body.to_string(),
610    )
611        .into_response()
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    fn parse(bytes: Vec<u8>) -> Value {
619        serde_json::from_slice(&bytes).unwrap()
620    }
621
622    #[test]
623    fn prompt_becomes_a_one_message_run_on_the_agentcore_session() {
624        let input = parse(run_agent_input(br#"{"prompt":"hi"}"#, Some("s-1")).unwrap());
625        assert_eq!(input["threadId"], "s-1");
626        assert_eq!(input["messages"][0]["role"], "user");
627        assert_eq!(input["messages"][0]["content"], "hi");
628        assert!(input["runId"].as_str().is_some_and(|id| !id.is_empty()));
629        assert_eq!(input["tools"], json!([]));
630        assert_eq!(input["forwardedProps"], json!({}));
631        assert!(input.get("prompt").is_none());
632    }
633
634    #[test]
635    fn run_agent_input_passes_through_and_keeps_its_thread() {
636        let body = json!({
637            "threadId": "t-1", "runId": "r-1",
638            "messages": [{ "id": "m", "role": "user", "content": "x" }],
639            "tools": [], "context": [], "state": {}, "forwardedProps": {}
640        });
641        let input = parse(run_agent_input(body.to_string().as_bytes(), Some("s-1")).unwrap());
642        assert_eq!(input, body);
643    }
644
645    #[test]
646    fn missing_thread_falls_back_to_the_session_header() {
647        let body = br#"{"messages":[{"id":"m","role":"user","content":"x"}],"threadId":" "}"#;
648        let input = parse(run_agent_input(body, Some("s-2")).unwrap());
649        assert_eq!(input["threadId"], "s-2");
650    }
651
652    #[test]
653    fn rejects_bodies_it_cannot_run() {
654        let no_thread = run_agent_input(br#"{"prompt":"hi"}"#, None).unwrap_err();
655        assert!(no_thread.contains(SESSION_HEADER), "{no_thread}");
656        assert!(run_agent_input(b"not json", Some("s")).is_err());
657        assert!(run_agent_input(b"[]", Some("s")).is_err());
658        assert!(run_agent_input(br#"{"prompt":7}"#, Some("s")).is_err());
659        assert!(run_agent_input(br#"{"input":"hi"}"#, Some("s")).is_err());
660    }
661}