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