refactor: rename engine/ to server/

This commit is contained in:
Nathan Flurry 2026-01-25 14:14:58 -08:00
parent 016024c04b
commit 71ab40388c
37 changed files with 917 additions and 3 deletions

View file

@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["engine/packages/*"]
members = ["server/packages/*"]
[workspace.package]
version = "0.1.0"

View file

@ -8,6 +8,12 @@ Universal API for running Claude Code, Codex, OpenCode, and Amp inside sandboxes
- **Supports your sandbox provider**: Daytona, E2B, Vercel Sandboxes, and more
- **Lightweight, portable Rust binary**: Install anywhere with 1 curl command
## Architecture
- TODO
- Embedded (runs agents locally)
- Sandboxed
## Project Goals
This project aims to solve 3 problems with agents:

View file

@ -31,6 +31,24 @@ await client.postMessage("my-session", { message: "Hello" });
const events = await client.getEvents("my-session", { offset: 0, limit: 50 });
```
## Autospawn (Node only)
```ts
import { connectSandboxDaemonClient } from "sandbox-agent";
const client = await connectSandboxDaemonClient({
spawn: { enabled: true },
});
await client.createSession("my-session", { agent: "claude" });
await client.postMessage("my-session", { message: "Hello" });
await client.dispose();
```
Autospawn uses the local `sandbox-agent` binary. Install `@sandbox-agent/cli` (recommended), or
set `SANDBOX_AGENT_BIN` to the binary path.
## Endpoint mapping
<details>

View file

@ -317,7 +317,7 @@ function publishCrates(rootDir: string, version: string) {
for (const crate of CRATE_ORDER) {
console.log(`==> Publishing sandbox-agent-${crate}`);
const crateDir = path.join(rootDir, "engine", "packages", crate);
const crateDir = path.join(rootDir, "server", "packages", crate);
run("cargo", ["publish", "--allow-dirty"], { cwd: crateDir });
// Wait for crates.io index propagation
console.log("Waiting 30s for index...");

View file

@ -1,4 +1,8 @@
import type { components } from "./generated/openapi.js";
import type {
SandboxDaemonSpawnHandle,
SandboxDaemonSpawnOptions,
} from "./spawn.js";
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
@ -25,6 +29,14 @@ export interface SandboxDaemonClientOptions {
headers?: HeadersInit;
}
export interface SandboxDaemonConnectOptions {
baseUrl?: string;
token?: string;
fetch?: typeof fetch;
headers?: HeadersInit;
spawn?: SandboxDaemonSpawnOptions | boolean;
}
export class SandboxDaemonError extends Error {
readonly status: number;
readonly problem?: ProblemDetails;
@ -53,6 +65,7 @@ export class SandboxDaemonClient {
private readonly token?: string;
private readonly fetcher: typeof fetch;
private readonly defaultHeaders?: HeadersInit;
private spawnHandle?: SandboxDaemonSpawnHandle;
constructor(options: SandboxDaemonClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
@ -65,6 +78,32 @@ export class SandboxDaemonClient {
}
}
static async connect(options: SandboxDaemonConnectOptions): Promise<SandboxDaemonClient> {
const spawnOptions = normalizeSpawnOptions(options.spawn, !options.baseUrl);
if (!spawnOptions.enabled) {
if (!options.baseUrl) {
throw new Error("baseUrl is required when autospawn is disabled.");
}
return new SandboxDaemonClient({
baseUrl: options.baseUrl,
token: options.token,
fetch: options.fetch,
headers: options.headers,
});
}
const { spawnSandboxDaemon } = await import("./spawn.js");
const handle = await spawnSandboxDaemon(spawnOptions, options.fetch ?? globalThis.fetch);
const client = new SandboxDaemonClient({
baseUrl: handle.baseUrl,
token: handle.token,
fetch: options.fetch,
headers: options.headers,
});
client.spawnHandle = handle;
return client;
}
async listAgents(): Promise<AgentListResponse> {
return this.requestJson("GET", `${API_PREFIX}/agents`);
}
@ -171,6 +210,13 @@ export class SandboxDaemonClient {
);
}
async dispose(): Promise<void> {
if (this.spawnHandle) {
await this.spawnHandle.dispose();
this.spawnHandle = undefined;
}
}
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.requestRaw(method, path, {
query: options.query,
@ -252,3 +298,22 @@ export class SandboxDaemonClient {
export const createSandboxDaemonClient = (options: SandboxDaemonClientOptions): SandboxDaemonClient => {
return new SandboxDaemonClient(options);
};
export const connectSandboxDaemonClient = (
options: SandboxDaemonConnectOptions,
): Promise<SandboxDaemonClient> => {
return SandboxDaemonClient.connect(options);
};
const normalizeSpawnOptions = (
spawn: SandboxDaemonSpawnOptions | boolean | undefined,
defaultEnabled: boolean,
): SandboxDaemonSpawnOptions => {
if (typeof spawn === "boolean") {
return { enabled: spawn };
}
if (spawn) {
return { enabled: spawn.enabled ?? defaultEnabled, ...spawn };
}
return { enabled: defaultEnabled };
};

View file

@ -1,4 +1,9 @@
export { SandboxDaemonClient, SandboxDaemonError, createSandboxDaemonClient } from "./client.js";
export {
SandboxDaemonClient,
SandboxDaemonError,
connectSandboxDaemonClient,
createSandboxDaemonClient,
} from "./client.js";
export type {
AgentInfo,
AgentInstallRequest,
@ -15,5 +20,8 @@ export type {
ProblemDetails,
QuestionReplyRequest,
UniversalEvent,
SandboxDaemonClientOptions,
SandboxDaemonConnectOptions,
} from "./client.js";
export type { components, paths } from "./generated/openapi.js";
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";

View file

@ -0,0 +1,221 @@
import type { ChildProcess } from "node:child_process";
import type { AddressInfo } from "node:net";
import type { NodeRequire } from "node:module";
export type SandboxDaemonSpawnLogMode = "inherit" | "pipe" | "silent";
export type SandboxDaemonSpawnOptions = {
enabled?: boolean;
host?: string;
port?: number;
token?: string;
binaryPath?: string;
timeoutMs?: number;
log?: SandboxDaemonSpawnLogMode;
env?: Record<string, string>;
};
export type SandboxDaemonSpawnHandle = {
baseUrl: string;
token: string;
child: ChildProcess;
dispose: () => Promise<void>;
};
const PLATFORM_PACKAGES: Record<string, string> = {
"darwin-arm64": "@sandbox-agent/cli-darwin-arm64",
"darwin-x64": "@sandbox-agent/cli-darwin-x64",
"linux-x64": "@sandbox-agent/cli-linux-x64",
"win32-x64": "@sandbox-agent/cli-win32-x64",
};
export function isNodeRuntime(): boolean {
return typeof process !== "undefined" && !!process.versions?.node;
}
export async function spawnSandboxDaemon(
options: SandboxDaemonSpawnOptions,
fetcher?: typeof fetch,
): Promise<SandboxDaemonSpawnHandle> {
if (!isNodeRuntime()) {
throw new Error("Autospawn requires a Node.js runtime.");
}
const {
spawn,
} = await import("node:child_process");
const crypto = await import("node:crypto");
const fs = await import("node:fs");
const path = await import("node:path");
const net = await import("node:net");
const { createRequire } = await import("node:module");
const host = options.host ?? "127.0.0.1";
const port = options.port ?? (await getFreePort(net, host));
const token = options.token ?? crypto.randomBytes(24).toString("hex");
const timeoutMs = options.timeoutMs ?? 15_000;
const logMode: SandboxDaemonSpawnLogMode = options.log ?? "inherit";
const binaryPath =
options.binaryPath ??
resolveBinaryFromEnv(fs, path) ??
resolveBinaryFromCliPackage(createRequire(import.meta.url), path, fs) ??
resolveBinaryFromPath(fs, path);
if (!binaryPath) {
throw new Error("sandbox-agent binary not found. Install @sandbox-agent/cli or set SANDBOX_AGENT_BIN.");
}
const stdio = logMode === "inherit" ? "inherit" : logMode === "silent" ? "ignore" : "pipe";
const args = ["--host", host, "--port", String(port), "--token", token];
const child = spawn(binaryPath, args, {
stdio,
env: {
...process.env,
...(options.env ?? {}),
},
});
const cleanup = registerProcessCleanup(child);
const baseUrl = `http://${host}:${port}`;
const ready = waitForHealth(baseUrl, fetcher ?? globalThis.fetch, timeoutMs, child);
await ready;
const dispose = async () => {
if (child.exitCode !== null) {
cleanup.dispose();
return;
}
child.kill("SIGTERM");
const exited = await waitForExit(child, 5_000);
if (!exited) {
child.kill("SIGKILL");
}
cleanup.dispose();
};
return { baseUrl, token, child, dispose };
}
function resolveBinaryFromEnv(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null {
const value = process.env.SANDBOX_AGENT_BIN;
if (!value) {
return null;
}
const resolved = path.resolve(value);
if (fs.existsSync(resolved)) {
return resolved;
}
return null;
}
function resolveBinaryFromCliPackage(
require: NodeRequire,
path: typeof import("node:path"),
fs: typeof import("node:fs"),
): string | null {
const key = `${process.platform}-${process.arch}`;
const pkg = PLATFORM_PACKAGES[key];
if (!pkg) {
return null;
}
try {
const pkgPath = require.resolve(`${pkg}/package.json`);
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
const resolved = path.join(path.dirname(pkgPath), "bin", bin);
return fs.existsSync(resolved) ? resolved : null;
} catch {
return null;
}
}
function resolveBinaryFromPath(fs: typeof import("node:fs"), path: typeof import("node:path")): string | null {
const pathEnv = process.env.PATH ?? "";
const separator = process.platform === "win32" ? ";" : ":";
const candidates = pathEnv.split(separator).filter(Boolean);
const bin = process.platform === "win32" ? "sandbox-agent.exe" : "sandbox-agent";
for (const dir of candidates) {
const resolved = path.join(dir, bin);
if (fs.existsSync(resolved)) {
return resolved;
}
}
return null;
}
async function getFreePort(net: typeof import("node:net"), host: string): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, host, () => {
const address = server.address() as AddressInfo;
server.close(() => resolve(address.port));
});
});
}
async function waitForHealth(
baseUrl: string,
fetcher: typeof fetch | undefined,
timeoutMs: number,
child: ChildProcess,
): Promise<void> {
if (!fetcher) {
throw new Error("Fetch API is not available; provide a fetch implementation.");
}
const start = Date.now();
let lastError: string | undefined;
while (Date.now() - start < timeoutMs) {
if (child.exitCode !== null) {
throw new Error("sandbox-agent exited before becoming healthy.");
}
try {
const response = await fetcher(`${baseUrl}/v1/health`);
if (response.ok) {
return;
}
lastError = `status ${response.status}`;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`Timed out waiting for sandbox-agent health (${lastError ?? "unknown error"}).`);
}
async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
if (child.exitCode !== null) {
return true;
}
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeoutMs);
child.once("exit", () => {
clearTimeout(timer);
resolve(true);
});
});
}
function registerProcessCleanup(child: ChildProcess): { dispose: () => void } {
const handler = () => {
if (child.exitCode === null) {
child.kill("SIGTERM");
}
};
process.once("exit", handler);
process.once("SIGINT", handler);
process.once("SIGTERM", handler);
return {
dispose: () => {
process.off("exit", handler);
process.off("SIGINT", handler);
process.off("SIGTERM", handler);
},
};
}

View file

@ -1,2 +1,3 @@
pub mod agents;
pub mod credentials;
pub mod testing;

View file

@ -0,0 +1,127 @@
use std::env;
use thiserror::Error;
use crate::agents::AgentId;
use crate::credentials::{AuthType, ExtractedCredentials, ProviderCredentials};
#[derive(Debug, Clone)]
pub struct TestAgentConfig {
pub agent: AgentId,
pub credentials: ExtractedCredentials,
}
#[derive(Debug, Error)]
pub enum TestAgentConfigError {
#[error("no test agents configured (set SANDBOX_TEST_AGENTS)")]
NoAgentsConfigured,
#[error("unknown agent name: {0}")]
UnknownAgent(String),
#[error("missing credentials for {agent}: {missing}")]
MissingCredentials { agent: AgentId, missing: String },
}
const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS";
const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY";
const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY";
pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigError> {
let raw_agents = env::var(AGENTS_ENV).unwrap_or_default();
let mut agents = Vec::new();
for entry in raw_agents.split(',') {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
if trimmed == "all" {
agents.extend([
AgentId::Claude,
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
]);
continue;
}
let agent = AgentId::parse(trimmed)
.ok_or_else(|| TestAgentConfigError::UnknownAgent(trimmed.to_string()))?;
agents.push(agent);
}
if agents.is_empty() {
return Err(TestAgentConfigError::NoAgentsConfigured);
}
let anthropic_key = read_env_key(ANTHROPIC_ENV);
let openai_key = read_env_key(OPENAI_ENV);
let mut configs = Vec::new();
for agent in agents {
let credentials = match agent {
AgentId::Claude | AgentId::Amp => {
let anthropic_key = anthropic_key.clone().ok_or_else(|| {
TestAgentConfigError::MissingCredentials {
agent,
missing: ANTHROPIC_ENV.to_string(),
}
})?;
credentials_with(anthropic_key, None)
}
AgentId::Codex => {
let openai_key = openai_key.clone().ok_or_else(|| {
TestAgentConfigError::MissingCredentials {
agent,
missing: OPENAI_ENV.to_string(),
}
})?;
credentials_with(None, Some(openai_key))
}
AgentId::Opencode => {
if anthropic_key.is_none() && openai_key.is_none() {
return Err(TestAgentConfigError::MissingCredentials {
agent,
missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"),
});
}
credentials_with(anthropic_key.clone(), openai_key.clone())
}
};
configs.push(TestAgentConfig { agent, credentials });
}
Ok(configs)
}
fn read_env_key(name: &str) -> Option<String> {
env::var(name).ok().and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
}
fn credentials_with(
anthropic_key: Option<String>,
openai_key: Option<String>,
) -> ExtractedCredentials {
let mut credentials = ExtractedCredentials::default();
if let Some(key) = anthropic_key {
credentials.anthropic = Some(ProviderCredentials {
api_key: key,
source: "sandbox-test-env".to_string(),
auth_type: AuthType::ApiKey,
provider: "anthropic".to_string(),
});
}
if let Some(key) = openai_key {
credentials.openai = Some(ProviderCredentials {
api_key: key,
source: "sandbox-test-env".to_string(),
auth_type: AuthType::ApiKey,
provider: "openai".to_string(),
});
}
credentials
}

View file

@ -33,4 +33,7 @@ tracing-logfmt = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
http-body-util = "0.1"
insta = "1.41"
tempfile = "3.10"
tower = "0.4"

View file

@ -0,0 +1,465 @@
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::Router;
use futures::StreamExt;
use http_body_util::BodyExt;
use serde_json::{json, Map, Value};
use tempfile::TempDir;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
use sandbox_agent_agent_management::testing::{test_agents_from_env, TestAgentConfig};
use sandbox_agent_agent_credentials::ExtractedCredentials;
use sandbox_agent_core::router::{build_router, AppState, AuthConfig};
use tower::ServiceExt;
const PROMPT: &str = "Reply with exactly the single word OK.";
struct TestApp {
app: Router,
_install_dir: TempDir,
}
impl TestApp {
fn new() -> Self {
let install_dir = tempfile::tempdir().expect("create temp install dir");
let manager = AgentManager::new(install_dir.path())
.expect("create agent manager");
let state = AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
app,
_install_dir: install_dir,
}
}
}
struct EnvGuard {
saved: BTreeMap<String, Option<String>>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard {
let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"];
let mut saved = BTreeMap::new();
for key in keys {
saved.insert(key.to_string(), std::env::var(key).ok());
}
match creds.anthropic.as_ref() {
Some(cred) => {
std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key);
std::env::set_var("CLAUDE_API_KEY", &cred.api_key);
}
None => {
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("CLAUDE_API_KEY");
}
}
match creds.openai.as_ref() {
Some(cred) => {
std::env::set_var("OPENAI_API_KEY", &cred.api_key);
std::env::set_var("CODEX_API_KEY", &cred.api_key);
}
None => {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("CODEX_API_KEY");
}
}
EnvGuard { saved }
}
async fn send_json(app: &Router, method: Method, path: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(path);
let body = if let Some(body) = body {
builder = builder.header("content-type", "application/json");
Body::from(body.to_string())
} else {
Body::empty()
};
let request = builder.body(body).expect("request");
let response = app
.clone()
.oneshot(request)
.await
.expect("request handled");
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.expect("read body")
.to_bytes();
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).to_string()))
};
(status, value)
}
async fn send_status(app: &Router, method: Method, path: &str, body: Option<Value>) -> StatusCode {
let (status, _) = send_json(app, method, path, body).await;
status
}
async fn install_agent(app: &Router, agent: AgentId) {
let status = send_status(
app,
Method::POST,
&format!("/v1/agents/{}/install", agent.as_str()),
Some(json!({})),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "install {agent}");
}
async fn create_session(app: &Router, agent: AgentId, session_id: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({
"agent": agent.as_str(),
"permissionMode": "bypass"
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session {agent}");
}
async fn send_message(app: &Router, session_id: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": PROMPT })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send message");
}
async fn poll_events_until(
app: &Router,
session_id: &str,
timeout: Duration,
) -> Vec<Value> {
let start = Instant::now();
let mut offset = 0u64;
let mut events = Vec::new();
while start.elapsed() < timeout {
let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200");
let (status, payload) = send_json(app, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK, "poll events");
let new_events = payload
.get("events")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !new_events.is_empty() {
if let Some(last) = new_events.last().and_then(|event| event.get("id")).and_then(Value::as_u64) {
offset = last;
}
events.extend(new_events);
if should_stop(&events) {
break;
}
}
tokio::time::sleep(Duration::from_millis(800)).await;
}
events
}
async fn read_sse_events(
app: &Router,
session_id: &str,
timeout: Duration,
) -> Vec<Value> {
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/sessions/{session_id}/events/sse?offset=0"))
.body(Body::empty())
.expect("sse request");
let response = app
.clone()
.oneshot(request)
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK, "sse status");
let mut stream = response.into_body().into_data_stream();
let mut buffer = String::new();
let mut events = Vec::new();
let start = Instant::now();
loop {
let remaining = match timeout.checked_sub(start.elapsed()) {
Some(remaining) if !remaining.is_zero() => remaining,
_ => break,
};
let next = tokio::time::timeout(remaining, stream.next()).await;
let chunk = match next {
Ok(Some(Ok(chunk))) => chunk,
Ok(Some(Err(_))) => break,
Ok(None) => break,
Err(_) => break,
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(idx) = buffer.find("\n\n") {
let block = buffer[..idx].to_string();
buffer = buffer[idx + 2..].to_string();
if let Some(event) = parse_sse_block(&block) {
events.push(event);
}
}
if should_stop(&events) {
break;
}
}
events
}
fn parse_sse_block(block: &str) -> Option<Value> {
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start());
}
}
if data_lines.is_empty() {
return None;
}
let data = data_lines.join("\n");
serde_json::from_str(&data).ok()
}
fn should_stop(events: &[Value]) -> bool {
events.iter().any(|event| is_assistant_message(event) || is_error_event(event))
}
fn is_assistant_message(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("message"))
.and_then(|message| message.get("role"))
.and_then(Value::as_str)
.map(|role| role == "assistant")
.unwrap_or(false)
}
fn is_error_event(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("error"))
.is_some()
}
fn normalize_events(events: &[Value]) -> Value {
let normalized = events
.iter()
.enumerate()
.map(|(idx, event)| normalize_event(event, idx + 1))
.collect::<Vec<_>>();
Value::Array(normalized)
}
fn normalize_event(event: &Value, seq: usize) -> Value {
let mut map = Map::new();
map.insert("seq".to_string(), Value::Number(seq.into()));
if let Some(agent) = event.get("agent").and_then(Value::as_str) {
map.insert("agent".to_string(), Value::String(agent.to_string()));
}
let data = event.get("data").unwrap_or(&Value::Null);
if let Some(message) = data.get("message") {
map.insert("kind".to_string(), Value::String("message".to_string()));
map.insert("message".to_string(), normalize_message(message));
} else if let Some(started) = data.get("started") {
map.insert("kind".to_string(), Value::String("started".to_string()));
map.insert("started".to_string(), normalize_started(started));
} else if let Some(error) = data.get("error") {
map.insert("kind".to_string(), Value::String("error".to_string()));
map.insert("error".to_string(), normalize_error(error));
} else if let Some(question) = data.get("questionAsked") {
map.insert("kind".to_string(), Value::String("question".to_string()));
map.insert("question".to_string(), normalize_question(question));
} else if let Some(permission) = data.get("permissionAsked") {
map.insert("kind".to_string(), Value::String("permission".to_string()));
map.insert("permission".to_string(), normalize_permission(permission));
} else {
map.insert("kind".to_string(), Value::String("unknown".to_string()));
}
Value::Object(map)
}
fn normalize_message(message: &Value) -> Value {
let mut map = Map::new();
if let Some(role) = message.get("role").and_then(Value::as_str) {
map.insert("role".to_string(), Value::String(role.to_string()));
}
if let Some(parts) = message.get("parts").and_then(Value::as_array) {
let parts = parts.iter().map(normalize_part).collect::<Vec<_>>();
map.insert("parts".to_string(), Value::Array(parts));
} else if message.get("raw").is_some() {
map.insert("unparsed".to_string(), Value::Bool(true));
}
Value::Object(map)
}
fn normalize_part(part: &Value) -> Value {
let mut map = Map::new();
if let Some(part_type) = part.get("type").and_then(Value::as_str) {
map.insert("type".to_string(), Value::String(part_type.to_string()));
}
if let Some(name) = part.get("name").and_then(Value::as_str) {
map.insert("name".to_string(), Value::String(name.to_string()));
}
if part.get("text").is_some() {
map.insert("text".to_string(), Value::String("<redacted>".to_string()));
}
if part.get("input").is_some() {
map.insert("input".to_string(), Value::Bool(true));
}
if part.get("output").is_some() {
map.insert("output".to_string(), Value::Bool(true));
}
Value::Object(map)
}
fn normalize_started(started: &Value) -> Value {
let mut map = Map::new();
if let Some(message) = started.get("message").and_then(Value::as_str) {
map.insert("message".to_string(), Value::String(message.to_string()));
}
Value::Object(map)
}
fn normalize_error(error: &Value) -> Value {
let mut map = Map::new();
if let Some(kind) = error.get("kind").and_then(Value::as_str) {
map.insert("kind".to_string(), Value::String(kind.to_string()));
}
if let Some(message) = error.get("message").and_then(Value::as_str) {
map.insert("message".to_string(), Value::String(message.to_string()));
}
Value::Object(map)
}
fn normalize_question(question: &Value) -> Value {
let mut map = Map::new();
if question.get("id").is_some() {
map.insert("id".to_string(), Value::String("<redacted>".to_string()));
}
if let Some(questions) = question.get("questions").and_then(Value::as_array) {
map.insert("count".to_string(), Value::Number(questions.len().into()));
}
Value::Object(map)
}
fn normalize_permission(permission: &Value) -> Value {
let mut map = Map::new();
if permission.get("id").is_some() {
map.insert("id".to_string(), Value::String("<redacted>".to_string()));
}
if let Some(value) = permission.get("permission").and_then(Value::as_str) {
map.insert("permission".to_string(), Value::String(value.to_string()));
}
Value::Object(map)
}
fn snapshot_name(prefix: &str, agent: AgentId) -> String {
format!("{prefix}_{}", agent.as_str())
}
async fn run_http_events_snapshot(app: &Router, config: &TestAgentConfig) {
let _guard = apply_credentials(&config.credentials);
install_agent(app, config.agent).await;
let session_id = format!("session-{}", config.agent.as_str());
create_session(app, config.agent, &session_id).await;
send_message(app, &session_id).await;
let events = poll_events_until(app, &session_id, Duration::from_secs(120)).await;
assert!(
!events.is_empty(),
"no events collected for {}",
config.agent
);
assert!(
should_stop(&events),
"timed out waiting for assistant/error event for {}",
config.agent
);
let normalized = normalize_events(&events);
insta::with_settings!({
snapshot_suffix => snapshot_name("http_events", config.agent),
}, {
insta::assert_yaml_snapshot!(normalized);
});
}
async fn run_sse_events_snapshot(app: &Router, config: &TestAgentConfig) {
let _guard = apply_credentials(&config.credentials);
install_agent(app, config.agent).await;
let session_id = format!("sse-{}", config.agent.as_str());
create_session(app, config.agent, &session_id).await;
let sse_task = {
let app = app.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
read_sse_events(&app, &session_id, Duration::from_secs(120)).await
})
};
send_message(app, &session_id).await;
let events = sse_task.await.expect("sse task");
assert!(
!events.is_empty(),
"no sse events collected for {}",
config.agent
);
assert!(
should_stop(&events),
"timed out waiting for assistant/error event for {}",
config.agent
);
let normalized = normalize_events(&events);
insta::with_settings!({
snapshot_suffix => snapshot_name("sse_events", config.agent),
}, {
insta::assert_yaml_snapshot!(normalized);
});
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_events_snapshots() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS");
let app = TestApp::new();
for config in &configs {
run_http_events_snapshot(&app.app, config).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sse_events_snapshots() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS");
let app = TestApp::new();
for config in &configs {
run_sse_events_snapshot(&app.app, config).await;
}
}