mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
refactor: rename engine/ to server/
This commit is contained in:
parent
016024c04b
commit
71ab40388c
37 changed files with 917 additions and 3 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["engine/packages/*"]
|
members = ["server/packages/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -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
|
- **Supports your sandbox provider**: Daytona, E2B, Vercel Sandboxes, and more
|
||||||
- **Lightweight, portable Rust binary**: Install anywhere with 1 curl command
|
- **Lightweight, portable Rust binary**: Install anywhere with 1 curl command
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- TODO
|
||||||
|
- Embedded (runs agents locally)
|
||||||
|
- Sandboxed
|
||||||
|
|
||||||
## Project Goals
|
## Project Goals
|
||||||
|
|
||||||
This project aims to solve 3 problems with agents:
|
This project aims to solve 3 problems with agents:
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,24 @@ await client.postMessage("my-session", { message: "Hello" });
|
||||||
const events = await client.getEvents("my-session", { offset: 0, limit: 50 });
|
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
|
## Endpoint mapping
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,7 @@ function publishCrates(rootDir: string, version: string) {
|
||||||
|
|
||||||
for (const crate of CRATE_ORDER) {
|
for (const crate of CRATE_ORDER) {
|
||||||
console.log(`==> Publishing sandbox-agent-${crate}`);
|
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 });
|
run("cargo", ["publish", "--allow-dirty"], { cwd: crateDir });
|
||||||
// Wait for crates.io index propagation
|
// Wait for crates.io index propagation
|
||||||
console.log("Waiting 30s for index...");
|
console.log("Waiting 30s for index...");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { components } from "./generated/openapi.js";
|
import type { components } from "./generated/openapi.js";
|
||||||
|
import type {
|
||||||
|
SandboxDaemonSpawnHandle,
|
||||||
|
SandboxDaemonSpawnOptions,
|
||||||
|
} from "./spawn.js";
|
||||||
|
|
||||||
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
|
export type AgentInstallRequest = components["schemas"]["AgentInstallRequest"];
|
||||||
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
|
export type AgentModeInfo = components["schemas"]["AgentModeInfo"];
|
||||||
|
|
@ -25,6 +29,14 @@ export interface SandboxDaemonClientOptions {
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SandboxDaemonConnectOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
spawn?: SandboxDaemonSpawnOptions | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class SandboxDaemonError extends Error {
|
export class SandboxDaemonError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
readonly problem?: ProblemDetails;
|
readonly problem?: ProblemDetails;
|
||||||
|
|
@ -53,6 +65,7 @@ export class SandboxDaemonClient {
|
||||||
private readonly token?: string;
|
private readonly token?: string;
|
||||||
private readonly fetcher: typeof fetch;
|
private readonly fetcher: typeof fetch;
|
||||||
private readonly defaultHeaders?: HeadersInit;
|
private readonly defaultHeaders?: HeadersInit;
|
||||||
|
private spawnHandle?: SandboxDaemonSpawnHandle;
|
||||||
|
|
||||||
constructor(options: SandboxDaemonClientOptions) {
|
constructor(options: SandboxDaemonClientOptions) {
|
||||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
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> {
|
async listAgents(): Promise<AgentListResponse> {
|
||||||
return this.requestJson("GET", `${API_PREFIX}/agents`);
|
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> {
|
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const response = await this.requestRaw(method, path, {
|
const response = await this.requestRaw(method, path, {
|
||||||
query: options.query,
|
query: options.query,
|
||||||
|
|
@ -252,3 +298,22 @@ export class SandboxDaemonClient {
|
||||||
export const createSandboxDaemonClient = (options: SandboxDaemonClientOptions): SandboxDaemonClient => {
|
export const createSandboxDaemonClient = (options: SandboxDaemonClientOptions): SandboxDaemonClient => {
|
||||||
return new SandboxDaemonClient(options);
|
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 };
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
export { SandboxDaemonClient, SandboxDaemonError, createSandboxDaemonClient } from "./client.js";
|
export {
|
||||||
|
SandboxDaemonClient,
|
||||||
|
SandboxDaemonError,
|
||||||
|
connectSandboxDaemonClient,
|
||||||
|
createSandboxDaemonClient,
|
||||||
|
} from "./client.js";
|
||||||
export type {
|
export type {
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentInstallRequest,
|
AgentInstallRequest,
|
||||||
|
|
@ -15,5 +20,8 @@ export type {
|
||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
QuestionReplyRequest,
|
QuestionReplyRequest,
|
||||||
UniversalEvent,
|
UniversalEvent,
|
||||||
|
SandboxDaemonClientOptions,
|
||||||
|
SandboxDaemonConnectOptions,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
export type { components, paths } from "./generated/openapi.js";
|
export type { components, paths } from "./generated/openapi.js";
|
||||||
|
export type { SandboxDaemonSpawnOptions, SandboxDaemonSpawnLogMode } from "./spawn.js";
|
||||||
|
|
|
||||||
221
sdks/typescript/src/spawn.ts
Normal file
221
sdks/typescript/src/spawn.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
|
pub mod testing;
|
||||||
127
server/packages/agent-management/src/testing.rs
Normal file
127
server/packages/agent-management/src/testing.rs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -33,4 +33,7 @@ tracing-logfmt = "0.3"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1"
|
||||||
|
insta = "1.41"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
|
tower = "0.4"
|
||||||
465
server/packages/sandbox-agent/tests/http_sse_snapshots.rs
Normal file
465
server/packages/sandbox-agent/tests/http_sse_snapshots.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue